add: finish ui rewrite

This commit is contained in:
trisua 2025-06-01 12:25:33 -04:00
parent e9846016e6
commit 5dec98d698
119 changed files with 8776 additions and 9350 deletions

8
Cargo.lock generated
View file

@ -3275,7 +3275,7 @@ dependencies = [
[[package]]
name = "tetratto"
version = "4.0.0"
version = "4.5.0"
dependencies = [
"ammonia",
"async-stripe",
@ -3307,7 +3307,7 @@ dependencies = [
[[package]]
name = "tetratto-core"
version = "4.0.0"
version = "4.5.0"
dependencies = [
"async-recursion",
"base16ct",
@ -3332,7 +3332,7 @@ dependencies = [
[[package]]
name = "tetratto-l10n"
version = "4.0.0"
version = "4.5.0"
dependencies = [
"pathbufd",
"serde",
@ -3341,7 +3341,7 @@ dependencies = [
[[package]]
name = "tetratto-shared"
version = "4.0.0"
version = "4.5.0"
dependencies = [
"ammonia",
"chrono",

View file

@ -1,6 +1,6 @@
[package]
name = "tetratto"
version = "4.0.0"
version = "4.5.0"
edition = "2024"
[features]

View file

@ -8,6 +8,7 @@ use std::{
collections::HashMap,
fs::{exists, read_to_string, write},
sync::LazyLock,
time::SystemTime,
};
use tera::Context;
use tetratto_core::{
@ -41,76 +42,76 @@ pub const STREAMS_JS: &str = include_str!("./public/js/streams.js");
// html
pub const ROOT: &str = include_str!("./public/html/root.lisp");
pub const MACROS: &str = include_str!("./public/html/macros.lisp");
pub const COMPONENTS: &str = include_str!("./public/html/components.html");
pub const COMPONENTS: &str = include_str!("./public/html/components.lisp");
pub const MISC_ERROR: &str = include_str!("./public/html/misc/error.lisp");
pub const MISC_NOTIFICATIONS: &str = include_str!("./public/html/misc/notifications.html");
pub const MISC_NOTIFICATIONS: &str = include_str!("./public/html/misc/notifications.lisp");
pub const MISC_MARKDOWN: &str = include_str!("./public/html/misc/markdown.lisp");
pub const MISC_REQUESTS: &str = include_str!("./public/html/misc/requests.html");
pub const MISC_REQUESTS: &str = include_str!("./public/html/misc/requests.lisp");
pub const AUTH_BASE: &str = include_str!("./public/html/auth/base.html");
pub const AUTH_LOGIN: &str = include_str!("./public/html/auth/login.html");
pub const AUTH_REGISTER: &str = include_str!("./public/html/auth/register.html");
pub const AUTH_CONNECTION: &str = include_str!("./public/html/auth/connection.html");
pub const AUTH_BASE: &str = include_str!("./public/html/auth/base.lisp");
pub const AUTH_LOGIN: &str = include_str!("./public/html/auth/login.lisp");
pub const AUTH_REGISTER: &str = include_str!("./public/html/auth/register.lisp");
pub const AUTH_CONNECTION: &str = include_str!("./public/html/auth/connection.lisp");
pub const PROFILE_BASE: &str = include_str!("./public/html/profile/base.html");
pub const PROFILE_POSTS: &str = include_str!("./public/html/profile/posts.html");
pub const PROFILE_SETTINGS: &str = include_str!("./public/html/profile/settings.html");
pub const PROFILE_FOLLOWING: &str = include_str!("./public/html/profile/following.html");
pub const PROFILE_FOLLOWERS: &str = include_str!("./public/html/profile/followers.html");
pub const PROFILE_WARNING: &str = include_str!("./public/html/profile/warning.html");
pub const PROFILE_PRIVATE: &str = include_str!("./public/html/profile/private.html");
pub const PROFILE_BLOCKED: &str = include_str!("./public/html/profile/blocked.html");
pub const PROFILE_BANNED: &str = include_str!("./public/html/profile/banned.html");
pub const PROFILE_REPLIES: &str = include_str!("./public/html/profile/replies.html");
pub const PROFILE_MEDIA: &str = include_str!("./public/html/profile/media.html");
pub const PROFILE_BASE: &str = include_str!("./public/html/profile/base.lisp");
pub const PROFILE_POSTS: &str = include_str!("./public/html/profile/posts.lisp");
pub const PROFILE_SETTINGS: &str = include_str!("./public/html/profile/settings.lisp");
pub const PROFILE_FOLLOWING: &str = include_str!("./public/html/profile/following.lisp");
pub const PROFILE_FOLLOWERS: &str = include_str!("./public/html/profile/followers.lisp");
pub const PROFILE_WARNING: &str = include_str!("./public/html/profile/warning.lisp");
pub const PROFILE_PRIVATE: &str = include_str!("./public/html/profile/private.lisp");
pub const PROFILE_BLOCKED: &str = include_str!("./public/html/profile/blocked.lisp");
pub const PROFILE_BANNED: &str = include_str!("./public/html/profile/banned.lisp");
pub const PROFILE_REPLIES: &str = include_str!("./public/html/profile/replies.lisp");
pub const PROFILE_MEDIA: &str = include_str!("./public/html/profile/media.lisp");
pub const COMMUNITIES_LIST: &str = include_str!("./public/html/communities/list.html");
pub const COMMUNITIES_BASE: &str = include_str!("./public/html/communities/base.html");
pub const COMMUNITIES_FEED: &str = include_str!("./public/html/communities/feed.html");
pub const COMMUNITIES_SETTINGS: &str = include_str!("./public/html/communities/settings.html");
pub const COMMUNITIES_MEMBERS: &str = include_str!("./public/html/communities/members.html");
pub const COMMUNITIES_SEARCH: &str = include_str!("./public/html/communities/search.html");
pub const COMMUNITIES_LIST: &str = include_str!("./public/html/communities/list.lisp");
pub const COMMUNITIES_BASE: &str = include_str!("./public/html/communities/base.lisp");
pub const COMMUNITIES_FEED: &str = include_str!("./public/html/communities/feed.lisp");
pub const COMMUNITIES_SETTINGS: &str = include_str!("./public/html/communities/settings.lisp");
pub const COMMUNITIES_MEMBERS: &str = include_str!("./public/html/communities/members.lisp");
pub const COMMUNITIES_SEARCH: &str = include_str!("./public/html/communities/search.lisp");
pub const COMMUNITIES_CREATE_POST: &str =
include_str!("./public/html/communities/create_post.html");
pub const COMMUNITIES_QUESTION: &str = include_str!("./public/html/communities/question.html");
pub const COMMUNITIES_QUESTIONS: &str = include_str!("./public/html/communities/questions.html");
include_str!("./public/html/communities/create_post.lisp");
pub const COMMUNITIES_QUESTION: &str = include_str!("./public/html/communities/question.lisp");
pub const COMMUNITIES_QUESTIONS: &str = include_str!("./public/html/communities/questions.lisp");
pub const POST_POST: &str = include_str!("./public/html/post/post.html");
pub const POST_REPOSTS: &str = include_str!("./public/html/post/reposts.html");
pub const POST_QUOTES: &str = include_str!("./public/html/post/quotes.html");
pub const POST_LIKES: &str = include_str!("./public/html/post/likes.html");
pub const POST_POST: &str = include_str!("./public/html/post/post.lisp");
pub const POST_REPOSTS: &str = include_str!("./public/html/post/reposts.lisp");
pub const POST_QUOTES: &str = include_str!("./public/html/post/quotes.lisp");
pub const POST_LIKES: &str = include_str!("./public/html/post/likes.lisp");
pub const TIMELINES_HOME: &str = include_str!("./public/html/timelines/home.html");
pub const TIMELINES_POPULAR: &str = include_str!("./public/html/timelines/popular.html");
pub const TIMELINES_FOLLOWING: &str = include_str!("./public/html/timelines/following.html");
pub const TIMELINES_ALL: &str = include_str!("./public/html/timelines/all.html");
pub const TIMELINES_HOME: &str = include_str!("./public/html/timelines/home.lisp");
pub const TIMELINES_POPULAR: &str = include_str!("./public/html/timelines/popular.lisp");
pub const TIMELINES_FOLLOWING: &str = include_str!("./public/html/timelines/following.lisp");
pub const TIMELINES_ALL: &str = include_str!("./public/html/timelines/all.lisp");
pub const TIMELINES_HOME_QUESTIONS: &str =
include_str!("./public/html/timelines/home_questions.html");
include_str!("./public/html/timelines/home_questions.lisp");
pub const TIMELINES_POPULAR_QUESTIONS: &str =
include_str!("./public/html/timelines/popular_questions.html");
include_str!("./public/html/timelines/popular_questions.lisp");
pub const TIMELINES_FOLLOWING_QUESTIONS: &str =
include_str!("./public/html/timelines/following_questions.html");
include_str!("./public/html/timelines/following_questions.lisp");
pub const TIMELINES_ALL_QUESTIONS: &str =
include_str!("./public/html/timelines/all_questions.html");
pub const TIMELINES_SEARCH: &str = include_str!("./public/html/timelines/search.html");
include_str!("./public/html/timelines/all_questions.lisp");
pub const TIMELINES_SEARCH: &str = include_str!("./public/html/timelines/search.lisp");
pub const MOD_AUDIT_LOG: &str = include_str!("./public/html/mod/audit_log.html");
pub const MOD_REPORTS: &str = include_str!("./public/html/mod/reports.html");
pub const MOD_FILE_REPORT: &str = include_str!("./public/html/mod/file_report.html");
pub const MOD_IP_BANS: &str = include_str!("./public/html/mod/ip_bans.html");
pub const MOD_PROFILE: &str = include_str!("./public/html/mod/profile.html");
pub const MOD_WARNINGS: &str = include_str!("./public/html/mod/warnings.html");
pub const MOD_STATS: &str = include_str!("./public/html/mod/stats.html");
pub const MOD_AUDIT_LOG: &str = include_str!("./public/html/mod/audit_log.lisp");
pub const MOD_REPORTS: &str = include_str!("./public/html/mod/reports.lisp");
pub const MOD_FILE_REPORT: &str = include_str!("./public/html/mod/file_report.lisp");
pub const MOD_IP_BANS: &str = include_str!("./public/html/mod/ip_bans.lisp");
pub const MOD_PROFILE: &str = include_str!("./public/html/mod/profile.lisp");
pub const MOD_WARNINGS: &str = include_str!("./public/html/mod/warnings.lisp");
pub const MOD_STATS: &str = include_str!("./public/html/mod/stats.lisp");
pub const CHATS_APP: &str = include_str!("./public/html/chats/app.html");
pub const CHATS_STREAM: &str = include_str!("./public/html/chats/stream.html");
pub const CHATS_MESSAGE: &str = include_str!("./public/html/chats/message.html");
pub const CHATS_CHANNELS: &str = include_str!("./public/html/chats/channels.html");
pub const CHATS_APP: &str = include_str!("./public/html/chats/app.lisp");
pub const CHATS_STREAM: &str = include_str!("./public/html/chats/stream.lisp");
pub const CHATS_MESSAGE: &str = include_str!("./public/html/chats/message.lisp");
pub const CHATS_CHANNELS: &str = include_str!("./public/html/chats/channels.lisp");
pub const STACKS_LIST: &str = include_str!("./public/html/stacks/list.html");
pub const STACKS_POSTS: &str = include_str!("./public/html/stacks/posts.html");
pub const STACKS_MANAGE: &str = include_str!("./public/html/stacks/manage.html");
pub const STACKS_LIST: &str = include_str!("./public/html/stacks/list.lisp");
pub const STACKS_POSTS: &str = include_str!("./public/html/stacks/posts.lisp");
pub const STACKS_MANAGE: &str = include_str!("./public/html/stacks/manage.lisp");
// langs
pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml");
@ -143,13 +144,7 @@ pub(crate) async fn pull_icon(icon: &str, icons_dir: &str) {
}
println!("download icon: {icon}");
let svg = reqwest::get(icon_url)
.await
.unwrap()
.text()
.await
.unwrap()
.replace("\n", "");
let svg = reqwest::get(icon_url).await.unwrap().text().await.unwrap();
write(&file_path, &svg).unwrap();
writer.insert(icon.to_string(), svg);
@ -198,7 +193,10 @@ pub(crate) async fn replace_in_html(
let mut input = if !lisp {
input.to_string()
} else {
let start = SystemTime::now();
let parsed = bberry::parse(input);
println!("parsed lisp in {}μs", start.elapsed().unwrap().as_micros());
if let Some(plugins) = plugins {
parsed.render(plugins)
} else {
@ -221,7 +219,12 @@ pub(crate) async fn replace_in_html(
Regex::new("(\\{\\{)\\s*(icon)\\s*(.*?)\\s*c\\((.*?)\\)\\s*(\\}\\})").unwrap();
for cap in icon_with_class.captures_iter(&input.clone()) {
let icon = &cap.get(3).unwrap().as_str().replace("\"", "");
let cap_str = &cap.get(3).unwrap().as_str().replace("\"", "");
let icon = &(if cap_str.contains(" }}") {
cap_str.split(" }}").next().unwrap().to_string()
} else {
cap_str.to_string()
});
pull_icon(icon, &config.dirs.icons).await;
@ -231,14 +234,26 @@ pub(crate) async fn replace_in_html(
&format!("<svg class=\"icon {}\"", cap.get(4).unwrap().as_str()),
);
input = input.replace(cap.get(0).unwrap().as_str(), &icon_text);
input = input.replace(
&format!(
"{{{{ icon \"{cap_str}\" c({}) }}}}",
cap.get(4).unwrap().as_str()
),
&icon_text,
);
}
// icon (without class)
let icon_without_class = Regex::new("(\\{\\{)\\s*(icon)\\s*(.*?)\\s*(\\}\\})").unwrap();
for cap in icon_without_class.captures_iter(&input.clone()) {
let icon = &cap.get(3).unwrap().as_str().replace("\"", "");
let cap_str = &cap.get(3).unwrap().as_str().replace("\"", "");
let icon = &(if cap_str.contains(" }}") {
cap_str.split(" }}").next().unwrap().to_string()
} else {
cap_str.to_string()
});
pull_icon(icon, &config.dirs.icons).await;
let reader = ICONS.read().await;
@ -247,7 +262,7 @@ pub(crate) async fn replace_in_html(
.unwrap()
.replace("<svg", "<svg class=\"icon\"");
input = input.replace(cap.get(0).unwrap().as_str(), &icon_text);
input = input.replace(&format!("{{{{ icon \"{cap_str}\" }}}}",), &icon_text);
}
// return
@ -293,71 +308,71 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
write_template!(html_path->"root.html"(crate::assets::ROOT) --config=config --lisp plugins);
write_template!(html_path->"macros.html"(crate::assets::MACROS) --config=config --lisp plugins);
write_template!(html_path->"components.html"(crate::assets::COMPONENTS) --config=config);
write_template!(html_path->"components.html"(crate::assets::COMPONENTS) --config=config --lisp plugins);
write_template!(html_path->"misc/error.html"(crate::assets::MISC_ERROR) -d "misc" --config=config --lisp plugins);
write_template!(html_path->"misc/notifications.html"(crate::assets::MISC_NOTIFICATIONS) --config=config);
write_template!(html_path->"misc/notifications.html"(crate::assets::MISC_NOTIFICATIONS) --config=config --lisp plugins);
write_template!(html_path->"misc/markdown.html"(crate::assets::MISC_MARKDOWN) --config=config --lisp plugins);
write_template!(html_path->"misc/requests.html"(crate::assets::MISC_REQUESTS) --config=config);
write_template!(html_path->"misc/requests.html"(crate::assets::MISC_REQUESTS) --config=config --lisp plugins);
write_template!(html_path->"auth/base.html"(crate::assets::AUTH_BASE) -d "auth" --config=config);
write_template!(html_path->"auth/login.html"(crate::assets::AUTH_LOGIN) --config=config);
write_template!(html_path->"auth/register.html"(crate::assets::AUTH_REGISTER) --config=config);
write_template!(html_path->"auth/connection.html"(crate::assets::AUTH_CONNECTION) --config=config);
write_template!(html_path->"auth/base.html"(crate::assets::AUTH_BASE) -d "auth" --config=config --lisp plugins);
write_template!(html_path->"auth/login.html"(crate::assets::AUTH_LOGIN) --config=config --lisp plugins);
write_template!(html_path->"auth/register.html"(crate::assets::AUTH_REGISTER) --config=config --lisp plugins);
write_template!(html_path->"auth/connection.html"(crate::assets::AUTH_CONNECTION) --config=config --lisp plugins);
write_template!(html_path->"profile/base.html"(crate::assets::PROFILE_BASE) -d "profile" --config=config);
write_template!(html_path->"profile/posts.html"(crate::assets::PROFILE_POSTS) --config=config);
write_template!(html_path->"profile/settings.html"(crate::assets::PROFILE_SETTINGS) --config=config);
write_template!(html_path->"profile/following.html"(crate::assets::PROFILE_FOLLOWING) --config=config);
write_template!(html_path->"profile/followers.html"(crate::assets::PROFILE_FOLLOWERS) --config=config);
write_template!(html_path->"profile/warning.html"(crate::assets::PROFILE_WARNING) --config=config);
write_template!(html_path->"profile/private.html"(crate::assets::PROFILE_PRIVATE) --config=config);
write_template!(html_path->"profile/blocked.html"(crate::assets::PROFILE_BLOCKED) --config=config);
write_template!(html_path->"profile/banned.html"(crate::assets::PROFILE_BANNED) --config=config);
write_template!(html_path->"profile/replies.html"(crate::assets::PROFILE_REPLIES) --config=config);
write_template!(html_path->"profile/media.html"(crate::assets::PROFILE_MEDIA) --config=config);
write_template!(html_path->"profile/base.html"(crate::assets::PROFILE_BASE) -d "profile" --config=config --lisp plugins);
write_template!(html_path->"profile/posts.html"(crate::assets::PROFILE_POSTS) --config=config --lisp plugins);
write_template!(html_path->"profile/settings.html"(crate::assets::PROFILE_SETTINGS) --config=config --lisp plugins);
write_template!(html_path->"profile/following.html"(crate::assets::PROFILE_FOLLOWING) --config=config --lisp plugins);
write_template!(html_path->"profile/followers.html"(crate::assets::PROFILE_FOLLOWERS) --config=config --lisp plugins);
write_template!(html_path->"profile/warning.html"(crate::assets::PROFILE_WARNING) --config=config --lisp plugins);
write_template!(html_path->"profile/private.html"(crate::assets::PROFILE_PRIVATE) --config=config --lisp plugins);
write_template!(html_path->"profile/blocked.html"(crate::assets::PROFILE_BLOCKED) --config=config --lisp plugins);
write_template!(html_path->"profile/banned.html"(crate::assets::PROFILE_BANNED) --config=config --lisp plugins);
write_template!(html_path->"profile/replies.html"(crate::assets::PROFILE_REPLIES) --config=config --lisp plugins);
write_template!(html_path->"profile/media.html"(crate::assets::PROFILE_MEDIA) --config=config --lisp plugins);
write_template!(html_path->"communities/list.html"(crate::assets::COMMUNITIES_LIST) -d "communities" --config=config);
write_template!(html_path->"communities/base.html"(crate::assets::COMMUNITIES_BASE) --config=config);
write_template!(html_path->"communities/feed.html"(crate::assets::COMMUNITIES_FEED) --config=config);
write_template!(html_path->"communities/settings.html"(crate::assets::COMMUNITIES_SETTINGS) --config=config);
write_template!(html_path->"communities/members.html"(crate::assets::COMMUNITIES_MEMBERS) --config=config);
write_template!(html_path->"communities/search.html"(crate::assets::COMMUNITIES_SEARCH) --config=config);
write_template!(html_path->"communities/create_post.html"(crate::assets::COMMUNITIES_CREATE_POST) --config=config);
write_template!(html_path->"communities/question.html"(crate::assets::COMMUNITIES_QUESTION) --config=config);
write_template!(html_path->"communities/questions.html"(crate::assets::COMMUNITIES_QUESTIONS) --config=config);
write_template!(html_path->"communities/list.html"(crate::assets::COMMUNITIES_LIST) -d "communities" --config=config --lisp plugins);
write_template!(html_path->"communities/base.html"(crate::assets::COMMUNITIES_BASE) --config=config --lisp plugins);
write_template!(html_path->"communities/feed.html"(crate::assets::COMMUNITIES_FEED) --config=config --lisp plugins);
write_template!(html_path->"communities/settings.html"(crate::assets::COMMUNITIES_SETTINGS) --config=config --lisp plugins);
write_template!(html_path->"communities/members.html"(crate::assets::COMMUNITIES_MEMBERS) --config=config --lisp plugins);
write_template!(html_path->"communities/search.html"(crate::assets::COMMUNITIES_SEARCH) --config=config --lisp plugins);
write_template!(html_path->"communities/create_post.html"(crate::assets::COMMUNITIES_CREATE_POST) --config=config --lisp plugins);
write_template!(html_path->"communities/question.html"(crate::assets::COMMUNITIES_QUESTION) --config=config --lisp plugins);
write_template!(html_path->"communities/questions.html"(crate::assets::COMMUNITIES_QUESTIONS) --config=config --lisp plugins);
write_template!(html_path->"post/post.html"(crate::assets::POST_POST) -d "post" --config=config);
write_template!(html_path->"post/reposts.html"(crate::assets::POST_REPOSTS) --config=config);
write_template!(html_path->"post/quotes.html"(crate::assets::POST_QUOTES) --config=config);
write_template!(html_path->"post/likes.html"(crate::assets::POST_LIKES) --config=config);
write_template!(html_path->"post/post.html"(crate::assets::POST_POST) -d "post" --config=config --lisp plugins);
write_template!(html_path->"post/reposts.html"(crate::assets::POST_REPOSTS) --config=config --lisp plugins);
write_template!(html_path->"post/quotes.html"(crate::assets::POST_QUOTES) --config=config --lisp plugins);
write_template!(html_path->"post/likes.html"(crate::assets::POST_LIKES) --config=config --lisp plugins);
write_template!(html_path->"timelines/home.html"(crate::assets::TIMELINES_HOME) -d "timelines" --config=config);
write_template!(html_path->"timelines/popular.html"(crate::assets::TIMELINES_POPULAR) --config=config);
write_template!(html_path->"timelines/following.html"(crate::assets::TIMELINES_FOLLOWING) --config=config);
write_template!(html_path->"timelines/all.html"(crate::assets::TIMELINES_ALL) --config=config);
write_template!(html_path->"timelines/home_questions.html"(crate::assets::TIMELINES_HOME_QUESTIONS) --config=config);
write_template!(html_path->"timelines/popular_questions.html"(crate::assets::TIMELINES_POPULAR_QUESTIONS) --config=config);
write_template!(html_path->"timelines/following_questions.html"(crate::assets::TIMELINES_FOLLOWING_QUESTIONS) --config=config);
write_template!(html_path->"timelines/all_questions.html"(crate::assets::TIMELINES_ALL_QUESTIONS) --config=config);
write_template!(html_path->"timelines/search.html"(crate::assets::TIMELINES_SEARCH) --config=config);
write_template!(html_path->"timelines/home.html"(crate::assets::TIMELINES_HOME) -d "timelines" --config=config --lisp plugins);
write_template!(html_path->"timelines/popular.html"(crate::assets::TIMELINES_POPULAR) --config=config --lisp plugins);
write_template!(html_path->"timelines/following.html"(crate::assets::TIMELINES_FOLLOWING) --config=config --lisp plugins);
write_template!(html_path->"timelines/all.html"(crate::assets::TIMELINES_ALL) --config=config --lisp plugins);
write_template!(html_path->"timelines/home_questions.html"(crate::assets::TIMELINES_HOME_QUESTIONS) --config=config --lisp plugins);
write_template!(html_path->"timelines/popular_questions.html"(crate::assets::TIMELINES_POPULAR_QUESTIONS) --config=config --lisp plugins);
write_template!(html_path->"timelines/following_questions.html"(crate::assets::TIMELINES_FOLLOWING_QUESTIONS) --config=config --lisp plugins);
write_template!(html_path->"timelines/all_questions.html"(crate::assets::TIMELINES_ALL_QUESTIONS) --config=config --lisp plugins);
write_template!(html_path->"timelines/search.html"(crate::assets::TIMELINES_SEARCH) --config=config --lisp plugins);
write_template!(html_path->"mod/audit_log.html"(crate::assets::MOD_AUDIT_LOG) -d "mod" --config=config);
write_template!(html_path->"mod/reports.html"(crate::assets::MOD_REPORTS) --config=config);
write_template!(html_path->"mod/file_report.html"(crate::assets::MOD_FILE_REPORT) --config=config);
write_template!(html_path->"mod/ip_bans.html"(crate::assets::MOD_IP_BANS) --config=config);
write_template!(html_path->"mod/profile.html"(crate::assets::MOD_PROFILE) --config=config);
write_template!(html_path->"mod/warnings.html"(crate::assets::MOD_WARNINGS) --config=config);
write_template!(html_path->"mod/stats.html"(crate::assets::MOD_STATS) --config=config);
write_template!(html_path->"mod/audit_log.html"(crate::assets::MOD_AUDIT_LOG) -d "mod" --config=config --lisp plugins);
write_template!(html_path->"mod/reports.html"(crate::assets::MOD_REPORTS) --config=config --lisp plugins);
write_template!(html_path->"mod/file_report.html"(crate::assets::MOD_FILE_REPORT) --config=config --lisp plugins);
write_template!(html_path->"mod/ip_bans.html"(crate::assets::MOD_IP_BANS) --config=config --lisp plugins);
write_template!(html_path->"mod/profile.html"(crate::assets::MOD_PROFILE) --config=config --lisp plugins);
write_template!(html_path->"mod/warnings.html"(crate::assets::MOD_WARNINGS) --config=config --lisp plugins);
write_template!(html_path->"mod/stats.html"(crate::assets::MOD_STATS) --config=config --lisp plugins);
write_template!(html_path->"chats/app.html"(crate::assets::CHATS_APP) -d "chats" --config=config);
write_template!(html_path->"chats/stream.html"(crate::assets::CHATS_STREAM) --config=config);
write_template!(html_path->"chats/message.html"(crate::assets::CHATS_MESSAGE) --config=config);
write_template!(html_path->"chats/channels.html"(crate::assets::CHATS_CHANNELS) --config=config);
write_template!(html_path->"chats/app.html"(crate::assets::CHATS_APP) -d "chats" --config=config --lisp plugins);
write_template!(html_path->"chats/stream.html"(crate::assets::CHATS_STREAM) --config=config --lisp plugins);
write_template!(html_path->"chats/message.html"(crate::assets::CHATS_MESSAGE) --config=config --lisp plugins);
write_template!(html_path->"chats/channels.html"(crate::assets::CHATS_CHANNELS) --config=config --lisp plugins);
write_template!(html_path->"stacks/list.html"(crate::assets::STACKS_LIST) -d "stacks" --config=config);
write_template!(html_path->"stacks/posts.html"(crate::assets::STACKS_POSTS) --config=config);
write_template!(html_path->"stacks/manage.html"(crate::assets::STACKS_MANAGE) --config=config);
write_template!(html_path->"stacks/list.html"(crate::assets::STACKS_LIST) -d "stacks" --config=config --lisp plugins);
write_template!(html_path->"stacks/posts.html"(crate::assets::STACKS_POSTS) --config=config --lisp plugins);
write_template!(html_path->"stacks/manage.html"(crate::assets::STACKS_MANAGE) --config=config --lisp plugins);
html_path
}

View file

@ -114,7 +114,6 @@ version = "1.0.0"
"communities:label.edit_content" = "Edit content"
"communities:label.repost" = "Repost"
"communities:label.quote_post" = "Quote post"
"communities:label.expand_original" = "Expand original"
"communities:label.search_results" = "Search results"
"communities:label.query" = "Query"
"communities:label.join_new" = "Join new"

View file

@ -1,9 +0,0 @@
{% extends "root.html" %} {% block body %}
<main class="flex flex-col gap-2" style="max-width: 25rem">
<h2 class="w-full text-center">{% block title %}{% endblock %}</h2>
<div class="card w-full flex flex-col gap-4 justify-center align-center">
{% block content %}{% endblock %}
</div>
{% block footer %}{% endblock %}
</main>
{% endblock %}

View file

@ -0,0 +1,16 @@
(text "{% extends \"root.html\" %} {% block body %}")
(main
("class" "flex flex-col gap-2")
("style" "max-width: 25rem")
(h2
("class" "w-full text-center")
; block for title
(text "{% block title %}{% endblock %}"))
(div
("class" "card w-full flex flex-col gap-4 justify-center align-center")
; block for actual page content
(text "{% block content %}{% endblock %}"))
; small footer block (for switching context)
(text "{% block footer %}{% endblock %}"))
(text "{% endblock %}")

View file

@ -1,75 +0,0 @@
{% extends "auth/base.html" %} {% block head %}
<title>Connection</title>
{% endblock %} {% block title %}Connection{% endblock %} {% block content %}
<div class="w-full flex-col gap-2" id="status"><b>Working...</b></div>
{% if connection_type == "Spotify" and user and user.connections.Spotify and
config.connections.spotify_client_id %}
<script>
setTimeout(async () => {
const code = new URLSearchParams(window.location.search).get("code");
const client_id = "{{ config.connections.spotify_client_id }}";
const verifier = "{{ user.connections.Spotify[0].data.verifier }}";
if (!code) {
alert("Connection failed (did not get code from Spotify)");
return;
}
const [token, refresh_token, expires_in] = await trigger(
"spotify::get_token",
[client_id, verifier, code],
);
const profile = await trigger("spotify::profile", [token]);
const { message } = await trigger("connections::push_con_data", [
"Spotify",
{
token,
refresh_token,
expires_in: expires_in.toString(),
name: profile.display_name,
url: profile.external_urls.spotify,
},
]);
document.getElementById("status").innerHTML =
`<b>${message}.</b> You can now close this tab.`;
setTimeout(() => {
window.location.href = "/settings#/connections";
}, 500);
}, 150);
</script>
{% elif connection_type == "LastFm" and user and user.connections.LastFm and
config.connections.last_fm_key %}
<script>
setTimeout(async () => {
const token = new URLSearchParams(window.location.search).get("token");
const api_key = "{{ config.connections.last_fm_key }}";
if (!token) {
alert("Connection failed (did not get token from Last.fm)");
return;
}
const res = await trigger("last_fm::get_session", [token]);
const { message } = await trigger("connections::push_con_data", [
"LastFm",
{
session_token: res.session.key,
name: res.session.name,
url: `https://last.fm/user/${res.session.name}`,
},
]);
document.getElementById("status").innerHTML =
`<b>${message}.</b> You can now close this tab.`;
setTimeout(() => {
window.location.href = "/settings#/connections";
}, 500);
}, 1000);
</script>
{%- endif %} {% endblock %}

View file

@ -0,0 +1,80 @@
(text "{% extends \"auth/base.html\" %} {% block head %}")
(title
(text "Connection"))
(text "{% endblock %} {% block title %}Connection{% endblock %} {% block content %}")
(div
("class" "w-full flex-col gap-2")
("id" "status")
; display loading text because we have to wait for the data to update on remote
(b
(text "Working...")))
(text "{% if connection_type == \"Spotify\" and user and user.connections.Spotify and config.connections.spotify_client_id %}")
(script
(text "setTimeout(async () => {
const code = new URLSearchParams(window.location.search).get(\"code\");
const client_id = \"{{ config.connections.spotify_client_id }}\";
const verifier = \"{{ user.connections.Spotify[0].data.verifier }}\";
if (!code) {
alert(\"Connection failed (did not get code from Spotify)\");
return;
}
const [token, refresh_token, expires_in] = await trigger(
\"spotify::get_token\",
[client_id, verifier, code],
);
const profile = await trigger(\"spotify::profile\", [token]);
const { message } = await trigger(\"connections::push_con_data\", [
\"Spotify\",
{
token,
refresh_token,
expires_in: expires_in.toString(),
name: profile.display_name,
url: profile.external_urls.spotify,
},
]);
document.getElementById(\"status\").innerHTML =
`<b>${message}.</b> You can now close this tab.`;
setTimeout(() => {
window.location.href = \"/settings#/connections\";
}, 500);
}, 150);"))
(text "{% elif connection_type == \"LastFm\" and user and user.connections.LastFm and config.connections.last_fm_key %}")
(script
(text "setTimeout(async () => {
const token = new URLSearchParams(window.location.search).get(\"token\");
const api_key = \"{{ config.connections.last_fm_key }}\";
if (!token) {
alert(\"Connection failed (did not get token from Last.fm)\");
return;
}
const res = await trigger(\"last_fm::get_session\", [token]);
const { message } = await trigger(\"connections::push_con_data\", [
\"LastFm\",
{
session_token: res.session.key,
name: res.session.name,
url: `https://last.fm/user/${res.session.name}`,
},
]);
document.getElementById(\"status\").innerHTML =
`<b>${message}.</b> You can now close this tab.`;
setTimeout(() => {
window.location.href = \"/settings#/connections\";
}, 500);
}, 1000);"))
(text "{%- endif %} {% endblock %}")

View file

@ -1,103 +0,0 @@
{% extends "auth/base.html" %} {% block head %}
<title>Login</title>
{% endblock %} {% block title %}Login{% endblock %} {% block content %}
<form class="w-full flex flex-col gap-4" onsubmit="login(event)">
<div id="flow_1" style="display: contents">
<div class="flex flex-col gap-1">
<label for="username"><b>Username</b></label>
<input
type="text"
placeholder="username"
required
name="username"
id="username"
/>
</div>
<div class="flex flex-col gap-1">
<label for="username"><b>Password</b></label>
<input
type="password"
placeholder="password"
required
name="password"
id="password"
/>
</div>
</div>
<div id="flow_2" style="display: none">
<div class="flex flex-col gap-1">
<label for="totp"><b>TOTP code</b></label>
<input type="text" placeholder="totp code" name="totp" id="totp" />
</div>
</div>
<button>Submit</button>
</form>
<script>
let flow_page = 1;
function next_page() {
document.getElementById(`flow_${flow_page}`).style.display = "none";
flow_page += 1;
document.getElementById(`flow_${flow_page}`).style.display = "contents";
}
async function login(e) {
e.preventDefault();
if (flow_page === 1) {
// check if we need TOTP
const res = await (
await fetch(
`/api/v1/auth/user/${e.target.username.value}/totp/check`,
)
).json();
trigger("atto::toast", [res.ok ? "success" : "error", res.message]);
if (res.ok && res.payload) {
// user exists AND totp is required
return next_page();
}
}
fetch("/api/v1/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: e.target.username.value,
password: e.target.password.value,
totp: e.target.totp.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
if (res.ok) {
// update tokens
const new_tokens = ns("me").LOGIN_ACCOUNT_TOKENS;
new_tokens[e.target.username.value] = res.message;
trigger("me::set_login_account_tokens", [new_tokens]);
// redirect
setTimeout(() => {
window.location.href = "/";
}, 150);
}
});
}
</script>
{% endblock %} {% block footer %}
<span class="small w-full text-center"
>Or, <a href="/auth/register">register</a></span
>
{% endblock %}

View file

@ -0,0 +1,121 @@
(text "{% extends \"auth/base.html\" %} {% block head %}")
(title
(text "Login"))
(text "{% endblock %} {% block title %}Login{% endblock %} {% block content %}")
(form
("class" "w-full flex flex-col gap-4")
("onsubmit" "login(event)")
(div
("id" "flow_1")
("style" "display: contents")
(div
("class" "flex flex-col gap-1")
(label
("for" "username")
(b
(text "Username")))
(input
("type" "text")
("placeholder" "username")
("required" "")
("name" "username")
("id" "username")))
(div
("class" "flex flex-col gap-1")
(label
("for" "username")
(b
(text "Password")))
(input
("type" "password")
("placeholder" "password")
("required" "")
("name" "password")
("id" "password"))))
(div
("id" "flow_2")
("style" "display: none")
(div
("class" "flex flex-col gap-1")
(label
("for" "totp")
(b
(text "TOTP code")))
(input
("type" "text")
("placeholder" "totp code")
("name" "totp")
("id" "totp"))))
(button
(text "Submit")))
(script
(text "let flow_page = 1;
function next_page() {
document.getElementById(`flow_${flow_page}`).style.display = \"none\";
flow_page += 1;
document.getElementById(`flow_${flow_page}`).style.display = \"contents\";
}
async function login(e) {
e.preventDefault();
if (flow_page === 1) {
// check if we need TOTP
const res = await (
await fetch(
`/api/v1/auth/user/${e.target.username.value}/totp/check`,
)
).json();
trigger(\"atto::toast\", [res.ok ? \"success\" : \"error\", res.message]);
if (res.ok && res.payload) {
// user exists AND totp is required
return next_page();
}
}
fetch(\"/api/v1/auth/login\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
username: e.target.username.value,
password: e.target.password.value,
totp: e.target.totp.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
// update tokens
const new_tokens = ns(\"me\").LOGIN_ACCOUNT_TOKENS;
new_tokens[e.target.username.value] = res.message;
trigger(\"me::set_login_account_tokens\", [new_tokens]);
// redirect
setTimeout(() => {
window.location.href = \"/\";
}, 150);
}
});
}"))
(text "{% endblock %} {% block footer %}")
(span
("class" "small w-full text-center")
(text "Or, ")
(a
("href" "/auth/register")
(text "register")))
(text "{% endblock %}")

View file

@ -1,120 +0,0 @@
{% extends "auth/base.html" %} {% block head %}
<title>Register</title>
{% endblock %} {% block title %}Register{% endblock %} {% block content %}
<script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
defer
></script>
<form class="w-full flex flex-col gap-4" onsubmit="register(event)">
<div class="flex flex-col gap-1">
<label for="username"><b>Username</b></label>
<input
type="text"
placeholder="username"
required
name="username"
id="username"
/>
</div>
<div class="flex flex-col gap-1">
<label for="username"><b>Password</b></label>
<input
type="password"
placeholder="password"
required
name="password"
id="password"
/>
</div>
<hr />
<div class="card-nest w-full">
<div class="card small flex items-center gap-2">
{{ icon "scroll-text" }}
<b>Policies</b>
</div>
<div class="card secondary flex flex-col gap-2">
<span>By continuing, you agree to the following policies:</span>
<ul>
<li>
<a href="{{ config.policies.terms_of_service }}"
>Terms of service</a
>
</li>
<li>
<a href="{{ config.policies.privacy }}">Privacy policy</a>
</li>
</ul>
<div class="flex gap-2">
<input
type="checkbox"
name="policy_consent"
id="policy_consent"
class="w-content"
required
/>
<label for="policy_consent">I agree</label>
</div>
</div>
</div>
<div
class="cf-turnstile"
data-sitekey="{{ config.turnstile.site_key }}"
></div>
<hr />
<button>Submit</button>
</form>
<script>
async function register(e) {
e.preventDefault();
await trigger("atto::debounce", ["users::create"]);
fetch("/api/v1/auth/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: e.target.username.value,
password: e.target.password.value,
policy_consent: e.target.policy_consent.checked,
captcha_response: e.target.querySelector(
"[name=cf-turnstile-response]",
).value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
if (res.ok) {
// update tokens
const new_tokens = ns("me").LOGIN_ACCOUNT_TOKENS;
new_tokens[e.target.username.value] = res.message;
trigger("me::set_login_account_tokens", [new_tokens]);
// redirect
setTimeout(() => {
window.location.href = "/";
}, 150);
}
});
}
</script>
{% endblock %} {% block footer %}
<span class="small w-full text-center"
>Or, <a href="/auth/login">login</a></span
>
{% endblock %}

View file

@ -0,0 +1,123 @@
(text "{% extends \"auth/base.html\" %} {% block head %}")
(title
(text "Register"))
(text "{% endblock %} {% block title %}Register{% endblock %} {% block content %}")
(script
("src" "https://challenges.cloudflare.com/turnstile/v0/api.js")
("defer" ""))
(form
("class" "w-full flex flex-col gap-4")
("onsubmit" "register(event)")
(div
("class" "flex flex-col gap-1")
(label
("for" "username")
(b
(text "Username")))
(input
("type" "text")
("placeholder" "username")
("required" "")
("name" "username")
("id" "username")))
(div
("class" "flex flex-col gap-1")
(label
("for" "username")
(b
(text "Password")))
(input
("type" "password")
("placeholder" "password")
("required" "")
("name" "password")
("id" "password")))
(hr)
(div
("class" "card-nest w-full")
(div
("class" "card small flex items-center gap-2")
(text "{{ icon \"scroll-text\" }}")
(b
(text "Policies")))
(div
("class" "card secondary flex flex-col gap-2")
(span
(text "By continuing, you agree to the following policies:"))
(ul
(li
(a
("href" "{{ config.policies.terms_of_service }}")
(text "Terms of service")))
(li
(a
("href" "{{ config.policies.privacy }}")
(text "Privacy policy"))))
(div
("class" "flex gap-2")
(input
("type" "checkbox")
("name" "policy_consent")
("id" "policy_consent")
("class" "w-content")
("required" ""))
(label
("for" "policy_consent")
(text "I agree")))))
(div
("class" "cf-turnstile")
("data-sitekey" "{{ config.turnstile.site_key }}"))
(hr)
(button
(text "Submit")))
(script
(text "async function register(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"users::create\"]);
fetch(\"/api/v1/auth/register\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
username: e.target.username.value,
password: e.target.password.value,
policy_consent: e.target.policy_consent.checked,
captcha_response: e.target.querySelector(
\"[name=cf-turnstile-response]\",
).value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
// update tokens
const new_tokens = ns(\"me\").LOGIN_ACCOUNT_TOKENS;
new_tokens[e.target.username.value] = res.message;
trigger(\"me::set_login_account_tokens\", [new_tokens]);
// redirect
setTimeout(() => {
window.location.href = \"/\";
}, 150);
}
});
}"))
(text "{% endblock %} {% block footer %}")
(span
("class" "small w-full text-center")
(text "Or, ")
(a
("href" "/auth/login")
(text "login")))
(text "{% endblock %}")

View file

@ -1,141 +1,117 @@
{% extends "root.html" %} {% block head %}
<title>Chats - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav(selected="chats",
hide_user_menu=true) }}
<nav class="chats_nav">
<button
class="flex gap-2 items-center active"
onclick="toggle_sidebars(event)"
>
{{ icon "panel-left" }} {% if community -%}
<b class="name shorter">
{% if community.context.display_name -%} {{
community.context.display_name }} {% else %} {{ community.title }}
{%- endif %}
</b>
{% else %}
<b>{{ text "chats:label.my_chats" }}</b>
{%- endif %}
</button>
</nav>
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Chats - {{ config.name }}"))
<div class="flex">
<div
class="sidebar flex flex-col items-center gap-2"
id="community_list"
style="width: var(--list-bar-width)"
>
<a
href="/chats/0/0"
class="button quaternary channel_icon {% if selected_community == 0 -%}selected{%- endif %}"
data-turbo="false"
>
{{ icon "message-circle" }}
</a>
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"chats\", hide_user_menu=true) }}")
(nav
("class" "chats_nav")
(button
("class" "flex gap-2 items-center active")
("onclick" "toggle_sidebars(event)")
(text "{{ icon \"panel-left\" }} {% if community -%}")
(b
("class" "name shorter")
(text "{% if community.context.display_name -%} {{ community.context.display_name }} {% else %} {{ community.title }} {%- endif %}"))
(text "{% else %}")
(b
(text "{{ text \"chats:label.my_chats\" }}"))
(text "{%- endif %}")))
{% for community in communities %} {% if community.id != 0 -%}
<a
href="/chats/{{ community.id }}/0"
class="button quaternary channel_icon {% if selected_community == community.id -%}selected{%- endif %}"
data-turbo="false"
>
{{ components::community_avatar(id=community.id,
community=community, size="48px") }}
</a>
{%- endif %} {% endfor %}
</div>
<div class="sidebar flex flex-col gap-2 justify-between" id="channels_list">
<div class="flex flex-col gap-2 w-full">
<div class="title flex items-center justify-between channel_header">
{% if community -%}
<b class="name shorter">
{% if community.context.display_name -%} {{
community.context.display_name }} {% else %} {{
community.title }} {%- endif %}
</b>
{% else %}
<b>{{ text "chats:label.my_chats" }}</b>
{%- endif %} {% if selected_community != 0 -%}
<div class="dropdown">
<button
class="camo small"
onclick="trigger('atto::hooks::dropdown', [event])"
exclude="dropdown"
>
{{ icon "ellipsis" }}
</button>
<div class="inner">
<a href="/community/{{ selected_community }}">
{{ icon "book-heart" }}
<span
>{{ text "communities:label.show_community"
}}</span
>
</a>
{% if can_manage_channels -%}
<a href="/community/{{ selected_community }}/manage">
{{ icon "settings" }}
<span>{{ text "general:action.manage" }}</span>
</a>
{%- endif %}
</div>
</div>
{%- endif %}
</div>
{% if can_manage_channels -%}
<a
class="button w-full justify-start quaternary"
href="/community/{{ selected_community }}/manage#/channels"
>
{{ icon "plus" }}
<span>{{ text "communities:action.create_channel" }}</span>
</a>
{%- endif %}
<turbo-frame
id="channels_list_frame"
src="/chats/{{ selected_community }}/{{ selected_channel }}/_channels"
target="_top"
></turbo-frame>
</div>
{{ components::user_plate(user=user, show_menu=true) }}
</div>
{% if channel -%}
<div class="w-full flex flex-col gap-2" id="stream" style="padding: 1rem">
<turbo-frame
id="stream_body_frame"
src="/chats/{{ selected_community }}/{{ selected_channel }}/_stream?page={{ page }}&message={{ message }}"
></turbo-frame>
<form
class="card flex flex-row gap-2"
onsubmit="create_message_from_form(event)"
>
<textarea
type="text"
name="content"
id="content"
placeholder="message {{ channel.title }}"
required
minlength="2"
maxlength="2048"
style="min-height: 48px !important; height: 48px"
></textarea>
<button class="camo send_button" title="Send">
{{ icon "send-horizontal" }}
</button>
</form>
</div>
{%- endif %}
<style>
:root {
(div
("class" "flex")
(div
("class" "sidebar flex flex-col items-center gap-2")
("id" "community_list")
("style" "width: var(--list-bar-width)")
(a
("href" "/chats/0/0")
("class" "button quaternary channel_icon {% if selected_community == 0 -%}selected{%- endif %}")
("data-turbo" "false")
(text "{{ icon \"message-circle\" }}"))
(text "{% for community in communities %} {% if community.id != 0 -%}")
(a
("href" "/chats/{{ community.id }}/0")
("class" "button quaternary channel_icon {% if selected_community == community.id -%}selected{%- endif %}")
("data-turbo" "false")
(text "{{ components::community_avatar(id=community.id, community=community, size=\"48px\") }}"))
(text "{%- endif %} {% endfor %}"))
(div
("class" "sidebar flex flex-col gap-2 justify-between")
("id" "channels_list")
(div
("class" "flex flex-col gap-2 w-full")
(div
("class" "title flex items-center justify-between channel_header")
(text "{% if community -%}")
(b
("class" "name shorter")
(text "{% if community.context.display_name -%} {{ community.context.display_name }} {% else %} {{ community.title }} {%- endif %}"))
(text "{% else %}")
(b
(text "{{ text \"chats:label.my_chats\" }}"))
(text "{%- endif %} {% if selected_community != 0 -%}")
(div
("class" "dropdown")
(button
("class" "camo small")
("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown")
(text "{{ icon \"ellipsis\" }}"))
(div
("class" "inner")
(a
("href" "/community/{{ selected_community }}")
(text "{{ icon \"book-heart\" }}")
(span
(text "{{ text \"communities:label.show_community\" }}")))
(text "{% if can_manage_channels -%}")
(a
("href" "/community/{{ selected_community }}/manage")
(text "{{ icon \"settings\" }}")
(span
(text "{{ text \"general:action.manage\" }}")))
(text "{%- endif %}")))
(text "{%- endif %}"))
(text "{% if can_manage_channels -%}")
(a
("class" "button w-full justify-start quaternary")
("href" "/community/{{ selected_community }}/manage#/channels")
(text "{{ icon \"plus\" }}")
(span
(text "{{ text \"communities:action.create_channel\" }}")))
(text "{%- endif %}")
(turbo-frame
("id" "channels_list_frame")
("src" "/chats/{{ selected_community }}/{{ selected_channel }}/_channels")
("target" "_top")))
(text "{{ components::user_plate(user=user, show_menu=true) }}"))
(text "{% if channel -%}")
(div
("class" "w-full flex flex-col gap-2")
("id" "stream")
("style" "padding: 1rem")
(turbo-frame
("id" "stream_body_frame")
("src" "/chats/{{ selected_community }}/{{ selected_channel }}/_stream?page={{ page }}&message={{ message }}"))
(form
("class" "card flex flex-row gap-2")
("onsubmit" "create_message_from_form(event)")
(textarea
("type" "text")
("name" "content")
("id" "content")
("placeholder" "message {{ channel.title }}")
("required" "")
("minlength" "2")
("maxlength" "2048")
("style" "min-height: 48px !important; height: 48px"))
(button
("class" "camo send_button")
("title" "Send")
(text "{{ icon \"send-horizontal\" }}"))))
(text "{%- endif %}")
(style
(text ":root {
--list-bar-width: 64px;
--channels-bar-width: 256px;
--sidebar-height: calc(100dvh - 42px);
@ -224,7 +200,7 @@ hide_user_menu=true) }}
width: calc(100% - var(--list-bar-width));
bottom: 0;
left: var(--list-bar-width);
content: "";
content: \"\";
}
nav .content_container {
@ -352,44 +328,42 @@ hide_user_menu=true) }}
.chats_nav {
display: flex;
}
}
</style>
<script>
window.CURRENT_PAGE = Number.parseInt("{{ page }}");
window.VIEWING_SINGLE = "{{ message }}".length > 0;
}"))
(script
(text "window.CURRENT_PAGE = Number.parseInt(\"{{ page }}\");
window.VIEWING_SINGLE = \"{{ message }}\".length > 0;
window.CHAT_PROPS = {
selected_community: "{{ selected_community }}",
selected_channel: "{{ selected_channel }}",
membership_role: Number.parseInt("{{ membership_role }}"),
selected_community: \"{{ selected_community }}\",
selected_channel: \"{{ selected_channel }}\",
membership_role: Number.parseInt(\"{{ membership_role }}\"),
};
window.SIDEBARS_OPEN = false;
if (new URLSearchParams(window.location.search).get("nav") === "true") {
if (new URLSearchParams(window.location.search).get(\"nav\") === \"true\") {
window.SIDEBARS_OPEN = true;
}
if (
window.SIDEBARS_OPEN &&
!document.body.classList.contains("sidebars_shown")
!document.body.classList.contains(\"sidebars_shown\")
) {
toggle_sidebars();
window.SIDEBARS_OPEN = true;
}
for (const anchor of document.querySelectorAll("[data-turbo=false]")) {
for (const anchor of document.querySelectorAll(\"[data-turbo=false]\")) {
anchor.href += `?nav=${window.SIDEBARS_OPEN}`;
}
function mention_user(username) {
document.getElementById("content").value += ` @${username} `;
document.getElementById(\"content\").value += ` @${username} `;
}
function toggle_sidebars() {
window.SIDEBARS_OPEN = !window.SIDEBARS_OPEN;
for (const anchor of document.querySelectorAll(
"[data-turbo=false]",
\"[data-turbo=false]\",
)) {
anchor.href = anchor.href.replace(
`?nav=${!window.SIDEBARS_OPEN}`,
@ -397,34 +371,34 @@ hide_user_menu=true) }}
);
}
const community_list = document.getElementById("community_list");
const channels_list = document.getElementById("channels_list");
const community_list = document.getElementById(\"community_list\");
const channels_list = document.getElementById(\"channels_list\");
if (document.body.classList.contains("sidebars_shown")) {
if (document.body.classList.contains(\"sidebars_shown\")) {
// hide
document.body.classList.remove("sidebars_shown");
community_list.style.left = "-200%";
channels_list.style.left = "-200%";
document.body.classList.remove(\"sidebars_shown\");
community_list.style.left = \"-200%\";
channels_list.style.left = \"-200%\";
} else {
// show
document.body.classList.add("sidebars_shown");
community_list.style.left = "0";
channels_list.style.left = "var(--list-bar-width)";
document.body.classList.add(\"sidebars_shown\");
community_list.style.left = \"0\";
channels_list.style.left = \"var(--list-bar-width)\";
}
}
globalThis.add_member = async (id) => {
await trigger("atto::debounce", ["channels::add_member"]);
const member = await trigger("atto::prompt", ["Member username:"]);
await trigger(\"atto::debounce\", [\"channels::add_member\"]);
const member = await trigger(\"atto::prompt\", [\"Member username:\"]);
if (!member) {
return;
}
fetch(`/api/v1/channels/${id}/add`, {
method: "POST",
method: \"POST\",
headers: {
"Content-Type": "application/json",
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
member,
@ -432,25 +406,25 @@ hide_user_menu=true) }}
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
globalThis.update_channel_title = async (id) => {
await trigger("atto::debounce", ["channels::update_title"]);
const title = await trigger("atto::prompt", ["New channel title:"]);
await trigger(\"atto::debounce\", [\"channels::update_title\"]);
const title = await trigger(\"atto::prompt\", [\"New channel title:\"]);
if (!title) {
return;
}
fetch(`/api/v1/channels/${id}/title`, {
method: "POST",
method: \"POST\",
headers: {
"Content-Type": "application/json",
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
title,
@ -458,8 +432,8 @@ hide_user_menu=true) }}
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
@ -467,17 +441,17 @@ hide_user_menu=true) }}
globalThis.kick_member = async (cid, uid) => {
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this?",
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(`/api/v1/channels/${cid}/kick`, {
method: "POST",
method: \"POST\",
headers: {
"Content-Type": "application/json",
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
member: uid,
@ -485,8 +459,8 @@ hide_user_menu=true) }}
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
@ -494,42 +468,42 @@ hide_user_menu=true) }}
globalThis.delete_channel = async (id) => {
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this?",
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(`/api/v1/channels/${id}`, {
method: "DELETE",
method: \"DELETE\",
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
</script>
<script id="socket_init" data-turbo-permanent="true">
globalThis.socket_init = () => {
};"))
(script
("id" "socket_init")
("data-turbo-permanent" "true")
(text "globalThis.socket_init = () => {
if (window.socket) {
window.socket.send("Close");
window.socket.send(\"Close\");
window.socket.close();
window.socket = undefined;
console.log("closed lingering");
console.log(\"closed lingering\");
}
if (window.CHAT_PROPS.selected_community !== "0") {
const endpoint = `${window.location.origin.replace("http", "ws")}/api/v1/_connect/${window.CHAT_PROPS.selected_community}`;
if (window.CHAT_PROPS.selected_community !== \"0\") {
const endpoint = `${window.location.origin.replace(\"http\", \"ws\")}/api/v1/_connect/${window.CHAT_PROPS.selected_community}`;
const socket = new WebSocket(endpoint);
window.socket = socket;
window.socket_id = window.CHAT_PROPS.selected_community;
} else {
const endpoint = `${window.location.origin.replace("http", "ws")}/api/v1/_connect/${window.CHAT_PROPS.selected_channel}`;
const endpoint = `${window.location.origin.replace(\"http\", \"ws\")}/api/v1/_connect/${window.CHAT_PROPS.selected_channel}`;
const socket = new WebSocket(endpoint);
window.socket = socket;
window.socket_id = window.CHAT_PROPS.selected_channel;
@ -544,25 +518,25 @@ hide_user_menu=true) }}
return;
}
if (!window.location.href.includes("{{ selected_channel }}")) {
if (!window.location.href.includes(\"{{ selected_channel }}\")) {
window.clearInterval(window.CHANNEL_NOTIFS_INTERVAL);
return;
}
fetch(
`/api/v1/notifications/tag/chats/${window.CHAT_PROPS.selected_channel}`,
{ method: "DELETE" },
{ method: \"DELETE\" },
);
}, 10000);
window.socket.addEventListener("open", () => {
window.socket.addEventListener(\"open\", () => {
// auth
window.socket.send(
JSON.stringify({
method: "Headers",
method: \"Headers\",
data: JSON.stringify({
// SocketHeaders
user: "{{ user.id }}",
user: \"{{ user.id }}\",
is_channel: window.SUBSCRIBE_CHANNEL,
}),
}),
@ -571,15 +545,15 @@ hide_user_menu=true) }}
setTimeout(() => {
window.LAST_MESSAGE_AUTHOR_ID = null;
window.socket.addEventListener("message", async (event) => {
if (event.data === "Ping") {
return socket.send("Pong");
window.socket.addEventListener(\"message\", async (event) => {
if (event.data === \"Ping\") {
return socket.send(\"Pong\");
}
const msg = JSON.parse(event.data);
if (
msg.method === "Message" &&
msg.method === \"Message\" &&
window.CURRENT_PAGE === 0 &&
window.VIEWING_SINGLE
) {
@ -590,18 +564,18 @@ hide_user_menu=true) }}
return;
}
if (document.getElementById("stream_body")) {
const element = document.createElement("div");
element.style.display = "contents";
if (document.getElementById(\"stream_body\")) {
const element = document.createElement(\"div\");
element.style.display = \"contents\";
const message_owner = JSON.parse(msg.data)[1].owner;
element.innerHTML = await (
await fetch(
`/chats/${window.CHAT_PROPS.selected_community}/${window.CHAT_PROPS.selected_channel}/_render`,
{
method: "POST",
method: \"POST\",
headers: {
"Content-Type": "application/json",
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
data: msg.data,
@ -614,16 +588,16 @@ hide_user_menu=true) }}
).text();
document
.getElementById("stream_body")
.getElementById(\"stream_body\")
.prepend(element);
clean_text();
window.LAST_MESSAGE_AUTHOR_ID = message_owner;
} else {
console.log("abandoned remote");
console.log(\"abandoned remote\");
socket.close();
}
} else if (msg.method === "Delete") {
} else if (msg.method === \"Delete\") {
const data = JSON.parse(msg.data);
if (document.getElementById(`message-${data.id}`)) {
document
@ -635,12 +609,12 @@ hide_user_menu=true) }}
globalThis.create_message_from_form = async (e) => {
e.preventDefault();
await trigger("atto::debounce", ["messages::create"]);
await trigger(\"atto::debounce\", [\"messages::create\"]);
fetch("/api/v1/messages", {
method: "POST",
fetch(\"/api/v1/messages\", {
method: \"POST\",
headers: {
"Content-Type": "application/json",
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
content: e.target.content.value.trim(),
@ -650,7 +624,7 @@ hide_user_menu=true) }}
.then((res) => res.json())
.then((res) => {
if (!res.ok) {
trigger("atto::toast", ["error", res.message]);
trigger(\"atto::toast\", [\"error\", res.message]);
}
e.target.reset();
@ -659,32 +633,32 @@ hide_user_menu=true) }}
globalThis.delete_message = async (id) => {
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this?",
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(`/api/v1/messages/${id}`, {
method: "DELETE",
method: \"DELETE\",
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
const clean_text = () => {
trigger("atto::clean_date_codes");
trigger("atto::hooks::online_indicator");
trigger(\"atto::clean_date_codes\");
trigger(\"atto::hooks::online_indicator\");
};
document.addEventListener(
"turbo:before-frame-render",
\"turbo:before-frame-render\",
(event) => {
setTimeout(clean_text, 50);
},
@ -692,27 +666,24 @@ hide_user_menu=true) }}
setTimeout(clean_text, 150);
}, 250);
};
</script>
{% if selected_channel -%}
<script>
window.SUBSCRIBE_CHANNEL = "{{ selected_community }}" === "0";
};"))
(text "{% if selected_channel -%}")
(script
(text "window.SUBSCRIBE_CHANNEL = \"{{ selected_community }}\" === \"0\";
setTimeout(() => {
if (!window.SUBSCRIBE_CHANNEL) {
// sub community
if (window.socket_id !== "{{ selected_community }}") {
if (window.socket_id !== \"{{ selected_community }}\") {
socket_init();
}
} else {
// sub channel
if (window.socket_id !== "{{ selected_channel }}") {
if (window.socket_id !== \"{{ selected_channel }}\") {
socket_init();
}
}
}, 100);
</script>
{%- endif %}
</div>
{% endblock %}
}, 100);"))
(text "{%- endif %}"))
(text "{% endblock %}")

View file

@ -1,75 +0,0 @@
{%- import "components.html" as components -%}
<turbo-frame id="channels_list_frame">
<div
class="channels_list_half flex flex-col gap-2 {% if selected_community != 0 or selected_channel == 0%}no_members{%- endif -%}"
>
{% for channel in channels %}
<div class="flex flex-row gap-1">
<a
class="w-full justify-start button {% if selected_channel == channel.id -%}quaternary{% else %}camo{%- endif %}"
href="/chats/{{ selected_community }}/{{ channel.id }}"
data-turbo="{{ selected_community == '0' }}"
>
{{ icon "rss" }}
<b class="name shortest">{{ channel.title }}</b>
</a>
<div class="dropdown">
<button
class="big_icon {% if selected_channel == channel.id -%}quaternary{% else %}camo{%- endif %}"
onclick="trigger('atto::hooks::dropdown', [event])"
exclude="dropdown"
style="width: 32px"
>
{{ icon "ellipsis" }}
</button>
<div class="inner">
{% if user.id == channel.owner -%} {% if selected_community
== 0 %}
<button
class="quaternary small"
onclick="add_member('{{ channel.id }}')"
>
{{ icon "user-plus" }}
<span>{{ text "chats:action.add_someone" }}</span>
</button>
{%- endif %}
<button
class="quaternary small"
onclick="update_channel_title('{{ channel.id }}')"
>
{{ icon "pencil" }}
<span>{{ text "chats:action.rename" }}</span>
</button>
<button
onclick="delete_channel('{{ channel.id }}')"
class="red"
>
{{ icon "trash" }}
<span>{{ text "general:action.delete" }}</span>
</button>
{% else %}
<button
onclick="kick_member('{{ channel.id }}', '{{ user.id }}')"
class="red"
>
{{ icon "door-open" }}
<span>{{ text "chats:action.leave" }}</span>
</button>
{%- endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% if selected_community == 0 and selected_channel -%}
<div class="members_list_half flex flex-col gap-2">
{% for member in members %} {{ components::user_plate(user=member,
show_kick=user.id == channel.owner) }} {% endfor %}
</div>
{%- endif %}
</turbo-frame>

View file

@ -0,0 +1,60 @@
(text "{%- import \"components.html\" as components -%}")
(turbo-frame
("id" "channels_list_frame")
(div
("class" "channels_list_half flex flex-col gap-2 {% if selected_community != 0 or selected_channel == 0%}no_members{%- endif -%}")
(text "{% for channel in channels %}")
(div
("class" "flex flex-row gap-1")
(a
("class" "w-full justify-start button {% if selected_channel == channel.id -%}quaternary{% else %}camo{%- endif %}")
("href" "/chats/{{ selected_community }}/{{ channel.id }}")
("data-turbo" "{{ selected_community == '0' }}")
(text "{{ icon \"rss\" }}")
(b
("class" "name shortest")
(text "{{ channel.title }}")))
(div
("class" "dropdown")
(button
("class" "big_icon {% if selected_channel == channel.id -%}quaternary{% else %}camo{%- endif %}")
("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown")
("style" "width: 32px")
(text "{{ icon \"ellipsis\" }}"))
(div
("class" "inner")
(text "{% if user.id == channel.owner -%} {% if selected_community == 0 %}")
(button
("class" "quaternary small")
("onclick" "add_member('{{ channel.id }}')")
(text "{{ icon \"user-plus\" }}")
(span
(text "{{ text \"chats:action.add_someone\" }}")))
(text "{%- endif %}")
(button
("class" "quaternary small")
("onclick" "update_channel_title('{{ channel.id }}')")
(text "{{ icon \"pencil\" }}")
(span
(text "{{ text \"chats:action.rename\" }}")))
(button
("onclick" "delete_channel('{{ channel.id }}')")
("class" "red")
(text "{{ icon \"trash\" }}")
(span
(text "{{ text \"general:action.delete\" }}")))
(text "{% else %}")
(button
("onclick" "kick_member('{{ channel.id }}', '{{ user.id }}')")
("class" "red")
(text "{{ icon \"door-open\" }}")
(span
(text "{{ text \"chats:action.leave\" }}")))
(text "{%- endif %}"))))
(text "{% endfor %}"))
(text "{% if selected_community == 0 and selected_channel -%}")
(div
("class" "members_list_half flex flex-col gap-2")
(text "{% for member in members %} {{ components::user_plate(user=member, show_kick=user.id == channel.owner) }} {% endfor %}"))
(text "{%- endif %}"))

View file

@ -1,2 +0,0 @@
{%- import "components.html" as components -%} {{ components::message(user=user,
message=message, grouped=grouped) }}

View file

@ -0,0 +1 @@
(text "{%- import \"components.html\" as components -%} {{ components::message(user=user, message=message, grouped=grouped) }}")

View file

@ -1,55 +0,0 @@
{%- import "components.html" as components -%}
<turbo-frame id="stream_body_frame">
<!-- prettier-ignore -->
<div class="gap-2" id="stream_body">
{% if page != 0 -%}
<div class="card flex gap-2 small tertiary flex-wrap">
<b>{{ text "chats:label.viewing_old_messages" }}</b>
<a href="/chats/{{ community }}/{{ channel }}/_stream?page={{ page - 1}}" class="button small" onclick="window.CURRENT_PAGE -= 1">
{{ text "chats:label.go_back" }}
</a>
</div>
{%- endif %}
{% if message -%}
<div class="card flex gap-2 small tertiary flex-wrap">
<b>{{ text "chats:label.viewing_single_message" }}</b>
<a href="/chats/{{ community }}/{{ channel }}?page={{ page }}" class="button small" onclick="window.VIEWING_SINGLE = false" target="_top">
{{ text "chats:label.go_back" }}
</a>
</div>
{{ components::message(user=message_owner, message=message, grouped=false) }}
{% else %}
{% for message in messages %}
{{ components::message(user=message[1], message=message[0], grouped=message[2]) }}
{% endfor %}
{%- endif %}
{% if messages|length > 0 -%}
<div class="flex gap-2 w-full justify-center">
<a class="button" href="/chats/{{ community }}/{{ channel }}/_stream?page={{ page + 1 }}" onclick="window.CURRENT_PAGE += 1">
{{ icon "clock" }}
<span>{{ text "chats:label.view_older" }}</span>
</a>
{% if page != 0 -%}
<a class="button quaternary" href="/chats/{{ community }}/{{ channel }}/_stream?page={{ page - 1 }}" onclick="window.CURRENT_PAGE -= 1">
{{ icon "rewind" }}
<span>{{ text "chats:label.view_more_recent" }}</span>
</a>
{%- endif %}
</div>
{%- endif %}
</div>
<style>
#stream_body {
height: 100%;
display: flex;
justify-content: flex-start;
flex-direction: column-reverse;
overflow: auto;
}
</style>
</turbo-frame>

View file

@ -0,0 +1,55 @@
(text "{%- import \"components.html\" as components -%}")
(turbo-frame
("id" "stream_body_frame")
(div
("class" "gap-2")
("id" "stream_body")
(text "{% if page != 0 -%}")
(div
("class" "card flex gap-2 small tertiary flex-wrap")
(b
(text "{{ text \"chats:label.viewing_old_messages\" }}"))
(a
("href" "/chats/{{ community }}/{{ channel }}/_stream?page={{ page - 1}}")
("class" "button small")
("onclick" "window.CURRENT_PAGE -= 1")
(text "{{ text \"chats:label.go_back\" }}")))
(text "{%- endif %} {% if message -%}")
(div
("class" "card flex gap-2 small tertiary flex-wrap")
(b
(text "{{ text \"chats:label.viewing_single_message\" }}"))
(a
("href" "/chats/{{ community }}/{{ channel }}?page={{ page }}")
("class" "button small")
("onclick" "window.VIEWING_SINGLE = false")
("target" "_top")
(text "{{ text \"chats:label.go_back\" }}")))
(text "{{ components::message(user=message_owner, message=message, grouped=false) }} {% else %} {% for message in messages %} {{ components::message(user=message[1], message=message[0], grouped=message[2]) }} {% endfor %} {%- endif %} {% if messages|length > 0 -%}")
(div
("class" "flex gap-2 w-full justify-center")
(a
("class" "button")
("href" "/chats/{{ community }}/{{ channel }}/_stream?page={{ page + 1 }}")
("onclick" "window.CURRENT_PAGE += 1")
(text "{{ icon \"clock\" }}")
(span
(text "{{ text \"chats:label.view_older\" }}")))
(text "{% if page != 0 -%}")
(a
("class" "button quaternary")
("href" "/chats/{{ community }}/{{ channel }}/_stream?page={{ page - 1 }}")
("onclick" "window.CURRENT_PAGE -= 1")
(text "{{ icon \"rewind\" }}")
(span
(text "{{ text \"chats:label.view_more_recent\" }}")))
(text "{%- endif %}"))
(text "{%- endif %}"))
(style
(text "#stream_body {
height: 100%;
display: flex;
justify-content: flex-start;
flex-direction: column-reverse;
overflow: auto;
}")))

View file

@ -1,340 +0,0 @@
{% extends "root.html" %} {% block head %}
<title>{{ community.context.display_name }} - {{ config.name }}</title>
<meta name="og:title" content="{{ community.title }}" />
<meta
name="description"
content='View the "{{ community.title }}" community on {{ config.name }}!'
/>
<meta
name="og:description"
content='View the "{{ community.title }}" community on {{ config.name }}!'
/>
<meta property="og:type" content="profile" />
<meta property="profile:username" content="{{ community.title }}" />
<meta
name="og:image"
content="{{ config.host|safe }}/api/v1/communities/{{ community.id }}/avatar"
/>
<meta
name="twitter:image"
content="{{ config.host|safe }}/api/v1/communities/{{ community.id }}/avatar"
/>
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="{{ community.title }}" />
<meta
name="twitter:description"
content='View the "{{ community.title }}" community on {{ config.name }}!'
/>
{% endblock %} {% block body %} {{ macros::nav() }}
<article>
<div class="content_container flex flex-col gap-4">
{{ components::community_banner(id=community.id, community=community) }}
<div class="w-full flex gap-4 flex-collapse">
<div
class="lhs flex flex-col gap-2 sm:w-full"
style="width: 22rem; min-width: 22rem"
>
<div class="card-nest w-full">
<div class="card flex gap-2" id="community_avatar_and_name">
{{ components::community_avatar(id=community.id,
community=community, size="72px") }}
<div class="flex flex-col">
<div class="flex gap-2 items-center">
<h3
id="title"
class="title name shorter flex gap-2"
>
<!-- prettier-ignore -->
{% if community.context.display_name -%}
{{ community.context.display_name }}
{% else %}
{{ community.title }}
{%- endif %}
{% if community.context.is_nsfw -%}
<span
title="NSFW community"
class="flex items-center"
style="color: var(--color-primary)"
>
{{ icon "square-asterisk" }}
</span>
{%- endif %}
</h3>
{% if user -%} {% if user.id != community.owner
%}
<div class="dropdown">
<button
class="camo small"
onclick="trigger('atto::hooks::dropdown', [event])"
exclude="dropdown"
>
{{ icon "ellipsis" }}
</button>
<div class="inner">
<button
class="red"
onclick="trigger('me::report', ['{{ community.id }}', 'community'])"
>
{{ icon "flag" }}
<span
>{{ text "general:action.report"
}}</span
>
</button>
</div>
</div>
{%- endif %} {%- endif %}
</div>
<span class="fade">{{ community.title }}</span>
</div>
</div>
{% if user -%}
<div class="card flex gap-2 flex-wrap" id="join_or_leave">
{% if not is_owner -%} {% if not is_joined -%} {% if not
is_pending %}
<button class="primary" onclick="join_community()">
{{ icon "circle-plus" }}
<span>{{ text "communities:action.join" }}</span>
</button>
<script>
globalThis.join_community = () => {
fetch(
"/api/v1/communities/{{ community.id }}/join",
{
method: "POST",
},
)
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
setTimeout(() => {
window.location.reload();
}, 150);
});
};
</script>
{% else %}
<button
class="quaternary red"
onclick="cancel_request()"
>
{{ icon "x" }}
<span
>{{ text "communities:action.cancel_request"
}}</span
>
</button>
<script>
globalThis.cancel_request = async () => {
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this?",
]))
) {
return;
}
fetch(
"/api/v1/communities/{{ community.id }}/memberships/{{ user.id }}",
{
method: "DELETE",
},
)
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
setTimeout(() => {
window.location.reload();
}, 150);
});
};
</script>
{%- endif %} {% else %}
<button
class="quaternary red"
onclick="leave_community()"
>
{{ icon "circle-minus" }}
<span>{{ text "communities:action.leave" }}</span>
</button>
<a
href="/chats/{{ community.id }}/0"
class="button quaternary"
>
{{ icon "message-circle" }}
<span>{{ text "communities:label.chats" }}</span>
</a>
{% if user and can_post -%}
<a
href="/communities/intents/post?community={{ community.id }}"
class="button quaternary"
data-turbo="false"
>
{{ icon "plus" }}
<span>{{ text "general:action.post" }}</span>
</a>
{%- endif %}
<script>
globalThis.leave_community = async () => {
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this?",
]))
) {
return;
}
fetch(
"/api/v1/communities/{{ community.id }}/memberships/{{ user.id }}",
{
method: "DELETE",
},
)
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
setTimeout(() => {
window.location.reload();
}, 150);
});
};
</script>
{%- endif %} {% else %}
<a
href="/chats/{{ community.id }}/0"
class="button quaternary"
>
{{ icon "message-circle" }}
<span>{{ text "communities:label.chats" }}</span>
</a>
<a
href="/communities/intents/post?community={{ community.id }}"
class="button quaternary"
data-turbo="false"
>
{{ icon "plus" }}
<span>{{ text "general:action.post" }}</span>
</a>
{%- endif %} {% if can_manage_community or is_manager
-%}
<a
href="/community/{{ community.id }}/manage"
class="button primary"
>
{{ icon "settings" }}
<span
>{{ text "communities:action.configure" }}</span
>
</a>
{%- endif %}
</div>
{%- endif %}
</div>
<div class="card-nest flex flex-col">
<div id="bio" class="card small no_p_margin">
{{ community.context.description|markdown|safe }}
</div>
<div class="card flex flex-col gap-2">
<div class="w-full flex justify-between items-center">
<span class="notification chip">ID</span>
<button
title="Copy"
onclick="trigger('atto::copy_text', ['{{ community.id }}'])"
class="camo small"
>
{{ icon "copy" }}
</button>
</div>
<div class="w-full flex justify-between items-center">
<span class="notification chip">Created</span>
<span class="date">{{ community.created }}</span>
</div>
<div class="w-full flex justify-between items-center">
<span class="notification chip">Members</span>
<a href="/community/{{ community.title }}/members"
>{{ community.member_count }}</a
>
</div>
<div class="w-full flex justify-between items-center">
<span class="notification chip">Score</span>
<div class="flex gap-2">
<b
>{{ community.likes - community.dislikes
}}</b
>
{% if user -%}
<div
class="flex gap-1 reactions_box"
hook="check_reactions"
hook-arg:id="{{ community.id }}"
>
{{ components::likes(id=community.id,
asset_type="Community",
likes=community.likes,
dislikes=community.dislikes) }}
</div>
{%- endif %}
</div>
</div>
</div>
</div>
</div>
<div class="rhs w-full">
{% if can_read -%} {% block content %}{% endblock %} {% else %}
<div class="card-nest">
<div class="card small flex items-center gap-2">
{{ icon "frown" }}
<b
>{{ text "communities:label.not_allowed_to_read"
}}</b
>
</div>
<div class="card">
<span>
{{ text "communities:label.might_need_to_join" }}
</span>
</div>
</div>
{%- endif %}
</div>
</div>
</div>
</article>
{% endblock %}

View file

@ -0,0 +1,300 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "{{ community.context.display_name }} - {{ config.name }}"))
(meta
("name" "og:title")
("content" "{{ community.title }}"))
(meta
("name" "description")
("content" "View the \\\"{{ community.title }}\\\" community on {{ config.name }}!"))
(meta
("name" "og:description")
("content" "View the \\\"{{ community.title }}\\\" community on {{ config.name }}!"))
(meta
("property" "og:type")
("content" "profile"))
(meta
("property" "profile:username")
("content" "{{ community.title }}"))
(meta
("name" "og:image")
("content" "{{ config.host|safe }}/api/v1/communities/{{ community.id }}/avatar"))
(meta
("name" "twitter:image")
("content" "{{ config.host|safe }}/api/v1/communities/{{ community.id }}/avatar"))
(meta
("name" "twitter:card")
("content" "summary"))
(meta
("name" "twitter:title")
("content" "{{ community.title }}"))
(meta
("name" "twitter:description")
("content" "View the \\\"{{ community.title }}\\\" community on {{ config.name }}!"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(article
(div
("class" "content_container flex flex-col gap-4")
(text "{{ components::community_banner(id=community.id, community=community) }}")
(div
("class" "w-full flex gap-4 flex-collapse")
(div
("class" "lhs flex flex-col gap-2 sm:w-full")
("style" "width: 22rem; min-width: 22rem")
(div
("class" "card-nest w-full")
(div
("class" "card flex gap-2")
("id" "community_avatar_and_name")
(text "{{ components::community_avatar(id=community.id, community=community, size=\"72px\") }}")
(div
("class" "flex flex-col")
(div
("class" "flex gap-2 items-center")
(h3
("id" "title")
("class" "title name shorter flex gap-2")
(text "{% if community.context.display_name -%} {{ community.context.display_name }} {% else %} {{ community.title }} {%- endif %} {% if community.context.is_nsfw -%}")
(span
("title" "NSFW community")
("class" "flex items-center")
("style" "color: var(--color-primary)")
(text "{{ icon \"square-asterisk\" }}"))
(text "{%- endif %}"))
(text "{% if user -%} {% if user.id != community.owner %}")
(div
("class" "dropdown")
(button
("class" "camo small")
("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown")
(text "{{ icon \"ellipsis\" }}"))
(div
("class" "inner")
(button
("class" "red")
("onclick" "trigger('me::report', ['{{ community.id }}', 'community'])")
(text "{{ icon \"flag\" }}")
(span
(text "{{ text \"general:action.report\" }}")))))
(text "{%- endif %} {%- endif %}"))
(span
("class" "fade")
(text "{{ community.title }}"))))
(text "{% if user -%}")
(div
("class" "card flex gap-2 flex-wrap")
("id" "join_or_leave")
(text "{% if not is_owner -%} {% if not is_joined -%} {% if not is_pending %}")
(button
("class" "primary")
("onclick" "join_community()")
(text "{{ icon \"circle-plus\" }}")
(span
(text "{{ text \"communities:action.join\" }}")))
(script
(text "globalThis.join_community = () => {
fetch(
\"/api/v1/communities/{{ community.id }}/join\",
{
method: \"POST\",
},
)
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
setTimeout(() => {
window.location.reload();
}, 150);
});
};"))
(text "{% else %}")
(button
("class" "quaternary red")
("onclick" "cancel_request()")
(text "{{ icon \"x\" }}")
(span
(text "{{ text \"communities:action.cancel_request\" }}")))
(script
(text "globalThis.cancel_request = async () => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(
\"/api/v1/communities/{{ community.id }}/memberships/{{ user.id }}\",
{
method: \"DELETE\",
},
)
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
setTimeout(() => {
window.location.reload();
}, 150);
});
};"))
(text "{%- endif %} {% else %}")
(button
("class" "quaternary red")
("onclick" "leave_community()")
(text "{{ icon \"circle-minus\" }}")
(span
(text "{{ text \"communities:action.leave\" }}")))
(a
("href" "/chats/{{ community.id }}/0")
("class" "button quaternary")
(text "{{ icon \"message-circle\" }}")
(span
(text "{{ text \"communities:label.chats\" }}")))
(text "{% if user and can_post -%}")
(a
("href" "/communities/intents/post?community={{ community.id }}")
("class" "button quaternary")
("data-turbo" "false")
(text "{{ icon \"plus\" }}")
(span
(text "{{ text \"general:action.post\" }}")))
(text "{%- endif %}")
(script
(text "globalThis.leave_community = async () => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(
\"/api/v1/communities/{{ community.id }}/memberships/{{ user.id }}\",
{
method: \"DELETE\",
},
)
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
setTimeout(() => {
window.location.reload();
}, 150);
});
};"))
(text "{%- endif %} {% else %}")
(a
("href" "/chats/{{ community.id }}/0")
("class" "button quaternary")
(text "{{ icon \"message-circle\" }}")
(span
(text "{{ text \"communities:label.chats\" }}")))
(a
("href" "/communities/intents/post?community={{ community.id }}")
("class" "button quaternary")
("data-turbo" "false")
(text "{{ icon \"plus\" }}")
(span
(text "{{ text \"general:action.post\" }}")))
(text "{%- endif %} {% if can_manage_community or is_manager -%}")
(a
("href" "/community/{{ community.id }}/manage")
("class" "button primary")
(text "{{ icon \"settings\" }}")
(span
(text "{{ text \"communities:action.configure\" }}")))
(text "{%- endif %}"))
(text "{%- endif %}"))
(div
("class" "card-nest flex flex-col")
(div
("id" "bio")
("class" "card small no_p_margin")
(text "{{ community.context.description|markdown|safe }}"))
(div
("class" "card flex flex-col gap-2")
(div
("class" "w-full flex justify-between items-center")
(span
("class" "notification chip")
(text "ID"))
(button
("title" "Copy")
("onclick" "trigger('atto::copy_text', ['{{ community.id }}'])")
("class" "camo small")
(text "{{ icon \"copy\" }}")))
(div
("class" "w-full flex justify-between items-center")
(span
("class" "notification chip")
(text "Created"))
(span
("class" "date")
(text "{{ community.created }}")))
(div
("class" "w-full flex justify-between items-center")
(span
("class" "notification chip")
(text "Members"))
(a
("href" "/community/{{ community.title }}/members")
(text "{{ community.member_count }}")))
(div
("class" "w-full flex justify-between items-center")
(span
("class" "notification chip")
(text "Score"))
(div
("class" "flex gap-2")
(b
(text "{{ community.likes - community.dislikes }}"))
(text "{% if user -%}")
(div
("class" "flex gap-1 reactions_box")
("hook" "check_reactions")
("hook-arg:id" "{{ community.id }}")
(text "{{ components::likes(id=community.id, asset_type=\"Community\", likes=community.likes, dislikes=community.dislikes) }}"))
(text "{%- endif %}"))))))
(div
("class" "rhs w-full")
(text "{% if can_read -%} {% block content %}{% endblock %} {% else %}")
(div
("class" "card-nest")
(div
("class" "card small flex items-center gap-2")
(text "{{ icon \"frown\" }}")
(b
(text "{{ text \"communities:label.not_allowed_to_read\" }}")))
(div
("class" "card")
(span
(text "{{ text \"communities:label.might_need_to_join\" }}"))))
(text "{%- endif %}")))))
(text "{% endblock %}")

View file

@ -1,432 +0,0 @@
{% extends "root.html" %} {% block head %}
<title>Create post - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav() }}
<main class="flex flex-col gap-2">
{% if drafts|length > 0 -%}
<div class="pillmenu">
<a href="#/create" data-tab-button="create" class="active">
{{ icon "plus" }}
<span>{{ text "general:action.post" }}</span>
</a>
<a href="#/drafts" data-tab-button="drafts">
{{ icon "notepad-text-dashed" }}
<span>{{ text "communities:label.drafts" }}</span>
</a>
</div>
{%- endif %}
<div class="card-nest" data-tab="create">
<div class="card small flex items-center justify-between gap-2">
<span class="flex items-center gap-2">
{{ icon "pen" }}
<span>{{ text "communities:label.create_post" }}</span>
</span>
<button onclick="cancel_create_post()" class="quaternary small red">
{{ icon "x" }}
<span>{{ text "dialog:action.cancel" }}</span>
</button>
</div>
<div class="card tertiary flex flex-col gap-2">
{% if draft -%}
<div
class="card secondary w-full flex items-center justify-between gap-2 small"
>
<a class="flex items-center gap-2 flush" href="#/drafts">
{{ icon "notepad-text-dashed" }}
<span class="date">{{ draft.created }}</span>
</a>
<div class="flex gap-2">
<a href="?" class="button quaternary small">
{{ icon "x" }}
<span>{{ text "dialog:action.cancel" }}</span>
</a>
<button
class="button quaternary red small"
onclick="remove_draft('{{ draft.id }}')"
>
{{ icon "trash"}}
<span>{{ text "general:action.delete" }}</span>
</button>
</div>
</div>
{%- endif %} {% if quoting -%}
<div
class="card secondary w-full flex items-center justify-between gap-2 small"
>
<a
class="flex items-center gap-2 flush"
href="/post/{{ quoting[1].id }}"
>
{{ icon "quote" }}
<span>{{ quoting[0].username }}'s post</span>
</a>
<a href="?" class="button quaternary small">
{{ icon "x" }}
<span>{{ text "dialog:action.cancel" }}</span>
</a>
</div>
{%- endif %}
<div class="card-nest">
<div class="card small flex flex-row gap-2 items-center">
{{ components::avatar(username=user.id, size="32px",
selector_type="id") }}
<select
id="community_to_post_to"
onchange="update_community_avatar(event)"
>
<option
value="{{ config.town_square }}"
selected="{% if not selected_community -%}true{% else %}false{%- endif %}"
>
{{ text "auth:link.my_profile" }}
</option>
{% for community in communities %}
<option
value="{{ community.id }}"
selected="{% if selected_community == community.id -%}true{% else %}false{%- endif %}"
>
<!-- prettier-ignore -->
{% if community.context.display_name -%}
{{ community.context.display_name }}
{% else %}
{{ community.title }}
{%- endif %}
</option>
{% endfor %}
</select>
</div>
<form
class="card flex flex-col gap-2"
id="create_form"
onsubmit="create_post_from_form(event)"
>
<div class="flex flex-col gap-1">
<label for="content"
>{{ text "communities:label.content" }}</label
>
<textarea
type="text"
name="content"
id="content"
placeholder="content"
minlength="2"
maxlength="4096"
>
{% if draft -%}{{ draft.content }}{%- endif %}</textarea
>
</div>
<div id="files_list" class="flex gap-2 flex-wrap"></div>
<div class="flex justify-between gap-2">
{{ components::create_post_options() }}
<div class="flex gap-2">
{% if not quoting -%} {% if draft -%}
<button
class="secondary small square"
title="Save as Draft"
onclick="update_draft('{{ draft.id }}')"
type="button"
>
{{ icon "notepad-text-dashed" }}
</button>
{% else %}
<button
class="secondary small square"
title="Save as Draft"
onclick="create_draft()"
type="button"
>
{{ icon "notepad-text-dashed" }}
</button>
{%- endif %} {%- endif %}
<button class="primary">
{{ text "communities:action.create" }}
</button>
</div>
</div>
</form>
</div>
{% if not quoting -%}
<script>
async function create_post_from_form(e) {
e.preventDefault();
await trigger("atto::debounce", ["posts::create"]);
e.target
.querySelector("button.primary")
.classList.add("hidden");
// create body
const body = new FormData();
if (e.target.file_picker) {
for (const file of e.target.file_picker.files) {
body.append(file.name, file);
}
}
body.append(
"body",
JSON.stringify({
content: e.target.content.value,
community: document.getElementById(
"community_to_post_to",
).selectedOptions[0].value,
}),
);
// ...
fetch("/api/v1/posts", {
method: "POST",
body,
})
.then((res) => res.json())
.then(async (res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
if (res.ok) {
// update settings
await update_settings_maybe(res.payload);
// remove draft
// {% if draft -%}
if ("{{ draft.id }}") {
fetch("/api/v1/drafts/{{ draft.id }}", {
method: "DELETE",
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
}
// {%- endif %}
// ...
setTimeout(() => {
window.location.href = `/post/${res.payload}`;
}, 100);
} else {
e.target
.querySelector("button.primary")
.classList.remove("hidden");
}
});
}
async function create_draft() {
const e = {
target: document.getElementById("create_form"),
};
await trigger("atto::debounce", ["posts::create"]);
e.target
.querySelector("button.primary")
.classList.add("hidden");
fetch("/api/v1/drafts", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
content: e.target.content.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
if (res.ok) {
setTimeout(() => {
window.location.href = `?from_draft=${res.payload}`;
}, 100);
} else {
e.target
.querySelector("button.primary")
.classList.remove("hidden");
}
});
}
async function update_draft(id) {
const e = {
target: document.getElementById("create_form"),
};
await trigger("atto::debounce", ["posts::create"]);
e.target
.querySelector("button.primary")
.classList.add("hidden");
fetch(`/api/v1/drafts/${id}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
content: e.target.content.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
if (!res.ok) {
e.target
.querySelector("button.primary")
.classList.remove("hidden");
}
});
}
</script>
{% else %}
<script>
async function create_post_from_form(e) {
const id = await trigger("me::repost", [
"{{ quoting[1].id }}",
e.target.content.value,
document.getElementById("community_to_post_to")
.selectedOptions[0].value,
false,
]);
// update settings
await update_settings_maybe(id);
// redirect
setTimeout(() => {
window.location.href = `/post/${id}`;
}, 100);
}
</script>
{%- endif %}
</div>
</div>
{% if drafts|length > 0 -%}
<div class="card-nest tertiary hidden" data-tab="drafts">
<div class="card small flex items-center gap-2">
{{ icon "notepad-text-dashed" }}
<span>{{ text "communities:label.drafts" }}</span>
</div>
<div class="card flex flex-col gap-2">
{{ components::supporter_ad(body="Become a supporter to save
infinite post drafts!") }} {% for draft in drafts %}
<div class="card-nest">
<div class="card small flex flex-col gap-2">
<span class="no_p_margin"
>{{ draft.content|markdown|safe }}</span
>
<span class="fade date">{{ draft.created }}</span>
</div>
<div class="card flex gap-2 secondary">
<a href="?from_draft={{ draft.id }}" class="button small">
{{ icon "pen-line"}}
<span>{{ text "communities:label.load" }}</span>
</a>
<button
class="button quaternary red small"
onclick="remove_draft('{{ draft.id }}')"
>
{{ icon "trash"}}
<span>{{ text "general:action.delete" }}</span>
</button>
</div>
</div>
{% endfor %}
</div>
</div>
<script>
async function remove_draft(id) {
if (
!(await trigger("atto::confirm", [
"Are you sure you want to do this?",
]))
) {
return;
}
fetch(`/api/v1/drafts/${id}`, {
method: "DELETE",
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
}
</script>
{%- endif %}
</main>
<script>
const town_square = "{{ config.town_square }}";
const user_id = "{{ user.id }}";
function update_community_avatar(e) {
const element = e.target.parentElement.querySelector(".avatar");
const id = e.target.selectedOptions[0].value;
element.setAttribute("title", id);
element.setAttribute("alt", `${id}'s avatar`);
if (id === town_square) {
element.src = `/api/v1/auth/user/${user_id}/avatar?selector_type=id`;
} else {
element.src = `/api/v1/communities/${id}/avatar`;
}
}
setTimeout(() => {
update_community_avatar({
target: document.getElementById("community_to_post_to"),
});
}, 150);
async function cancel_create_post() {
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this? Your post content will be lost.",
]))
) {
return;
}
window.history.back();
}
</script>
{% endblock %}

View file

@ -0,0 +1,406 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Create post - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
(text "{% if drafts|length > 0 -%}")
(div
("class" "pillmenu")
(a
("href" "#/create")
("data-tab-button" "create")
("class" "active")
(text "{{ icon \"plus\" }}")
(span
(text "{{ text \"general:action.post\" }}")))
(a
("href" "#/drafts")
("data-tab-button" "drafts")
(text "{{ icon \"notepad-text-dashed\" }}")
(span
(text "{{ text \"communities:label.drafts\" }}"))))
(text "{%- endif %}")
(div
("class" "card-nest")
("data-tab" "create")
(div
("class" "card small flex items-center justify-between gap-2")
(span
("class" "flex items-center gap-2")
(text "{{ icon \"pen\" }}")
(span
(text "{{ text \"communities:label.create_post\" }}")))
(button
("onclick" "cancel_create_post()")
("class" "quaternary small red")
(text "{{ icon \"x\" }}")
(span
(text "{{ text \"dialog:action.cancel\" }}"))))
(div
("class" "card tertiary flex flex-col gap-2")
(text "{% if draft -%}")
(div
("class" "card secondary w-full flex items-center justify-between gap-2 small")
(a
("class" "flex items-center gap-2 flush")
("href" "#/drafts")
(text "{{ icon \"notepad-text-dashed\" }}")
(span
("class" "date")
(text "{{ draft.created }}")))
(div
("class" "flex gap-2")
(a
("href" "?")
("class" "button quaternary small")
(text "{{ icon \"x\" }}")
(span
(text "{{ text \"dialog:action.cancel\" }}")))
(button
("class" "button quaternary red small")
("onclick" "remove_draft('{{ draft.id }}')")
(text "{{ icon \"trash\" }}")
(span
(text "{{ text \"general:action.delete\" }}")))))
(text "{%- endif %} {% if quoting -%}")
(div
("class" "card secondary w-full flex items-center justify-between gap-2 small")
(a
("class" "flex items-center gap-2 flush")
("href" "/post/{{ quoting[1].id }}")
(text "{{ icon \"quote\" }}")
(span
(text "{{ quoting[0].username }}'s post")))
(a
("href" "?")
("class" "button quaternary small")
(text "{{ icon \"x\" }}")
(span
(text "{{ text \"dialog:action.cancel\" }}"))))
(text "{%- endif %}")
(div
("class" "card-nest")
(div
("class" "card small flex flex-row gap-2 items-center")
(text "{{ components::avatar(username=user.id, size=\"32px\", selector_type=\"id\") }}")
(select
("id" "community_to_post_to")
("onchange" "update_community_avatar(event)")
(option
("value" "{{ config.town_square }}")
("selected" "{% if not selected_community -%}true{% else %}false{%- endif %}")
(text "{{ text \"auth:link.my_profile\" }}"))
(text "{% for community in communities %}")
(option
("value" "{{ community.id }}")
("selected" "{% if selected_community == community.id -%}true{% else %}false{%- endif %}")
(text "{% if community.context.display_name -%} {{ community.context.display_name }} {% else %} {{ community.title }} {%- endif %}"))
(text "{% endfor %}")))
(form
("class" "card flex flex-col gap-2")
("id" "create_form")
("onsubmit" "create_post_from_form(event)")
(div
("class" "flex flex-col gap-1")
(label
("for" "content")
(text "{{ text \"communities:label.content\" }}"))
(textarea
("type" "text")
("name" "content")
("id" "content")
("placeholder" "content")
("minlength" "2")
("maxlength" "4096")
(text "{% if draft -%}{{ draft.content }}{%- endif %}")))
(div
("id" "files_list")
("class" "flex gap-2 flex-wrap"))
(div
("class" "flex justify-between gap-2")
(text "{{ components::create_post_options() }}")
(div
("class" "flex gap-2")
(text "{% if not quoting -%} {% if draft -%}")
(button
("class" "secondary small square")
("title" "Save as Draft")
("onclick" "update_draft('{{ draft.id }}')")
("type" "button")
(text "{{ icon \"notepad-text-dashed\" }}"))
(text "{% else %}")
(button
("class" "secondary small square")
("title" "Save as Draft")
("onclick" "create_draft()")
("type" "button")
(text "{{ icon \"notepad-text-dashed\" }}"))
(text "{%- endif %} {%- endif %}")
(button
("class" "primary")
(text "{{ text \"communities:action.create\" }}"))))))
(text "{% if not quoting -%}")
(script
(text "async function create_post_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"posts::create\"]);
e.target
.querySelector(\"button.primary\")
.classList.add(\"hidden\");
// create body
const body = new FormData();
if (e.target.file_picker) {
for (const file of e.target.file_picker.files) {
body.append(file.name, file);
}
}
body.append(
\"body\",
JSON.stringify({
content: e.target.content.value,
community: document.getElementById(
\"community_to_post_to\",
).selectedOptions[0].value,
}),
);
// ...
fetch(\"/api/v1/posts\", {
method: \"POST\",
body,
})
.then((res) => res.json())
.then(async (res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
// update settings
await update_settings_maybe(res.payload);
// remove draft
// {% if draft -%}
if (\"{{ draft.id }}\") {
fetch(\"/api/v1/drafts/{{ draft.id }}\", {
method: \"DELETE\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
// {%- endif %}
// ...
setTimeout(() => {
window.location.href = `/post/${res.payload}`;
}, 100);
} else {
e.target
.querySelector(\"button.primary\")
.classList.remove(\"hidden\");
}
});
}
async function create_draft() {
const e = {
target: document.getElementById(\"create_form\"),
};
await trigger(\"atto::debounce\", [\"posts::create\"]);
e.target
.querySelector(\"button.primary\")
.classList.add(\"hidden\");
fetch(\"/api/v1/drafts\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
content: e.target.content.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
setTimeout(() => {
window.location.href = `?from_draft=${res.payload}`;
}, 100);
} else {
e.target
.querySelector(\"button.primary\")
.classList.remove(\"hidden\");
}
});
}
async function update_draft(id) {
const e = {
target: document.getElementById(\"create_form\"),
};
await trigger(\"atto::debounce\", [\"posts::create\"]);
e.target
.querySelector(\"button.primary\")
.classList.add(\"hidden\");
fetch(`/api/v1/drafts/${id}`, {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
content: e.target.content.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (!res.ok) {
e.target
.querySelector(\"button.primary\")
.classList.remove(\"hidden\");
}
});
}"))
(text "{% else %}")
(script
(text "async function create_post_from_form(e) {
const id = await trigger(\"me::repost\", [
\"{{ quoting[1].id }}\",
e.target.content.value,
document.getElementById(\"community_to_post_to\")
.selectedOptions[0].value,
false,
]);
// update settings
await update_settings_maybe(id);
// redirect
setTimeout(() => {
window.location.href = `/post/${id}`;
}, 100);
}"))
(text "{%- endif %}")))
(text "{% if drafts|length > 0 -%}")
(div
("class" "card-nest tertiary hidden")
("data-tab" "drafts")
(div
("class" "card small flex items-center gap-2")
(text "{{ icon \"notepad-text-dashed\" }}")
(span
(text "{{ text \"communities:label.drafts\" }}")))
(div
("class" "card flex flex-col gap-2")
(text "{{ components::supporter_ad(body=\"Become a supporter to save infinite post drafts!\") }} {% for draft in drafts %}")
(div
("class" "card-nest")
(div
("class" "card small flex flex-col gap-2")
(span
("class" "no_p_margin")
(text "{{ draft.content|markdown|safe }}"))
(span
("class" "fade date")
(text "{{ draft.created }}")))
(div
("class" "card flex gap-2 secondary")
(a
("href" "?from_draft={{ draft.id }}")
("class" "button small")
(text "{{ icon \"pen-line\" }}")
(span
(text "{{ text \"communities:label.load\" }}")))
(button
("class" "button quaternary red small")
("onclick" "remove_draft('{{ draft.id }}')")
(text "{{ icon \"trash\" }}")
(span
(text "{{ text \"general:action.delete\" }}")))))
(text "{% endfor %}")))
(script
(text "async function remove_draft(id) {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you want to do this?\",
]))
) {
return;
}
fetch(`/api/v1/drafts/${id}`, {
method: \"DELETE\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}"))
(text "{%- endif %}"))
(script
(text "const town_square = \"{{ config.town_square }}\";
const user_id = \"{{ user.id }}\";
function update_community_avatar(e) {
const element = e.target.parentElement.querySelector(\".avatar\");
const id = e.target.selectedOptions[0].value;
element.setAttribute(\"title\", id);
element.setAttribute(\"alt\", `${id}'s avatar`);
if (id === town_square) {
element.src = `/api/v1/auth/user/${user_id}/avatar?selector_type=id`;
} else {
element.src = `/api/v1/communities/${id}/avatar`;
}
}
setTimeout(() => {
update_community_avatar({
target: document.getElementById(\"community_to_post_to\"),
});
}, 150);
async function cancel_create_post() {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this? Your post content will be lost.\",
]))
) {
return;
}
window.history.back();
}"))
(text "{% endblock %}")

View file

@ -1,45 +0,0 @@
{% import "components.html" as components %} {% extends "communities/base.html"
%} {% block content %}
<div class="flex flex-col gap-4 w-full">
{{ macros::community_nav(community=community, selected="posts") }} {% if
pinned|length != 0 %}
<div class="card-nest">
<div class="card small flex gap-2 items-center">
{{ icon "pin" }}
<span>{{ text "communities:label.pinned" }}</span>
</div>
<div class="card flex flex-col gap-4">
<!-- prettier-ignore -->
{% for post in pinned %}
{% if post[0].context.repost and post[0].context.repost.reposting -%}
{{ components::repost(repost=post[2], post=post[0], owner=post[1], secondary=true, show_community=false, can_manage_post=can_manage_posts) }}
{% else %}
{{ components::post(post=post[0], owner=post[1], question=post[3], secondary=true, show_community=false, can_manage_post=can_manage_posts) }}
{%- endif %}
{% endfor %}
</div>
</div>
{%- endif %}
<div class="card-nest">
<div class="card small flex gap-2 items-center">
{{ icon "newspaper" }}
<span>{{ text "communities:label.posts" }}</span>
</div>
<div class="card flex flex-col gap-4">
<!-- prettier-ignore -->
{% for post in feed %}
{% if post[0].context.repost and post[0].context.repost.reposting -%}
{{ components::repost(repost=post[2], post=post[0], owner=post[1], secondary=true, show_community=false, can_manage_post=can_manage_posts) }}
{% else %}
{{ components::post(post=post[0], owner=post[1], question=post[3], secondary=true, show_community=false, can_manage_post=can_manage_posts) }}
{%- endif %}
{% endfor %}
{{ components::pagination(page=page, items=feed|length) }}
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,27 @@
(text "{% import \"components.html\" as components %} {% extends \"communities/base.html\" %} {% block content %}")
(div
("class" "flex flex-col gap-4 w-full")
(text "{{ macros::community_nav(community=community, selected=\"posts\") }} {% if pinned|length != 0 %}")
(div
("class" "card-nest")
(div
("class" "card small flex gap-2 items-center")
(text "{{ icon \"pin\" }}")
(span
(text "{{ text \"communities:label.pinned\" }}")))
(div
("class" "card flex flex-col gap-4")
(text "{% for post in pinned %} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[2], post=post[0], owner=post[1], secondary=true, show_community=false, can_manage_post=can_manage_posts) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[3], secondary=true, show_community=false, can_manage_post=can_manage_posts) }} {%- endif %} {% endfor %}")))
(text "{%- endif %}")
(div
("class" "card-nest")
(div
("class" "card small flex gap-2 items-center")
(text "{{ icon \"newspaper\" }}")
(span
(text "{{ text \"communities:label.posts\" }}")))
(div
("class" "card flex flex-col gap-4")
(text "{% for post in feed %} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[2], post=post[0], owner=post[1], secondary=true, show_community=false, can_manage_post=can_manage_posts) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[3], secondary=true, show_community=false, can_manage_post=can_manage_posts) }} {%- endif %} {% endfor %} {{ components::pagination(page=page, items=feed|length) }}"))))
(text "{% endblock %}")

View file

@ -1,102 +0,0 @@
{% extends "root.html" %} {% block head %}
<title>My communities - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav(selected="communities") }}
<main class="flex flex-col gap-2">
{% if user -%}
<div class="card-nest">
<div class="card small">
<b>{{ text "communities:label.create_new" }}</b>
</div>
<form
class="card flex flex-col gap-2"
onsubmit="create_community_from_form(event)"
>
<div class="flex flex-col gap-1">
<label for="title">{{ text "communities:label.name" }}</label>
<input
type="text"
name="title"
id="title"
placeholder="name"
required
minlength="2"
maxlength="32"
/>
</div>
<button class="primary">
{{ text "communities:action.create" }}
</button>
</form>
</div>
{% if list|length >= 4 -%} {{ components::supporter_ad(body="Become a
supporter to create up to 10 communities!") }} {%- endif %} {%- endif %}
<div class="card-nest w-full">
<div class="card small flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
{{ icon "award" }}
<span>{{ text "communities:label.my_communities" }}</span>
</div>
<a href="/communities/search" class="button quaternary small">
{{ icon "search" }}
<span>{{ text "communities:label.join_new" }}</span>
</a>
</div>
<div class="card flex flex-col gap-2">
{% for item in list %} {{
components::community_listing_card(community=item) }} {% endfor %}
</div>
</div>
<div class="card-nest w-full">
<div class="card small flex items-center gap-2">
{{ icon "trending-up" }}
<span>{{ text "communities:label.popular_communities" }}</span>
</div>
<div class="card flex flex-col gap-2">
{% for item in popular_list %} {{
components::community_listing_card(community=item) }} {% endfor %}
</div>
</div>
</main>
<script>
async function create_community_from_form(e) {
e.preventDefault();
await trigger("atto::debounce", ["communities::create"]);
if (e.target.title.value.includes(" ")) {
return alert("Cannot contain spaces!");
}
fetch("/api/v1/communities", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: e.target.title.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
if (res.ok) {
setTimeout(() => {
window.location.href = `/community/${res.payload}`;
}, 100);
}
});
}
</script>
{% endblock %}

View file

@ -0,0 +1,97 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "My communities - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"communities\") }}")
(main
("class" "flex flex-col gap-2")
(text "{% if user -%}")
(div
("class" "card-nest")
(div
("class" "card small")
(b
(text "{{ text \"communities:label.create_new\" }}")))
(form
("class" "card flex flex-col gap-2")
("onsubmit" "create_community_from_form(event)")
(div
("class" "flex flex-col gap-1")
(label
("for" "title")
(text "{{ text \"communities:label.name\" }}"))
(input
("type" "text")
("name" "title")
("id" "title")
("placeholder" "name")
("required" "")
("minlength" "2")
("maxlength" "32")))
(button
("class" "primary")
(text "{{ text \"communities:action.create\" }}"))))
(text "{% if list|length >= 4 -%} {{ components::supporter_ad(body=\"Become a supporter to create up to 10 communities!\") }} {%- endif %} {%- endif %}")
(div
("class" "card-nest w-full")
(div
("class" "card small flex items-center justify-between gap-2")
(div
("class" "flex items-center gap-2")
(text "{{ icon \"award\" }}")
(span
(text "{{ text \"communities:label.my_communities\" }}")))
(a
("href" "/communities/search")
("class" "button quaternary small")
(text "{{ icon \"search\" }}")
(span
(text "{{ text \"communities:label.join_new\" }}"))))
(div
("class" "card flex flex-col gap-2")
(text "{% for item in list %} {{ components::community_listing_card(community=item) }} {% endfor %}")))
(div
("class" "card-nest w-full")
(div
("class" "card small flex items-center gap-2")
(text "{{ icon \"trending-up\" }}")
(span
(text "{{ text \"communities:label.popular_communities\" }}")))
(div
("class" "card flex flex-col gap-2")
(text "{% for item in popular_list %} {{ components::community_listing_card(community=item) }} {% endfor %}"))))
(script
(text "async function create_community_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"communities::create\"]);
if (e.target.title.value.includes(\" \")) {
return alert(\"Cannot contain spaces!\");
}
fetch(\"/api/v1/communities\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
title: e.target.title.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
setTimeout(() => {
window.location.href = `/community/${res.payload}`;
}, 100);
}
});
}"))
(text "{% endblock %}")

View file

@ -1,49 +0,0 @@
{% import "components.html" as components %} {% extends "communities/base.html"
%} {% block content %}
<div class="flex flex-col gap-4 w-full">
<div class="card-nest">
<div class="card small flex gap-2 items-center">
{{ icon "users-round" }}
<span>{{ text "communities:tab.members" }}</span>
</div>
<div class="card flex flex-col gap-4">
{% if page == 0 -%}
<div class="card-nest">
<div class="card small flex items-center gap-2">
{{ icon "crown" }}
<span>Owner</span>
</div>
{{ components::user_card(user=owner) }}
</div>
{%- endif %}
<!-- prettier-ignore -->
{% for item in list %}
<div class="card-nest">
<div class="card small flex items-center gap-2 justify-between">
<span>
Since
<span class="date">{{ item[0].created }}</span>
</span>
{% if can_manage_roles -%}
<a
href="/community/{{ community.id }}/manage?uid={{ item[1].id }}#/members"
class="button small quaternary"
>
{{ icon "pencil" }}
<span>{{ text "general:action.manage" }}</span>
</a>
{%- endif %}
</div>
{{ components::user_card(user=item[1]) }}
</div>
{% endfor %} {{ components::pagination(page=page, items=list|length)
}}
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,44 @@
(text "{% import \"components.html\" as components %} {% extends \"communities/base.html\" %} {% block content %}")
(div
("class" "flex flex-col gap-4 w-full")
(div
("class" "card-nest")
(div
("class" "card small flex gap-2 items-center")
(text "{{ icon \"users-round\" }}")
(span
(text "{{ text \"communities:tab.members\" }}")))
(div
("class" "card flex flex-col gap-4")
(text "{% if page == 0 -%}")
(div
("class" "card-nest")
(div
("class" "card small flex items-center gap-2")
(text "{{ icon \"crown\" }}")
(span
(text "Owner")))
(text "{{ components::user_card(user=owner) }}"))
(text "{%- endif %}")
(text "{% for item in list %}")
(div
("class" "card-nest")
(div
("class" "card small flex items-center gap-2 justify-between")
(span
(text "Since")
(span
("class" "date")
(text "{{ item[0].created }}")))
(text "{% if can_manage_roles -%}")
(a
("href" "/community/{{ community.id }}/manage?uid={{ item[1].id }}#/members")
("class" "button small quaternary")
(text "{{ icon \"pencil\" }}")
(span
(text "{{ text \"general:action.manage\" }}")))
(text "{%- endif %}"))
(text "{{ components::user_card(user=item[1]) }}"))
(text "{% endfor %} {{ components::pagination(page=page, items=list|length) }}"))))
(text "{% endblock %}")

View file

@ -1,111 +0,0 @@
{% extends "root.html" %} {% block head %}
<title>Question - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav() }}
<main class="flex flex-col gap-2">
<div style="display: contents">
{{ components::question(question=question, owner=owner) }}
</div>
{% if user and (user.id == question.receiver or question.is_global) and not
has_answered %}
<div class="card-nest">
<div class="card small flex items-center gap-2">
{{ icon "square-pen" }}
<b>{{ text "requests:label.answer" }}</b>
</div>
<form
class="card flex flex-col gap-2"
onsubmit="answer_question_from_form(event, '{{ question.id }}')"
>
<div class="flex flex-col gap-1">
<label for="content"
>{{ text "communities:label.content" }}</label
>
<textarea
type="text"
name="content"
id="content"
placeholder="content"
required
minlength="2"
maxlength="4096"
></textarea>
</div>
<div id="files_list" class="flex gap-2 flex-wrap"></div>
<div class="flex gap-2">
{{ components::emoji_picker(element_id="content",
render_dialog=true) }} {% if is_supporter -%} {{
components::file_picker(files_list_id="files_list") }} {% endif
%}
<button class="primary">
{{ text "requests:label.answer" }}
</button>
</div>
</form>
</div>
{%- endif %}
<div class="card-nest w-full" data-tab="replies">
<div class="card small flex items-center gap-2">
{{ icon "newspaper" }}
<span>{{ text "communities:label.replies" }}</span>
</div>
<div class="card flex flex-col gap-4">
<!-- prettier-ignore -->
{% for post in replies %}
{{ components::post(post=post[0], owner=post[1], question=false, secondary=true, show_community=false) }}
{% endfor %}
{{ components::pagination(page=page, items=replies|length) }}
</div>
</div>
</main>
<script>
const community = "{{ question.community }}";
window.answer_question_from_form = async (e, answering) => {
e.preventDefault();
await trigger("atto::debounce", ["posts::create"]);
// create body
const body = new FormData();
if (e.target.file_picker) {
for (const file of e.target.file_picker.files) {
body.append(file.name, file);
}
}
body.append(
"body",
JSON.stringify({
content: e.target.content.value,
community: community ? community : "{{ config.town_square }}",
answering,
}),
);
// ...
fetch("/api/v1/posts", {
method: "POST",
body,
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
if (res.ok) {
window.location.reload();
}
});
};
</script>
{% endblock %}

View file

@ -0,0 +1,99 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Question - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
(div
("style" "display: contents")
(text "{{ components::question(question=question, owner=owner) }}"))
(text "{% if user and (user.id == question.receiver or question.is_global) and not has_answered %}")
(div
("class" "card-nest")
(div
("class" "card small flex items-center gap-2")
(text "{{ icon \"square-pen\" }}")
(b
(text "{{ text \"requests:label.answer\" }}")))
(form
("class" "card flex flex-col gap-2")
("onsubmit" "answer_question_from_form(event, '{{ question.id }}')")
(div
("class" "flex flex-col gap-1")
(label
("for" "content")
(text "{{ text \"communities:label.content\" }}"))
(textarea
("type" "text")
("name" "content")
("id" "content")
("placeholder" "content")
("required" "")
("minlength" "2")
("maxlength" "4096")))
(div
("id" "files_list")
("class" "flex gap-2 flex-wrap"))
(div
("class" "flex gap-2")
(text "{{ components::emoji_picker(element_id=\"content\", render_dialog=true) }} {% if is_supporter -%} {{ components::file_picker(files_list_id=\"files_list\") }} {% endif %}")
(button
("class" "primary")
(text "{{ text \"requests:label.answer\" }}")))))
(text "{%- endif %}")
(div
("class" "card-nest w-full")
("data-tab" "replies")
(div
("class" "card small flex items-center gap-2")
(text "{{ icon \"newspaper\" }}")
(span
(text "{{ text \"communities:label.replies\" }}")))
(div
("class" "card flex flex-col gap-4")
(text "{% for post in replies %} {{ components::post(post=post[0], owner=post[1], question=false, secondary=true, show_community=false) }} {% endfor %} {{ components::pagination(page=page, items=replies|length) }}"))))
(script
(text "const community = \"{{ question.community }}\";
window.answer_question_from_form = async (e, answering) => {
e.preventDefault();
await trigger(\"atto::debounce\", [\"posts::create\"]);
// create body
const body = new FormData();
if (e.target.file_picker) {
for (const file of e.target.file_picker.files) {
body.append(file.name, file);
}
}
body.append(
\"body\",
JSON.stringify({
content: e.target.content.value,
community: community ? community : \"{{ config.town_square }}\",
answering,
}),
);
// ...
fetch(\"/api/v1/posts\", {
method: \"POST\",
body,
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
window.location.reload();
}
});
};"))
(text "{% endblock %}")

View file

@ -1,30 +0,0 @@
{% import "components.html" as components %} {% extends "communities/base.html"
%} {% block content %}
<div class="flex flex-col gap-4 w-full">
{{ macros::community_nav(community=community, selected="questions") }}
<!-- prettier-ignore -->
{% if user and can_post -%}
<div style="display: contents">
{{ components::create_question_form(community=community.id,
is_global=true) }}
</div>
{%- endif %}
<div class="card-nest">
<div class="card small flex gap-2 items-center">
{{ icon "newspaper" }}
<span>{{ text "communities:label.questions" }}</span>
</div>
<div class="card flex flex-col gap-4">
<!-- prettier-ignore -->
{% for question in feed %}
{{ components::global_question(question=question, can_manage_questions=can_manage_questions, show_community=false, secondary=true) }}
{% endfor %}
{{ components::pagination(page=page, items=feed|length) }}
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,21 @@
(text "{% import \"components.html\" as components %} {% extends \"communities/base.html\" %} {% block content %}")
(div
("class" "flex flex-col gap-4 w-full")
(text "{{ macros::community_nav(community=community, selected=\"questions\") }}")
(text "{% if user and can_post -%}")
(div
("style" "display: contents")
(text "{{ components::create_question_form(community=community.id, is_global=true) }}"))
(text "{%- endif %}")
(div
("class" "card-nest")
(div
("class" "card small flex gap-2 items-center")
(text "{{ icon \"newspaper\" }}")
(span
(text "{{ text \"communities:label.questions\" }}")))
(div
("class" "card flex flex-col gap-4")
(text "{% for question in feed %} {{ components::global_question(question=question, can_manage_questions=can_manage_questions, show_community=false, secondary=true) }} {% endfor %} {{ components::pagination(page=page, items=feed|length) }}"))))
(text "{% endblock %}")

View file

@ -1,45 +0,0 @@
{% extends "root.html" %} {% block head %}
<title>Search communities - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav(selected="communities") }}
<main class="flex flex-col gap-2">
<div class="card-nest">
<div class="card small flex items-center gap-2">
{{ icon "search" }}
<span>{{ text "general:link.search" }}</span>
</div>
<form class="card flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label for="text">{{ text "communities:label.query" }}</label>
<input
type="text"
name="text"
id="text"
placeholder="text"
required
maxlength="32"
value="{{ text }}"
/>
</div>
<button class="primary">{{ text "dialog:action.continue" }}</button>
</form>
</div>
<div class="card-nest">
<div class="card small flex items-center gap-2">
{{ icon "book-marked" }}
<span>{{ text "communities:label.search_results" }}</span>
</div>
<!-- prettier-ignore -->
<div class="card flex flex-col gap-4">
{% for item in list %}
{{ components::community_listing_card(community=item) }}
{% endfor %}
{{ components::pagination(page=page, items=list|length, key="&text=", value=text) }}
</div>
</div>
</main>
{% endblock %}

View file

@ -0,0 +1,44 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Search communities - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"communities\") }}")
(main
("class" "flex flex-col gap-2")
(div
("class" "card-nest")
(div
("class" "card small flex items-center gap-2")
(text "{{ icon \"search\" }}")
(span
(text "{{ text \"general:link.search\" }}")))
(form
("class" "card flex flex-col gap-4")
(div
("class" "flex flex-col gap-1")
(label
("for" "text")
(text "{{ text \"communities:label.query\" }}"))
(input
("type" "text")
("name" "text")
("id" "text")
("placeholder" "text")
("required" "")
("maxlength" "32")
("value" "{{ text }}")))
(button
("class" "primary")
(text "{{ text \"dialog:action.continue\" }}"))))
(div
("class" "card-nest")
(div
("class" "card small flex items-center gap-2")
(text "{{ icon \"book-marked\" }}")
(span
(text "{{ text \"communities:label.search_results\" }}")))
(div
("class" "card flex flex-col gap-4")
(text "{% for item in list %} {{ components::community_listing_card(community=item) }} {% endfor %} {{ components::pagination(page=page, items=list|length, key=\"&text=\", value=text) }}"))))
(text "{% endblock %}")

View file

@ -1,964 +0,0 @@
{% extends "root.html" %} {% block head %}
<title>Community settings - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav() }}
<main class="flex flex-col gap-2">
<div class="pillmenu">
<a href="#/general" data-tab-button="general" class="active">
{{ icon "settings" }}
<span>{{ text "settings:tab.general" }}</span>
</a>
<a href="#/images" data-tab-button="images">
{{ icon "image" }}
<span>{{ text "settings:tab.images" }}</span>
</a>
<a href="#/members" data-tab-button="members">
{{ icon "users-round" }}
<span>{{ text "communities:tab.members" }}</span>
</a>
{% if can_manage_channels -%}
<a href="#/channels" data-tab-button="channels">
{{ icon "rss" }}
<span>{{ text "communities:tab.channels" }}</span>
</a>
{%- endif %} {% if can_manage_emojis -%}
<a href="#/emojis" data-tab-button="emojis">
{{ icon "smile" }}
<span>{{ text "communities:tab.emojis" }}</span>
</a>
{%- endif %}
</div>
<div class="w-full flex flex-col gap-2" data-tab="general">
<div id="manage_fields" class="card tertiary flex flex-col gap-2">
<div class="card-nest" ui_ident="read_access">
<div class="card small">
<b>Read access</b>
</div>
<div class="card">
<select onchange="save_access(event, 'read')">
<option
value="Everybody"
selected="{% if community.read_access == 'Everybody' -%}true{% else %}false{%- endif %}"
>
Everybody
</option>
<option
value="Joined"
selected="{% if community.read_access == 'Joined' -%}true{% else %}false{%- endif %}"
>
Joined
</option>
</select>
</div>
</div>
<div class="card-nest" ui_ident="join_access">
<div class="card small">
<b>Join access</b>
</div>
<div class="card">
<select onchange="save_access(event, 'join')">
<option
value="Everybody"
selected="{% if community.join_access == 'Everybody' -%}true{% else %}false{%- endif %}"
>
Everybody
</option>
<option
value="Request"
selected="{% if community.join_access == 'Request' -%}true{% else %}false{%- endif %}"
>
Request
</option>
<option
value="Nobody"
selected="{% if community.join_access == 'Nobody' -%}true{% else %}false{%- endif %}"
>
Nobody
</option>
</select>
</div>
</div>
<div class="card-nest" ui_ident="write_access">
<div class="card small">
<b>Post permission</b>
</div>
<div class="card">
<select onchange="save_access(event, 'write')">
<option
value="Everybody"
selected="{% if community.write_access == 'Everybody' -%}true{% else %}false{%- endif %}"
>
Everybody
</option>
<option
value="Joined"
selected="{% if community.write_access == 'Joined' -%}true{% else %}false{%- endif %}"
>
Joined
</option>
<option
value="Owner"
selected="{% if community.write_access == 'Owner' -%}true{% else %}false{%- endif %}"
>
Owner only
</option>
</select>
</div>
</div>
<div class="card-nest" ui_ident="change_title">
<div class="card small">
<b>{{ text "communities:label.change_title" }}</b>
</div>
<form
class="card flex flex-col gap-2"
onsubmit="change_title(event)"
>
<div class="flex flex-col gap-1">
<label for="new_title"
>{{ text "communities:label.new_title" }}</label
>
<input
type="text"
name="new_title"
id="new_title"
placeholder="new_title"
required
minlength="2"
/>
</div>
<button class="primary">
{{ icon "check" }}
<span>{{ text "general:action.save" }}</span>
</button>
</form>
</div>
</div>
<div class="card-nest" ui_ident="danger_zone">
<div class="card small flex gap-1 items-center red">
{{ icon "skull" }}
<b> {{ text "communities:label.danger_zone" }} </b>
</div>
<div class="card flex flex-wrap gap-2">
<button class="red quaternary" onclick="delete_community()">
{{ icon "trash" }}
<span>{{ text "communities:label.delete_community" }}</span>
</button>
</div>
</div>
<div class="flex gap-2 flex-wrap">
<button onclick="save_context()">
{{ icon "check" }}
<span>{{ text "general:action.save" }}</span>
</button>
<a href="/community/{{ community.title }}" class="button secondary">
{{ icon "arrow-left" }}
<span>{{ text "general:action.back" }}</span>
</a>
</div>
</div>
<div
class="card tertiary w-full hidden flex flex-col gap-2"
data-tab="images"
>
<div class="card-nest" ui_ident="change_avatar">
<div class="card small">
<b>{{ text "settings:label.change_avatar" }}</b>
</div>
<form
class="card flex gap-2 flex-row flex-wrap items-center"
method="post"
enctype="multipart/form-data"
onsubmit="upload_avatar(event)"
>
<input
id="avatar_file"
name="file"
type="file"
accept="image/png,image/jpeg,image/avif,image/webp,image/gif"
class="w-content"
/>
<button class="primary">{{ icon "check" }}</button>
</form>
</div>
<div class="card-nest" ui_ident="change_banner">
<div class="card small">
<b>{{ text "settings:label.change_banner" }}</b>
</div>
<form
class="card flex flex-col gap-2"
method="post"
enctype="multipart/form-data"
onsubmit="upload_banner(event)"
>
<div class="flex gap-2 flex-row flex-wrap items-center">
<input
id="banner_file"
name="file"
type="file"
accept="image/png,image/jpeg,image/avif,image/webp"
class="w-content"
/>
<button class="primary">{{ icon "check" }}</button>
</div>
<span class="fade"
>Use an image of 1100x350px for the best results.</span
>
</form>
</div>
</div>
<div
class="card tertiary w-full hidden flex flex-col gap-2"
data-tab="members"
>
<div class="card-nest">
<div class="card small">
<b>{{ text "communities:label.select_member" }}</b>
</div>
<form
class="card flex-col gap-2"
onsubmit="select_user_from_form(event)"
>
<div class="flex flex-col gap-1">
<div class="flex flex-col gap-1">
<label for="uid"
>{{ text "communities:label.user_id" }}</label
>
<input
type="number"
name="uid"
id="uid"
placeholder="user id"
required
minlength="18"
/>
</div>
<button class="primary">
{{ text "communities:action.select" }}
</button>
</div>
</form>
</div>
<div class="card flex flex-col gap-2 w-full" id="membership_info"></div>
</div>
{% if can_manage_channels -%}
<div
class="card tertiary w-full hidden flex flex-col gap-2"
data-tab="channels"
>
<div class="card-nest">
<div class="card small">
<b>{{ text "communities:action.create_channel" }}</b>
</div>
<form
class="card flex flex-col gap-2"
onsubmit="create_channel_from_form(event)"
>
<div class="flex flex-col gap-1">
<label for="title"
>{{ text "communities:label.name" }}</label
>
<input
type="text"
name="title"
id="title"
placeholder="name"
required
minlength="2"
maxlength="32"
/>
</div>
<button class="primary">
{{ text "communities:action.create" }}
</button>
</form>
</div>
{% for channel in channels %}
<div class="card-nest">
<div class="card small">
<b>{{ channel.position }}</b>
{{ channel.title }}
</div>
<div class="card flex gap-2">
<button
class="red quaternary small"
onclick="delete_channel('{{ channel.id }}')"
>
{{ text "general:action.delete" }}
</button>
<button
class="quaternary small"
onclick="update_channel_position('{{ channel.id }}')"
>
{{ text "chats:action.move" }}
</button>
<button
class="quaternary small"
onclick="update_channel_title('{{ channel.id }}')"
>
{{ text "chats:action.rename" }}
</button>
</div>
</div>
{% endfor %}
</div>
<script>
globalThis.delete_channel = async (id) => {
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this?",
]))
) {
return;
}
fetch(`/api/v1/channels/${id}`, {
method: "DELETE",
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
globalThis.update_channel_position = async (id) => {
await trigger("atto::debounce", ["channels::move"]);
const position = Number.parseInt(
await trigger("atto::prompt", [
"New channel position (number):",
]),
);
if (!position && position !== 0) {
return alert("Must be a number!");
}
fetch(`/api/v1/channels/${id}/move`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
position,
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
globalThis.update_channel_title = async (id) => {
await trigger("atto::debounce", ["channels::update_title"]);
const title = await trigger("atto::prompt", ["New channel title:"]);
if (!title) {
return;
}
fetch(`/api/v1/channels/${id}/title`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title,
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
async function create_channel_from_form(e) {
e.preventDefault();
await trigger("atto::debounce", ["channels::create"]);
fetch("/api/v1/channels", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: e.target.title.value,
community: "{{ community.id }}",
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
if (res.ok) {
window.location.reload();
}
});
}
</script>
{%- endif %} {% if can_manage_emojis -%}
<div
class="card tertiary w-full hidden flex flex-col gap-2"
data-tab="emojis"
>
{{ components::supporter_ad(body="Become a supporter to upload GIF
animated emojis!") }}
<div class="card-nest" ui_ident="change_banner">
<div class="card small flex items-center gap-2">
{{ icon "upload" }}
<b>{{ text "communities:label.upload" }}</b>
</div>
<form
class="card flex flex-col gap-2"
onsubmit="upload_emoji(event)"
>
<div class="flex flex-col gap-1">
<label for="name"
>{{ text "communities:label.name" }}</label
>
<input
type="text"
name="name"
id="name"
placeholder="name"
required
minlength="2"
maxlength="32"
/>
</div>
<div class="flex flex-col gap-1">
<label for="file"
>{{ text "communities:label.file" }}</label
>
<input
id="banner_file"
name="file"
type="file"
accept="image/png,image/jpeg,image/avif,image/webp"
class="w-full"
/>
</div>
<button>{{ text "communities:action.create" }}</button>
<span class="fade"
>Emojis can be a maximum of 256 KiB, or 512x512px (width x
height).</span
>
</form>
</div>
{% for emoji in emojis %}
<div
class="card secondary flex flex-wrap gap-2 items-center justify-between"
>
<div class="flex gap-2 items-center">
<img
src="/api/v1/communities/{{ community.id }}/emojis/{{ emoji.name }}"
alt="{{ emoji.name }}"
class="emoji"
loading="lazy"
/>
<b>{{ emoji.name }}</b>
</div>
<div class="flex gap-2">
<button
class="quaternary small"
onclick="rename_emoji('{{ emoji.id }}')"
>
{{ icon "pencil" }}
<span>{{ text "chats:action.rename" }}</span>
</button>
<button
class="quaternary small red"
onclick="remove_emoji('{{ emoji.id }}')"
>
{{ icon "x" }}
<span>{{ text "stacks:label.remove" }}</span>
</button>
</div>
</div>
{% endfor %}
</div>
<script>
globalThis.upload_emoji = (e) => {
e.preventDefault();
e.target.querySelector("button").style.display = "none";
fetch(
`/api/v1/communities/{{ community.id }}/emojis/${e.target.name.value}`,
{
method: "POST",
body: e.target.file.files[0],
},
)
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
e.target.querySelector("button").removeAttribute("style");
});
alert("Emoji upload in progress. Please wait!");
};
globalThis.rename_emoji = async (id) => {
const name = await trigger("atto::prompt", ["New emoji name:"]);
if (!name) {
return;
}
fetch(`/api/v1/emojis_id/${id}/name`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name,
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
globalThis.remove_emoji = async (id) => {
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this? This action is permanent.",
]))
) {
return;
}
fetch(`/api/v1/emojis_id/${id}`, {
method: "DELETE",
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
</script>
{%- endif %}
</main>
<script>
setTimeout(() => {
const element = document.getElementById("membership_info");
const ui = ns("ui");
const uid = new URLSearchParams(window.location.search).get("uid");
if (uid) {
document.getElementById("uid").value = uid;
}
globalThis.update_user_role = async (uid, new_role) => {
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this?",
]))
) {
return;
}
fetch(
`/api/v1/communities/{{ community.id }}/memberships/${uid}/role`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
role: Number.parseInt(new_role),
}),
},
)
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
globalThis.kick_user = async (uid, new_role) => {
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this?",
]))
) {
return;
}
fetch(`/api/v1/communities/{{ community.id }}/memberships/${uid}`, {
method: "DELETE",
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
globalThis.transfer_ownership = async (uid) => {
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this?\n\nThis action is PERMANENT!",
]))
) {
return;
}
fetch(`/api/v1/communities/{{ community.id }}/transfer_ownership`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
user: uid,
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
globalThis.select_user_from_form = (e) => {
e.preventDefault();
fetch(
`/api/v1/communities/{{ community.id }}/memberships/${e.target.uid.value}`,
)
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
if (!res.ok) {
return;
}
// permissions manager
const get_permissions_html = trigger(
"ui::generate_permissions_ui",
[
{
// https://trisuaso.github.io/tetratto/tetratto/model/communities_permissions/struct.CommunityPermission.html
DEFAULT: 1 << 0,
ADMINISTRATOR: 1 << 1,
MEMBER: 1 << 2,
MANAGE_POSTS: 1 << 3,
MANAGE_ROLES: 1 << 4,
BANNED: 1 << 5,
REQUESTED: 1 << 6,
MANAGE_PINS: 1 << 7,
MANAGE_COMMUNITY: 1 << 8,
MANAGE_QUESTIONS: 1 << 9,
MANAGE_CHANNELS: 1 << 10,
MANAGE_MESSAGES: 1 << 11,
MANAGE_EMOJIS: 1 << 12,
},
],
);
// ...
element.innerHTML = `<div class="flex gap-2 flex-wrap" ui_ident="actions">
<a target="_blank" class="button" href="/api/v1/auth/user/find/${e.target.uid.value}">Open user profile</a>
${res.payload.role !== 33 ? `<button class="red quaternary" onclick="update_user_role('${e.target.uid.value}', 33)">Ban</button>` : `<button class="quaternary" onclick="update_user_role('${e.target.uid.value}', 5)">Unban</button>`}
${res.payload.role !== 65 ? `<button class="red quaternary" onclick="update_user_role('${e.target.uid.value}', 65)">Send to review</button>` : `<button class="green quaternary" onclick="update_user_role('${e.target.uid.value}', 5)">Accept join request</button>`}
<button class="red quaternary" onclick="kick_user('${e.target.uid.value}')">Kick</button>
<button class="red quaternary" onclick="transfer_ownership('${e.target.uid.value}')">Transfer ownership</button>
</div>
<div class="flex flex-col gap-2" ui_ident="permissions" id="permissions">
${get_permissions_html(res.payload.role, "permissions")}
</div>`;
ui.refresh_container(element, ["actions", "permissions"]);
ui.generate_settings_ui(
element,
[
[
["role", "Permission level"],
res.payload.role,
"input",
],
],
null,
{
role: (new_role) => {
const [matching, _] =
all_matching_permissions(new_role);
document.getElementById(
"permissions",
).innerHTML = get_permissions_html(
rebuild_role(matching),
"permissions",
);
return update_user_role(
e.target.uid.value,
new_role,
);
},
},
);
});
};
}, 250);
</script>
<!-- prettier-ignore -->
<script type="application/json" id="settings_json">{{ community.context|json_encode()|safe }}</script>
<script>
setTimeout(() => {
const ui = ns("ui");
const settings = JSON.parse(
document.getElementById("settings_json").innerHTML,
);
globalThis.upload_avatar = (e) => {
e.preventDefault();
e.target.querySelector("button").style.display = "none";
fetch("/api/v1/communities/{{ community.id }}/upload/avatar", {
method: "POST",
body: e.target.file.files[0],
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
e.target.querySelector("button").removeAttribute("style");
});
alert("Avatar upload in progress. Please wait!");
};
globalThis.upload_banner = (e) => {
e.preventDefault();
e.target.querySelector("button").style.display = "none";
fetch("/api/v1/communities/{{ community.id }}/upload/banner", {
method: "POST",
body: e.target.file.files[0],
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
e.target.querySelector("button").removeAttribute("style");
});
alert("Banner upload in progress. Please wait!");
};
globalThis.save_context = () => {
fetch("/api/v1/communities/{{ community.id }}/context", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
context: settings,
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
globalThis.save_access = (event, mode) => {
const selected = event.target.selectedOptions[0];
fetch(`/api/v1/communities/{{ community.id }}/access/${mode}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
access: selected.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
globalThis.change_title = async (e) => {
e.preventDefault();
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this?",
]))
) {
return;
}
fetch("/api/v1/communities/{{ community.id }}/title", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: e.target.new_title.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
globalThis.delete_community = async () => {
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this? This action is permanent.",
]))
) {
return;
}
fetch(`/api/v1/communities/{{ community.id }}`, {
method: "DELETE",
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
ui.refresh_container(document.getElementById("manage_fields"), [
"read_access",
"join_access",
"write_access",
"change_title",
"change_avatar",
"change_banner",
]);
ui.generate_settings_ui(
document.getElementById("manage_fields"),
[
[
["display_name", "Display title"],
"{{ community.context.display_name }}",
"input",
],
[
["description", "Description"],
settings.description,
"textarea",
],
[
["is_nsfw", "Mark as NSFW"],
"{{ community.context.is_nsfw }}",
"checkbox",
],
[
[
"enable_questions",
"Allow users to ask questions in this community",
],
"{{ community.context.enable_questions }}",
"checkbox",
],
],
settings,
);
}, 250);
</script>
{% endblock %}

View file

@ -0,0 +1,912 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Community settings - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
(div
("class" "pillmenu")
(a
("href" "#/general")
("data-tab-button" "general")
("class" "active")
(text "{{ icon \"settings\" }}")
(span
(text "{{ text \"settings:tab.general\" }}")))
(a
("href" "#/images")
("data-tab-button" "images")
(text "{{ icon \"image\" }}")
(span
(text "{{ text \"settings:tab.images\" }}")))
(a
("href" "#/members")
("data-tab-button" "members")
(text "{{ icon \"users-round\" }}")
(span
(text "{{ text \"communities:tab.members\" }}")))
(text "{% if can_manage_channels -%}")
(a
("href" "#/channels")
("data-tab-button" "channels")
(text "{{ icon \"rss\" }}")
(span
(text "{{ text \"communities:tab.channels\" }}")))
(text "{%- endif %} {% if can_manage_emojis -%}")
(a
("href" "#/emojis")
("data-tab-button" "emojis")
(text "{{ icon \"smile\" }}")
(span
(text "{{ text \"communities:tab.emojis\" }}")))
(text "{%- endif %}"))
(div
("class" "w-full flex flex-col gap-2")
("data-tab" "general")
(div
("id" "manage_fields")
("class" "card tertiary flex flex-col gap-2")
(div
("class" "card-nest")
("ui_ident" "read_access")
(div
("class" "card small")
(b
(text "Read access")))
(div
("class" "card")
(select
("onchange" "save_access(event, 'read')")
(option
("value" "Everybody")
("selected" "{% if community.read_access == 'Everybody' -%}true{% else %}false{%- endif %}")
(text "Everybody"))
(option
("value" "Joined")
("selected" "{% if community.read_access == 'Joined' -%}true{% else %}false{%- endif %}")
(text "Joined")))))
(div
("class" "card-nest")
("ui_ident" "join_access")
(div
("class" "card small")
(b
(text "Join access")))
(div
("class" "card")
(select
("onchange" "save_access(event, 'join')")
(option
("value" "Everybody")
("selected" "{% if community.join_access == 'Everybody' -%}true{% else %}false{%- endif %}")
(text "Everybody"))
(option
("value" "Request")
("selected" "{% if community.join_access == 'Request' -%}true{% else %}false{%- endif %}")
(text "Request"))
(option
("value" "Nobody")
("selected" "{% if community.join_access == 'Nobody' -%}true{% else %}false{%- endif %}")
(text "Nobody")))))
(div
("class" "card-nest")
("ui_ident" "write_access")
(div
("class" "card small")
(b
(text "Post permission")))
(div
("class" "card")
(select
("onchange" "save_access(event, 'write')")
(option
("value" "Everybody")
("selected" "{% if community.write_access == 'Everybody' -%}true{% else %}false{%- endif %}")
(text "Everybody"))
(option
("value" "Joined")
("selected" "{% if community.write_access == 'Joined' -%}true{% else %}false{%- endif %}")
(text "Joined"))
(option
("value" "Owner")
("selected" "{% if community.write_access == 'Owner' -%}true{% else %}false{%- endif %}")
(text "Owner only")))))
(div
("class" "card-nest")
("ui_ident" "change_title")
(div
("class" "card small")
(b
(text "{{ text \"communities:label.change_title\" }}")))
(form
("class" "card flex flex-col gap-2")
("onsubmit" "change_title(event)")
(div
("class" "flex flex-col gap-1")
(label
("for" "new_title")
(text "{{ text \"communities:label.new_title\" }}"))
(input
("type" "text")
("name" "new_title")
("id" "new_title")
("placeholder" "new_title")
("required" "")
("minlength" "2")))
(button
("class" "primary")
(text "{{ icon \"check\" }}")
(span
(text "{{ text \"general:action.save\" }}"))))))
(div
("class" "card-nest")
("ui_ident" "danger_zone")
(div
("class" "card small flex gap-1 items-center red")
(text "{{ icon \"skull\" }}")
(b
(text "{{ text \"communities:label.danger_zone\" }}")))
(div
("class" "card flex flex-wrap gap-2")
(button
("class" "red quaternary")
("onclick" "delete_community()")
(text "{{ icon \"trash\" }}")
(span
(text "{{ text \"communities:label.delete_community\" }}")))))
(div
("class" "flex gap-2 flex-wrap")
(button
("onclick" "save_context()")
(text "{{ icon \"check\" }}")
(span
(text "{{ text \"general:action.save\" }}")))
(a
("href" "/community/{{ community.title }}")
("class" "button secondary")
(text "{{ icon \"arrow-left\" }}")
(span
(text "{{ text \"general:action.back\" }}")))))
(div
("class" "card tertiary w-full hidden flex flex-col gap-2")
("data-tab" "images")
(div
("class" "card-nest")
("ui_ident" "change_avatar")
(div
("class" "card small")
(b
(text "{{ text \"settings:label.change_avatar\" }}")))
(form
("class" "card flex gap-2 flex-row flex-wrap items-center")
("method" "post")
("enctype" "multipart/form-data")
("onsubmit" "upload_avatar(event)")
(input
("id" "avatar_file")
("name" "file")
("type" "file")
("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif")
("class" "w-content"))
(button
("class" "primary")
(text "{{ icon \"check\" }}"))))
(div
("class" "card-nest")
("ui_ident" "change_banner")
(div
("class" "card small")
(b
(text "{{ text \"settings:label.change_banner\" }}")))
(form
("class" "card flex flex-col gap-2")
("method" "post")
("enctype" "multipart/form-data")
("onsubmit" "upload_banner(event)")
(div
("class" "flex gap-2 flex-row flex-wrap items-center")
(input
("id" "banner_file")
("name" "file")
("type" "file")
("accept" "image/png,image/jpeg,image/avif,image/webp")
("class" "w-content"))
(button
("class" "primary")
(text "{{ icon \"check\" }}")))
(span
("class" "fade")
(text "Use an image of 1100x350px for the best results.")))))
(div
("class" "card tertiary w-full hidden flex flex-col gap-2")
("data-tab" "members")
(div
("class" "card-nest")
(div
("class" "card small")
(b
(text "{{ text \"communities:label.select_member\" }}")))
(form
("class" "card flex-col gap-2")
("onsubmit" "select_user_from_form(event)")
(div
("class" "flex flex-col gap-1")
(div
("class" "flex flex-col gap-1")
(label
("for" "uid")
(text "{{ text \"communities:label.user_id\" }}"))
(input
("type" "number")
("name" "uid")
("id" "uid")
("placeholder" "user id")
("required" "")
("minlength" "18")))
(button
("class" "primary")
(text "{{ text \"communities:action.select\" }}")))))
(div
("class" "card flex flex-col gap-2 w-full")
("id" "membership_info")))
(text "{% if can_manage_channels -%}")
(div
("class" "card tertiary w-full hidden flex flex-col gap-2")
("data-tab" "channels")
(div
("class" "card-nest")
(div
("class" "card small")
(b
(text "{{ text \"communities:action.create_channel\" }}")))
(form
("class" "card flex flex-col gap-2")
("onsubmit" "create_channel_from_form(event)")
(div
("class" "flex flex-col gap-1")
(label
("for" "title")
(text "{{ text \"communities:label.name\" }}"))
(input
("type" "text")
("name" "title")
("id" "title")
("placeholder" "name")
("required" "")
("minlength" "2")
("maxlength" "32")))
(button
("class" "primary")
(text "{{ text \"communities:action.create\" }}"))))
(text "{% for channel in channels %}")
(div
("class" "card-nest")
(div
("class" "card small")
(b
(text "{{ channel.position }} "))
(text "{{ channel.title }}"))
(div
("class" "card flex gap-2")
(button
("class" "red quaternary small")
("onclick" "delete_channel('{{ channel.id }}')")
(text "{{ text \"general:action.delete\" }}"))
(button
("class" "quaternary small")
("onclick" "update_channel_position('{{ channel.id }}')")
(text "{{ text \"chats:action.move\" }}"))
(button
("class" "quaternary small")
("onclick" "update_channel_title('{{ channel.id }}')")
(text "{{ text \"chats:action.rename\" }}"))))
(text "{% endfor %}"))
(script
(text "globalThis.delete_channel = async (id) => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(`/api/v1/channels/${id}`, {
method: \"DELETE\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
globalThis.update_channel_position = async (id) => {
await trigger(\"atto::debounce\", [\"channels::move\"]);
const position = Number.parseInt(
await trigger(\"atto::prompt\", [
\"New channel position (number):\",
]),
);
if (!position && position !== 0) {
return alert(\"Must be a number!\");
}
fetch(`/api/v1/channels/${id}/move`, {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
position,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
globalThis.update_channel_title = async (id) => {
await trigger(\"atto::debounce\", [\"channels::update_title\"]);
const title = await trigger(\"atto::prompt\", [\"New channel title:\"]);
if (!title) {
return;
}
fetch(`/api/v1/channels/${id}/title`, {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
title,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
async function create_channel_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"channels::create\"]);
fetch(\"/api/v1/channels\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
title: e.target.title.value,
community: \"{{ community.id }}\",
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
window.location.reload();
}
});
}"))
(text "{%- endif %} {% if can_manage_emojis -%}")
(div
("class" "card tertiary w-full hidden flex flex-col gap-2")
("data-tab" "emojis")
(text "{{ components::supporter_ad(body=\"Become a supporter to upload GIF animated emojis!\") }}")
(div
("class" "card-nest")
("ui_ident" "change_banner")
(div
("class" "card small flex items-center gap-2")
(text "{{ icon \"upload\" }}")
(b
(text "{{ text \"communities:label.upload\" }}")))
(form
("class" "card flex flex-col gap-2")
("onsubmit" "upload_emoji(event)")
(div
("class" "flex flex-col gap-1")
(label
("for" "name")
(text "{{ text \"communities:label.name\" }}"))
(input
("type" "text")
("name" "name")
("id" "name")
("placeholder" "name")
("required" "")
("minlength" "2")
("maxlength" "32")))
(div
("class" "flex flex-col gap-1")
(label
("for" "file")
(text "{{ text \"communities:label.file\" }}"))
(input
("id" "banner_file")
("name" "file")
("type" "file")
("accept" "image/png,image/jpeg,image/avif,image/webp")
("class" "w-full")))
(button
(text "{{ text \"communities:action.create\" }}"))
(span
("class" "fade")
(text "Emojis can be a maximum of 256 KiB, or 512x512px (width x
height)."))))
(text "{% for emoji in emojis %}")
(div
("class" "card secondary flex flex-wrap gap-2 items-center justify-between")
(div
("class" "flex gap-2 items-center")
(img
("src" "/api/v1/communities/{{ community.id }}/emojis/{{ emoji.name }}")
("alt" "{{ emoji.name }}")
("class" "emoji")
("loading" "lazy"))
(b
(text "{{ emoji.name }}")))
(div
("class" "flex gap-2")
(button
("class" "quaternary small")
("onclick" "rename_emoji('{{ emoji.id }}')")
(text "{{ icon \"pencil\" }}")
(span
(text "{{ text \"chats:action.rename\" }}")))
(button
("class" "quaternary small red")
("onclick" "remove_emoji('{{ emoji.id }}')")
(text "{{ icon \"x\" }}")
(span
(text "{{ text \"stacks:label.remove\" }}")))))
(text "{% endfor %}"))
(script
(text "globalThis.upload_emoji = (e) => {
e.preventDefault();
e.target.querySelector(\"button\").style.display = \"none\";
fetch(
`/api/v1/communities/{{ community.id }}/emojis/${e.target.name.value}`,
{
method: \"POST\",
body: e.target.file.files[0],
},
)
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
e.target.querySelector(\"button\").removeAttribute(\"style\");
});
alert(\"Emoji upload in progress. Please wait!\");
};
globalThis.rename_emoji = async (id) => {
const name = await trigger(\"atto::prompt\", [\"New emoji name:\"]);
if (!name) {
return;
}
fetch(`/api/v1/emojis_id/${id}/name`, {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
name,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
globalThis.remove_emoji = async (id) => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this? This action is permanent.\",
]))
) {
return;
}
fetch(`/api/v1/emojis_id/${id}`, {
method: \"DELETE\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};"))
(text "{%- endif %}"))
(script
(text "setTimeout(() => {
const element = document.getElementById(\"membership_info\");
const ui = ns(\"ui\");
const uid = new URLSearchParams(window.location.search).get(\"uid\");
if (uid) {
document.getElementById(\"uid\").value = uid;
}
globalThis.update_user_role = async (uid, new_role) => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(
`/api/v1/communities/{{ community.id }}/memberships/${uid}/role`,
{
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
role: Number.parseInt(new_role),
}),
},
)
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
globalThis.kick_user = async (uid, new_role) => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(`/api/v1/communities/{{ community.id }}/memberships/${uid}`, {
method: \"DELETE\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
globalThis.transfer_ownership = async (uid) => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\n\nThis action is PERMANENT!\",
]))
) {
return;
}
fetch(`/api/v1/communities/{{ community.id }}/transfer_ownership`, {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
user: uid,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
globalThis.select_user_from_form = (e) => {
e.preventDefault();
fetch(
`/api/v1/communities/{{ community.id }}/memberships/${e.target.uid.value}`,
)
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (!res.ok) {
return;
}
// permissions manager
const get_permissions_html = trigger(
\"ui::generate_permissions_ui\",
[
{
// https://trisuaso.github.io/tetratto/tetratto/model/communities_permissions/struct.CommunityPermission.html
DEFAULT: 1 << 0,
ADMINISTRATOR: 1 << 1,
MEMBER: 1 << 2,
MANAGE_POSTS: 1 << 3,
MANAGE_ROLES: 1 << 4,
BANNED: 1 << 5,
REQUESTED: 1 << 6,
MANAGE_PINS: 1 << 7,
MANAGE_COMMUNITY: 1 << 8,
MANAGE_QUESTIONS: 1 << 9,
MANAGE_CHANNELS: 1 << 10,
MANAGE_MESSAGES: 1 << 11,
MANAGE_EMOJIS: 1 << 12,
},
],
);
// ...
element.innerHTML = `<div class=\"flex gap-2 flex-wrap\" ui_ident=\"actions\">
<a target=\"_blank\" class=\"button\" href=\"/api/v1/auth/user/find/${e.target.uid.value}\">Open user profile</a>
${res.payload.role !== 33 ? `<button class=\"red quaternary\" onclick=\"update_user_role('${e.target.uid.value}', 33)\">Ban</button>` : `<button class=\"quaternary\" onclick=\"update_user_role('${e.target.uid.value}', 5)\">Unban</button>`}
${res.payload.role !== 65 ? `<button class=\"red quaternary\" onclick=\"update_user_role('${e.target.uid.value}', 65)\">Send to review</button>` : `<button class=\"green quaternary\" onclick=\"update_user_role('${e.target.uid.value}', 5)\">Accept join request</button>`}
<button class=\"red quaternary\" onclick=\"kick_user('${e.target.uid.value}')\">Kick</button>
<button class=\"red quaternary\" onclick=\"transfer_ownership('${e.target.uid.value}')\">Transfer ownership</button>
</div>
<div class=\"flex flex-col gap-2\" ui_ident=\"permissions\" id=\"permissions\">
${get_permissions_html(res.payload.role, \"permissions\")}
</div>`;
ui.refresh_container(element, [\"actions\", \"permissions\"]);
ui.generate_settings_ui(
element,
[
[
[\"role\", \"Permission level\"],
res.payload.role,
\"input\",
],
],
null,
{
role: (new_role) => {
const [matching, _] =
all_matching_permissions(new_role);
document.getElementById(
\"permissions\",
).innerHTML = get_permissions_html(
rebuild_role(matching),
\"permissions\",
);
return update_user_role(
e.target.uid.value,
new_role,
);
},
},
);
});
};
}, 250);"))
(script
("type" "application/json")
("id" "settings_json")
(text "{{ community.context|json_encode()|safe }}"))
(script
(text "setTimeout(() => {
const ui = ns(\"ui\");
const settings = JSON.parse(
document.getElementById(\"settings_json\").innerHTML,
);
globalThis.upload_avatar = (e) => {
e.preventDefault();
e.target.querySelector(\"button\").style.display = \"none\";
fetch(\"/api/v1/communities/{{ community.id }}/upload/avatar\", {
method: \"POST\",
body: e.target.file.files[0],
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
e.target.querySelector(\"button\").removeAttribute(\"style\");
});
alert(\"Avatar upload in progress. Please wait!\");
};
globalThis.upload_banner = (e) => {
e.preventDefault();
e.target.querySelector(\"button\").style.display = \"none\";
fetch(\"/api/v1/communities/{{ community.id }}/upload/banner\", {
method: \"POST\",
body: e.target.file.files[0],
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
e.target.querySelector(\"button\").removeAttribute(\"style\");
});
alert(\"Banner upload in progress. Please wait!\");
};
globalThis.save_context = () => {
fetch(\"/api/v1/communities/{{ community.id }}/context\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
context: settings,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
globalThis.save_access = (event, mode) => {
const selected = event.target.selectedOptions[0];
fetch(`/api/v1/communities/{{ community.id }}/access/${mode}`, {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
access: selected.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
globalThis.change_title = async (e) => {
e.preventDefault();
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(\"/api/v1/communities/{{ community.id }}/title\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
title: e.target.new_title.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
globalThis.delete_community = async () => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this? This action is permanent.\",
]))
) {
return;
}
fetch(`/api/v1/communities/{{ community.id }}`, {
method: \"DELETE\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
ui.refresh_container(document.getElementById(\"manage_fields\"), [
\"read_access\",
\"join_access\",
\"write_access\",
\"change_title\",
\"change_avatar\",
\"change_banner\",
]);
ui.generate_settings_ui(
document.getElementById(\"manage_fields\"),
[
[
[\"display_name\", \"Display title\"],
\"{{ community.context.display_name }}\",
\"input\",
],
[
[\"description\", \"Description\"],
settings.description,
\"textarea\",
],
[
[\"is_nsfw\", \"Mark as NSFW\"],
\"{{ community.context.is_nsfw }}\",
\"checkbox\",
],
[
[
\"enable_questions\",
\"Allow users to ask questions in this community\",
],
\"{{ community.context.enable_questions }}\",
\"checkbox\",
],
],
settings,
);
}, 250);"))
(text "{% endblock %}")

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -130,7 +130,7 @@
(a
("href" "/")
("class" "{% if selected == 'all' -%}active{%- endif %}")
(icon (text "earch"))
(icon (text "earth"))
(str (text "general:link.all")))
(text "{%- endif %}")
@ -198,7 +198,6 @@
(text "{% macro profile_nav(selected=\"\") -%}")
(div
("class" "pillmenu")
(a
("href" "/@{{ profile.username }}")
("class" "{% if selected == 'posts' -%}active{%- endif %}")

View file

@ -1,86 +0,0 @@
{% extends "root.html" %} {% block head %}
<title>Notifications - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav(selected="notifications") }}
<main class="flex flex-col gap-2">
<div class="card-nest">
<div class="card small flex items-center justify-between gap-2">
<span class="flex items-center gap-2">
{{ icon "bell" }}
<span>{{ text "notifs:label.notifications" }}</span>
</span>
<div class="flex gap-2">
<button
onclick="trigger('me::clear_notifs')"
class="small red quaternary"
>
{{ icon "bomb" }}
<span>{{ text "notifs:action.clear" }}</span>
</button>
<div class="dropdown">
<button
class="small quaternary"
onclick="trigger('atto::hooks::dropdown', [event])"
exclude="dropdown"
>
{{ icon "ellipsis" }}
</button>
<div class="inner">
<button onclick="mark_all_as_read(true)">
{{ icon "bookmark-check" }}
<span
>{{ text "notifs:label.mark_all_as_read"
}}</span
>
</button>
<button onclick="mark_all_as_read(false)">
{{ icon "bookmark-x" }}
<span
>{{ text "notifs:label.mark_all_as_unread"
}}</span
>
</button>
</div>
</div>
</div>
</div>
<div class="card tertiary flex flex-col gap-4">
{% for notification in notifications %} {{
components::notification(notification=notification) }} {% endfor %}
</div>
</div>
</main>
<script>
async function mark_all_as_read(read) {
if (
!(await trigger("atto::confirm", [
"Are you sure you want to do this?",
]))
) {
return;
}
fetch("/api/v1/notifications/all/read_status", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
read,
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
}
</script>
{% endblock %}

View file

@ -0,0 +1,76 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Notifications - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"notifications\") }}")
(main
("class" "flex flex-col gap-2")
(div
("class" "card-nest")
(div
("class" "card small flex items-center justify-between gap-2")
(span
("class" "flex items-center gap-2")
(text "{{ icon \"bell\" }}")
(span
(text "{{ text \"notifs:label.notifications\" }}")))
(div
("class" "flex gap-2")
(button
("onclick" "trigger('me::clear_notifs')")
("class" "small red quaternary")
(text "{{ icon \"bomb\" }}")
(span
(text "{{ text \"notifs:action.clear\" }}")))
(div
("class" "dropdown")
(button
("class" "small quaternary")
("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown")
(text "{{ icon \"ellipsis\" }}"))
(div
("class" "inner")
(button
("onclick" "mark_all_as_read(true)")
(text "{{ icon \"bookmark-check\" }}")
(span
(text "{{ text \"notifs:label.mark_all_as_read\" }}")))
(button
("onclick" "mark_all_as_read(false)")
(text "{{ icon \"bookmark-x\" }}")
(span
(text "{{ text \"notifs:label.mark_all_as_unread\" }}")))))))
(div
("class" "card tertiary flex flex-col gap-4")
(text "{% for notification in notifications %} {{ components::notification(notification=notification) }} {% endfor %}"))))
(script
(text "async function mark_all_as_read(read) {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you want to do this?\",
]))
) {
return;
}
fetch(\"/api/v1/notifications/all/read_status\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
read,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}"))
(text "{% endblock %}")

View file

@ -1,259 +0,0 @@
{% extends "root.html" %} {% block head %}
<title>Requests - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav(selected="requests") }}
<main class="flex flex-col gap-2">
<div class="card-nest">
<div class="card small flex items-center justify-between gap-2">
<span class="flex items-center gap-2">
{{ icon "inbox" }}
<span>{{ text "requests:label.requests" }}</span>
</span>
<button onclick="clear_requests()" class="small red quaternary">
{{ icon "bomb" }}
<span>{{ text "notifs:action.clear" }}</span>
</button>
</div>
<div class="card tertiary flex flex-col gap-4">
{% for request in requests %} {% if request.action_type ==
"CommunityJoin" %}
<div class="card-nest">
<div class="card small flex items-center gap-2">
{{ icon "user-plus" }}
<span
>{{ text "requests:label.community_join_request"
}}</span
>
</div>
<div class="card flex flex-wrap gap-2">
<a
href="/community/{{ request.linked_asset }}/manage?uid={{ request.id }}#/members"
class="button"
>
{{ icon "external-link" }}
<span>{{ text "requests:label.review" }}</span>
</a>
<button
class="quaternary red"
onclick="remove_request('{{ request.id }}', '{{ request.linked_asset }}')"
>
{{ icon "trash" }}
<span>{{ text "general:action.delete" }}</span>
</button>
</div>
</div>
{% elif request.action_type == "Follow" %}
<div class="card-nest">
<div class="card small flex items-center gap-2">
{{ icon "user-plus" }}
<span>{{ text "requests:label.user_follow_request" }}</span>
</div>
<div class="card flex flex-col gap-2">
<span>
{{ text "requests:label.user_follow_request_message" }}
</span>
<div class="card flex w-full secondary gap-2">
<a
href="/api/v1/auth/user/find/{{ request.id }}"
class="button"
>
{{ icon "external-link" }}
<span
>{{ text "requests:action.view_profile" }}</span
>
</a>
<button
class="quaternary green"
onclick="accept_follow_request(event, '{{ request.id }}')"
>
{{ icon "check" }}
<span>{{ text "general:action.accept" }}</span>
</button>
<button
class="quaternary red"
onclick="remove_request('{{ request.id }}', '{{ request.linked_asset }}')"
>
{{ icon "trash" }}
<span>{{ text "general:action.delete" }}</span>
</button>
</div>
</div>
</div>
{%- endif %} {% endfor %} {% for question in questions %}
<!-- prettier-ignore -->
<div class="card-nest">
{{ components::question(question=question[0], owner=question[1], profile=user) }}
<form
class="card flex flex-col gap-2"
onsubmit="answer_question_from_form(event, '{{ question[0].id }}')"
>
<div class="flex flex-col gap-1">
<label for="content">{{ text "communities:label.content" }}</label>
<textarea
type="text"
name="content"
id="content"
placeholder="content"
required
minlength="2"
maxlength="4096"
></textarea>
</div>
<div id="files_list" class="flex gap-2 flex-wrap"></div>
<div class="flex flex-wrap w-full gap-2">
{{ components::create_post_options() }}
<button class="primary">{{ text "requests:label.answer" }}</button>
<button type="button" class="red quaternary" onclick="trigger('me::remove_question', ['{{ question[0].id }}'])">{{ text "general:action.delete" }}</button>
<button type="button" class="red quaternary" onclick="trigger('me::ip_block_question', ['{{ question[0].id }}'])">{{ text "auth:action.ip_block" }}</button>
</div>
</form>
</div>
{% endfor %}
</div>
</div>
</main>
<script>
async function remove_request(id, linked_asset) {
if (
!(await trigger("atto::confirm", [
"Are you sure you want to do this?",
]))
) {
return;
}
fetch(`/api/v1/requests/${id}/${linked_asset}`, {
method: "DELETE",
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
}
async function clear_requests() {
if (
!(await trigger("atto::confirm", [
"Are you sure you want to do this?",
]))
) {
return;
}
fetch("/api/v1/requests/my", {
method: "DELETE",
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
}
window.answer_question_from_form = async (e, answering) => {
e.preventDefault();
await trigger("atto::debounce", ["posts::create"]);
// create body
const body = new FormData();
if (e.target.file_picker) {
for (const file of e.target.file_picker.files) {
body.append(file.name, file);
}
}
body.append(
"body",
JSON.stringify({
content: e.target.content.value,
community: "{{ config.town_square }}",
answering,
}),
);
// ...
fetch("/api/v1/posts", {
method: "POST",
body,
})
.then((res) => res.json())
.then(async (res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
if (res.ok) {
// update settings
await update_settings_maybe(res.payload);
// ...
e.target.parentElement.remove();
}
});
};
globalThis.accept_follow_request = async (e, id) => {
await trigger("atto::debounce", ["users::follow"]);
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this?",
]))
) {
return;
}
fetch(`/api/v1/auth/user/${id}/follow/accept`, {
method: "POST",
})
.then((res) => res.json())
.then(async (res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
if (res.ok) {
e.target.parentElement.parentElement.parentElement.parentElement.remove();
if (
await trigger("atto::confirm", [
"Would you like to follow this user back? This will allow them to view your profile.",
])
) {
fetch(`/api/v1/auth/user/${id}/follow`, {
method: "POST",
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
}
}
});
};
</script>
{% endblock %}

View file

@ -0,0 +1,252 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Requests - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"requests\") }}")
(main
("class" "flex flex-col gap-2")
(div
("class" "card-nest")
(div
("class" "card small flex items-center justify-between gap-2")
(span
("class" "flex items-center gap-2")
(text "{{ icon \"inbox\" }}")
(span
(text "{{ text \"requests:label.requests\" }}")))
(button
("onclick" "clear_requests()")
("class" "small red quaternary")
(text "{{ icon \"bomb\" }}")
(span
(text "{{ text \"notifs:action.clear\" }}"))))
(div
("class" "card tertiary flex flex-col gap-4")
(text "{% for request in requests %} {% if request.action_type == \"CommunityJoin\" %}")
(div
("class" "card-nest")
(div
("class" "card small flex items-center gap-2")
(text "{{ icon \"user-plus\" }}")
(span
(text "{{ text \"requests:label.community_join_request\" }}")))
(div
("class" "card flex flex-wrap gap-2")
(a
("href" "/community/{{ request.linked_asset }}/manage?uid={{ request.id }}#/members")
("class" "button")
(text "{{ icon \"external-link\" }}")
(span
(text "{{ text \"requests:label.review\" }}")))
(button
("class" "quaternary red")
("onclick" "remove_request('{{ request.id }}', '{{ request.linked_asset }}')")
(text "{{ icon \"trash\" }}")
(span
(text "{{ text \"general:action.delete\" }}")))))
(text "{% elif request.action_type == \"Follow\" %}")
(div
("class" "card-nest")
(div
("class" "card small flex items-center gap-2")
(text "{{ icon \"user-plus\" }}")
(span
(text "{{ text \"requests:label.user_follow_request\" }}")))
(div
("class" "card flex flex-col gap-2")
(span
(text "{{ text \"requests:label.user_follow_request_message\" }}"))
(div
("class" "card flex w-full secondary gap-2")
(a
("href" "/api/v1/auth/user/find/{{ request.id }}")
("class" "button")
(text "{{ icon \"external-link\" }}")
(span
(text "{{ text \"requests:action.view_profile\" }}")))
(button
("class" "quaternary green")
("onclick" "accept_follow_request(event, '{{ request.id }}')")
(text "{{ icon \"check\" }}")
(span
(text "{{ text \"general:action.accept\" }}")))
(button
("class" "quaternary red")
("onclick" "remove_request('{{ request.id }}', '{{ request.linked_asset }}')")
(text "{{ icon \"trash\" }}")
(span
(text "{{ text \"general:action.delete\" }}"))))))
(text "{%- endif %} {% endfor %} {% for question in questions %}")
(div
("class" "card-nest")
(text "{{ components::question(question=question[0], owner=question[1], profile=user) }}")
(form
("class" "card flex flex-col gap-2")
("onsubmit" "answer_question_from_form(event, '{{ question[0].id }}')")
(div
("class" "flex flex-col gap-1")
(label
("for" "content")
(text "{{ text \"communities:label.content\" }}"))
(textarea
("type" "text")
("name" "content")
("id" "content")
("placeholder" "content")
("required" "")
("minlength" "2")
("maxlength" "4096")))
(div
("id" "files_list")
("class" "flex gap-2 flex-wrap"))
(div
("class" "flex flex-wrap w-full gap-2")
(text "{{ components::create_post_options() }}")
(button
("class" "primary")
(text "{{ text \"requests:label.answer\" }}"))
(button
("type" "button")
("class" "red quaternary")
("onclick" "trigger('me::remove_question', ['{{ question[0].id }}'])")
(text "{{ text \"general:action.delete\" }}"))
(button
("type" "button")
("class" "red quaternary")
("onclick" "trigger('me::ip_block_question', ['{{ question[0].id }}'])")
(text "{{ text \"auth:action.ip_block\" }}")))))
(text "{% endfor %}"))))
(script
(text "async function remove_request(id, linked_asset) {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you want to do this?\",
]))
) {
return;
}
fetch(`/api/v1/requests/${id}/${linked_asset}`, {
method: \"DELETE\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
async function clear_requests() {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you want to do this?\",
]))
) {
return;
}
fetch(\"/api/v1/requests/my\", {
method: \"DELETE\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
window.answer_question_from_form = async (e, answering) => {
e.preventDefault();
await trigger(\"atto::debounce\", [\"posts::create\"]);
// create body
const body = new FormData();
if (e.target.file_picker) {
for (const file of e.target.file_picker.files) {
body.append(file.name, file);
}
}
body.append(
\"body\",
JSON.stringify({
content: e.target.content.value,
community: \"{{ config.town_square }}\",
answering,
}),
);
// ...
fetch(\"/api/v1/posts\", {
method: \"POST\",
body,
})
.then((res) => res.json())
.then(async (res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
// update settings
await update_settings_maybe(res.payload);
// ...
e.target.parentElement.remove();
}
});
};
globalThis.accept_follow_request = async (e, id) => {
await trigger(\"atto::debounce\", [\"users::follow\"]);
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(`/api/v1/auth/user/${id}/follow/accept`, {
method: \"POST\",
})
.then((res) => res.json())
.then(async (res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
e.target.parentElement.parentElement.parentElement.parentElement.remove();
if (
await trigger(\"atto::confirm\", [
\"Would you like to follow this user back? This will allow them to view your profile.\",
])
) {
fetch(`/api/v1/auth/user/${id}/follow`, {
method: \"POST\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
}
});
};"))
(text "{% endblock %}")

View file

@ -1,38 +0,0 @@
{% extends "root.html" %} {% block head %}
<title>Audit log - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav() }}
<main class="flex flex-col gap-2">
<div class="card-nest w-full">
<div class="card small flex items-center gap-2">
{{ icon "scroll" }}
<span>{{ text "general:link.audit_log" }}</span>
</div>
<div class="card flex flex-col gap-2">
<!-- prettier-ignore -->
{% for item in items %}
<div class="card-nest">
<a
class="card small flex items-center gap-2 flush"
href="/api/v1/auth/user/find/{{ item.moderator }}"
>
<!-- prettier-ignore -->
{{ components::avatar(username=item.moderator, selector_type="id") }}
<span>{{ item.moderator }}</span>
<span class="fade date">{{ item.created }}</span>
</a>
<div class="card secondary">
<span class="no_p_margin"
>{{ item.content|markdown|safe }}</span
>
</div>
</div>
{% endfor %}
<!-- prettier-ignore -->
{{ components::pagination(page=page, items=items|length) }}
</div>
</div>
</main>
{% endblock %}

View file

@ -0,0 +1,37 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Audit log - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
(div
("class" "card-nest w-full")
(div
("class" "card small flex items-center gap-2")
(text "{{ icon \"scroll\" }}")
(span
(text "{{ text \"general:link.audit_log\" }}")))
(div
("class" "card flex flex-col gap-2")
(text "{% for item in items %}")
(div
("class" "card-nest")
(a
("class" "card small flex items-center gap-2 flush")
("href" "/api/v1/auth/user/find/{{ item.moderator }}")
(text "{{ components::avatar(username=item.moderator, selector_type=\"id\") }}")
(span
(text "{{ item.moderator }}"))
(span
("class" "fade date")
(text "{{ item.created }}")))
(div
("class" "card secondary")
(span
("class" "no_p_margin")
(text "{{ item.content|markdown|safe }}"))))
(text "{% endfor %}")
(text "{{ components::pagination(page=page, items=items|length) }}"))))
(text "{% endblock %}")

View file

@ -1,65 +0,0 @@
{% extends "root.html" %} {% block head %}
<title>File report - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav() }}
<main class="flex flex-col gap-2">
<div class="card-nest w-full">
<div class="card small flex items-center gap-2">
{{ icon "flag" }}
<span>{{ text "general:label.file_report" }}</span>
</div>
<form
class="card flex flex-col gap-2"
onsubmit="create_report_from_form(event)"
>
<div class="flex flex-col gap-1">
<label for="title"
>{{ text "communities:label.content" }}</label
>
<textarea
type="text"
name="content"
id="content"
placeholder="content"
required
minlength="16"
></textarea>
</div>
<button class="primary">
{{ text "communities:action.create" }}
</button>
</form>
</div>
</main>
<script>
function create_report_from_form(e) {
e.preventDefault();
fetch("/api/v1/reports", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
content: e.target.content.value,
asset: "{{ asset }}",
asset_type: `{{ asset_type }}`,
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
if (res.ok) {
setTimeout(() => {
window.close();
}, 150);
}
});
}
</script>
{% endblock %}

View file

@ -0,0 +1,63 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "File report - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
(div
("class" "card-nest w-full")
(div
("class" "card small flex items-center gap-2")
(text "{{ icon \"flag\" }}")
(span
(text "{{ text \"general:label.file_report\" }}")))
(form
("class" "card flex flex-col gap-2")
("onsubmit" "create_report_from_form(event)")
(div
("class" "flex flex-col gap-1")
(label
("for" "title")
(text "{{ text \"communities:label.content\" }}"))
(textarea
("type" "text")
("name" "content")
("id" "content")
("placeholder" "content")
("required" "")
("minlength" "16")))
(button
("class" "primary")
(text "{{ text \"communities:action.create\" }}")))))
(script
(text "function create_report_from_form(e) {
e.preventDefault();
fetch(\"/api/v1/reports\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
content: e.target.content.value,
asset: \"{{ asset }}\",
asset_type: `{{ asset_type }}`,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
setTimeout(() => {
window.close();
}, 150);
}
});
}"))
(text "{% endblock %}")

View file

@ -1,87 +0,0 @@
{% extends "root.html" %} {% block head %}
<title>IP Bans - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav() }}
<main class="flex flex-col gap-2">
<div class="card-nest w-full">
<div class="card small flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
{{ icon "ban" }}
<span>{{ text "general:link.ip_bans" }}</span>
</div>
<button onclick="prompt_ban_ip()" class="quaternary small">
{{ icon "plus" }}
<span>{{ text "communities:action.create" }}</span>
</button>
</div>
<div class="card flex flex-col gap-2">
<!-- prettier-ignore -->
{% for item in items %}
<div class="card-nest">
<a
class="card small flex items-center gap-2 flush"
href="/api/v1/auth/user/find/{{ item.moderator }}"
>
<!-- prettier-ignore -->
{{ components::avatar(username=item.moderator, selector_type="id") }}
<span>{{ item.moderator }}</span>
<span class="fade date">{{ item.created }}</span>
</a>
<div class="card secondary flex flex-col gap-2">
<code>{{ item.ip }}</code>
<span>{{ item.reason|markdown|safe }}</span>
<div class="card w-full flex flex-wrap gap-2">
<button
onclick="remove_ipban('{{ item.ip }}')"
class="red quaternary"
>
{{ icon "trash" }}
<span>{{ text "general:action.delete" }}</span>
</button>
</div>
</div>
</div>
{% endfor %}
<!-- prettier-ignore -->
{{ components::pagination(page=page, items=items|length) }}
</div>
</div>
</main>
<script>
async function prompt_ban_ip() {
const ip = await trigger("atto::prompt", ["IP address (or prefix):"]);
if (!ip) {
return;
}
trigger("atto::ban_ip", [ip]);
}
async function remove_ipban(ip) {
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this?",
]))
) {
return;
}
fetch(`/api/v1/bans/${ip}`, {
method: "DELETE",
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
}
</script>
{% endblock %}

View file

@ -0,0 +1,86 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "IP Bans - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
(div
("class" "card-nest w-full")
(div
("class" "card small flex items-center justify-between gap-2")
(div
("class" "flex items-center gap-2")
(text "{{ icon \"ban\" }}")
(span
(text "{{ text \"general:link.ip_bans\" }}")))
(button
("onclick" "prompt_ban_ip()")
("class" "quaternary small")
(text "{{ icon \"plus\" }}")
(span
(text "{{ text \"communities:action.create\" }}"))))
(div
("class" "card flex flex-col gap-2")
(text "{% for item in items %}")
(div
("class" "card-nest")
(a
("class" "card small flex items-center gap-2 flush")
("href" "/api/v1/auth/user/find/{{ item.moderator }}")
(text "{{ components::avatar(username=item.moderator, selector_type=\"id\") }}")
(span
(text "{{ item.moderator }}"))
(span
("class" "fade date")
(text "{{ item.created }}")))
(div
("class" "card secondary flex flex-col gap-2")
(code
(text "{{ item.ip }}"))
(span
(text "{{ item.reason|markdown|safe }}"))
(div
("class" "card w-full flex flex-wrap gap-2")
(button
("onclick" "remove_ipban('{{ item.ip }}')")
("class" "red quaternary")
(text "{{ icon \"trash\" }}")
(span
(text "{{ text \"general:action.delete\" }}"))))))
(text "{% endfor %}")
(text "{{ components::pagination(page=page, items=items|length) }}"))))
(script
(text "async function prompt_ban_ip() {
const ip = await trigger(\"atto::prompt\", [\"IP address (or prefix):\"]);
if (!ip) {
return;
}
trigger(\"atto::ban_ip\", [ip]);
}
async function remove_ipban(ip) {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(`/api/v1/bans/${ip}`, {
method: \"DELETE\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}"))
(text "{% endblock %}")

View file

@ -1,253 +0,0 @@
{% extends "root.html" %} {% block head %}
<title>Manage profile - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav() }}
<main class="flex flex-col gap-2">
<div class="card-nest w-full">
<div class="card small flex items-center gap-2">
{{ icon "shield" }}
<span>{{ text "mod_panel:label.manage_profile" }}</span>
</div>
<div class="card tertiary">
<div class="flex flex-col gap-2" id="mod_options">
<div
class="card w-full flex flex-wrap gap-2"
ui_ident="actions"
>
<a
href="/settings?username={{ profile.username }}"
class="button quaternary"
>
{{ icon "settings" }}
<span>View settings</span>
</a>
<a
href="/mod_panel/profile/{{ profile.id }}/warnings"
class="button quaternary"
>
{{ icon "shield-alert" }}
<span>View warnings</span>
</a>
<button
class="red quaternary"
onclick="delete_account(event)"
>
{{ icon "trash" }}
<span>{{ text "settings:label.delete_account" }}</span>
</button>
{% if profile.permissions != 131073 -%}
<button
class="red quaternary"
onclick="update_user_role(131073)"
>
Ban
</button>
{% else %}
<button class="quaternary" onclick="update_user_role(1)">
Unban
</button>
{%- endif %}
</div>
</div>
<script>
setTimeout(() => {
const ui = ns("ui");
const element = document.getElementById("mod_options");
async function profile_request(do_confirm, path, body) {
if (do_confirm) {
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this?",
]))
) {
return;
}
}
fetch(`/api/v1/auth/user/{{ profile.id }}/${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
}
globalThis.delete_account = async (e) => {
e.preventDefault();
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this?",
]))
) {
return;
}
fetch("/api/v1/auth/user/{{ profile.id }}", {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
password: "",
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
globalThis.update_user_role = async (new_role) => {
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this?",
]))
) {
return;
}
fetch(`/api/v1/auth/user/{{ profile.id }}/role`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
role: Number.parseInt(new_role),
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
ui.refresh_container(element, ["actions"]);
setTimeout(() => {
ui.refresh_container(element, ["actions"]);
ui.generate_settings_ui(
element,
[
[
["is_verified", "Is verified"],
"{{ profile.is_verified }}",
"checkbox",
],
[
["role", "Permission level"],
"{{ profile.permissions }}",
"input",
],
],
null,
{
is_verified: (value) => {
profile_request(false, "verified", {
is_verified: value,
});
},
role: (new_role) => {
return update_user_role(new_role);
},
},
);
}, 100);
}, 150);
</script>
</div>
</div>
<div class="card-nest w-full">
<div class="card small flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
{{ icon "blocks" }}
<span
>{{ text "mod_panel:label.permissions_level_builder"
}}</span
>
</div>
<button
class="small quaternary"
onclick="update_user_role(Number.parseInt(document.getElementById('role').value))"
>
{{ icon "check" }}
<span>{{ text "general:action.save" }}</span>
</button>
</div>
<div
class="card tertiary flex flex-col gap-2"
id="permission_builder"
></div>
</div>
<script>
setTimeout(() => {
const get_permissions_html = trigger(
"ui::generate_permissions_ui",
[
{
// https://trisuaso.github.io/tetratto/tetratto/model/permissions/struct.FinePermission.html
DEFAULT: 1 << 0,
ADMINISTRATOR: 1 << 1,
MANAGE_COMMUNITIES: 1 << 2,
MANAGE_POSTS: 1 << 3,
MANAGE_POST_REPLIES: 1 << 4,
MANAGE_USERS: 1 << 5,
MANAGE_BANS: 1 << 6,
MANAGE_WARNINGS: 1 << 7,
MANAGE_NOTIFICATIONS: 1 << 8,
VIEW_REPORTS: 1 << 9,
VIEW_AUDIT_LOG: 1 << 10,
MANAGE_MEMBERSHIPS: 1 << 11,
MANAGE_REACTIONS: 1 << 12,
MANAGE_FOLLOWS: 1 << 13,
MANAGE_VERIFIED: 1 << 14,
MANAGE_AUDITLOG: 1 << 15,
MANAGE_REPORTS: 1 << 16,
BANNED: 1 << 17,
INFINITE_COMMUNITIES: 1 << 18,
SUPPORTER: 1 << 19,
MANAGE_REQUESTS: 1 << 20,
MANAGE_QUESTIONS: 1 << 21,
MANAGE_CHANNELS: 1 << 22,
MANAGE_MESSAGES: 1 << 23,
MANAGE_UPLOADS: 1 << 24,
MANAGE_EMOJIS: 1 << 25,
MANAGE_STACKS: 1 << 26,
STAFF_BADGE: 1 << 27,
},
],
);
document.getElementById("permission_builder").innerHTML =
get_permissions_html(
Number.parseInt("{{ profile.permissions }}"),
"permission_builder",
);
}, 250);
</script>
</main>
{% endblock %}

View file

@ -0,0 +1,237 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Manage profile - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
(div
("class" "card-nest w-full")
(div
("class" "card small flex items-center gap-2")
(text "{{ icon \"shield\" }}")
(span
(text "{{ text \"mod_panel:label.manage_profile\" }}")))
(div
("class" "card tertiary")
(div
("class" "flex flex-col gap-2")
("id" "mod_options")
(div
("class" "card w-full flex flex-wrap gap-2")
("ui_ident" "actions")
(a
("href" "/settings?username={{ profile.username }}")
("class" "button quaternary")
(text "{{ icon \"settings\" }}")
(span
(text "View settings")))
(a
("href" "/mod_panel/profile/{{ profile.id }}/warnings")
("class" "button quaternary")
(text "{{ icon \"shield-alert\" }}")
(span
(text "View warnings")))
(button
("class" "red quaternary")
("onclick" "delete_account(event)")
(text "{{ icon \"trash\" }}")
(span
(text "{{ text \"settings:label.delete_account\" }}")))
(text "{% if profile.permissions != 131073 -%}")
(button
("class" "red quaternary")
("onclick" "update_user_role(131073)")
(text "Ban"))
(text "{% else %}")
(button
("class" "quaternary")
("onclick" "update_user_role(1)")
(text "Unban"))
(text "{%- endif %}")))
(script
(text "setTimeout(() => {
const ui = ns(\"ui\");
const element = document.getElementById(\"mod_options\");
async function profile_request(do_confirm, path, body) {
if (do_confirm) {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
}
fetch(`/api/v1/auth/user/{{ profile.id }}/${path}`, {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify(body),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}
globalThis.delete_account = async (e) => {
e.preventDefault();
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(\"/api/v1/auth/user/{{ profile.id }}\", {
method: \"DELETE\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
password: \"\",
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
globalThis.update_user_role = async (new_role) => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(`/api/v1/auth/user/{{ profile.id }}/role`, {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
role: Number.parseInt(new_role),
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
ui.refresh_container(element, [\"actions\"]);
setTimeout(() => {
ui.refresh_container(element, [\"actions\"]);
ui.generate_settings_ui(
element,
[
[
[\"is_verified\", \"Is verified\"],
\"{{ profile.is_verified }}\",
\"checkbox\",
],
[
[\"role\", \"Permission level\"],
\"{{ profile.permissions }}\",
\"input\",
],
],
null,
{
is_verified: (value) => {
profile_request(false, \"verified\", {
is_verified: value,
});
},
role: (new_role) => {
return update_user_role(new_role);
},
},
);
}, 100);
}, 150);"))))
(div
("class" "card-nest w-full")
(div
("class" "card small flex items-center justify-between gap-2")
(div
("class" "flex items-center gap-2")
(text "{{ icon \"blocks\" }}")
(span
(text "{{ text \"mod_panel:label.permissions_level_builder\" }}")))
(button
("class" "small quaternary")
("onclick" "update_user_role(Number.parseInt(document.getElementById('role').value))")
(text "{{ icon \"check\" }}")
(span
(text "{{ text \"general:action.save\" }}"))))
(div
("class" "card tertiary flex flex-col gap-2")
("id" "permission_builder")))
(script
(text "setTimeout(() => {
const get_permissions_html = trigger(
\"ui::generate_permissions_ui\",
[
{
// https://trisuaso.github.io/tetratto/tetratto/model/permissions/struct.FinePermission.html
DEFAULT: 1 << 0,
ADMINISTRATOR: 1 << 1,
MANAGE_COMMUNITIES: 1 << 2,
MANAGE_POSTS: 1 << 3,
MANAGE_POST_REPLIES: 1 << 4,
MANAGE_USERS: 1 << 5,
MANAGE_BANS: 1 << 6,
MANAGE_WARNINGS: 1 << 7,
MANAGE_NOTIFICATIONS: 1 << 8,
VIEW_REPORTS: 1 << 9,
VIEW_AUDIT_LOG: 1 << 10,
MANAGE_MEMBERSHIPS: 1 << 11,
MANAGE_REACTIONS: 1 << 12,
MANAGE_FOLLOWS: 1 << 13,
MANAGE_VERIFIED: 1 << 14,
MANAGE_AUDITLOG: 1 << 15,
MANAGE_REPORTS: 1 << 16,
BANNED: 1 << 17,
INFINITE_COMMUNITIES: 1 << 18,
SUPPORTER: 1 << 19,
MANAGE_REQUESTS: 1 << 20,
MANAGE_QUESTIONS: 1 << 21,
MANAGE_CHANNELS: 1 << 22,
MANAGE_MESSAGES: 1 << 23,
MANAGE_UPLOADS: 1 << 24,
MANAGE_EMOJIS: 1 << 25,
MANAGE_STACKS: 1 << 26,
STAFF_BADGE: 1 << 27,
},
],
);
document.getElementById(\"permission_builder\").innerHTML =
get_permissions_html(
Number.parseInt(\"{{ profile.permissions }}\"),
\"permission_builder\",
);
}, 250);")))
(text "{% endblock %}")

View file

@ -1,81 +0,0 @@
{% extends "root.html" %} {% block head %}
<title>Reports - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav() }}
<main class="flex flex-col gap-2">
<div class="card-nest w-full">
<div class="card small flex items-center gap-2">
{{ icon "flag" }}
<span>{{ text "general:link.reports" }}</span>
</div>
<div class="card flex flex-col gap-2">
<!-- prettier-ignore -->
{% for item in items %}
<div class="card-nest">
<a
class="card small flex items-center gap-2 flush"
href="/api/v1/auth/user/find/{{ item.owner }}"
>
<!-- prettier-ignore -->
{{ components::avatar(username=item.owner, selector_type="id") }}
<span>{{ item.owner }}</span>
<span class="fade date">{{ item.created }}</span>
</a>
<div class="card secondary flex flex-col gap-2">
<span class="no_p_margin"
>{{ item.content|markdown|safe }}</span
>
<div class="card w-full flex flex-wrap gap-2">
<button
onclick="open_reported_content('{{ item.asset }}', '{{ item.asset_type }}')"
>
{{ icon "external-link" }}
<span
>{{ text "mod_panel:label.open_reported_content"
}}</span
>
</button>
<button
onclick="remove_report('{{ item.id }}')"
class="red quaternary"
>
{{ icon "trash" }}
<span>{{ text "general:action.delete" }}</span>
</button>
</div>
</div>
</div>
{% endfor %}
<!-- prettier-ignore -->
{{ components::pagination(page=page, items=items|length) }}
</div>
</div>
</main>
<script>
function open_reported_content(asset, asset_type) {
if (asset_type === "Post") {
window.open(`/post/${asset}`);
} else if (asset_type === "Community") {
window.open(`/community/${asset}`);
}
}
function remove_report(id) {
fetch(`/api/v1/reports/${id}`, {
method: "DELETE",
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
}
</script>
{% endblock %}

View file

@ -0,0 +1,72 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Reports - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
(div
("class" "card-nest w-full")
(div
("class" "card small flex items-center gap-2")
(text "{{ icon \"flag\" }}")
(span
(text "{{ text \"general:link.reports\" }}")))
(div
("class" "card flex flex-col gap-2")
(text "{% for item in items %}")
(div
("class" "card-nest")
(a
("class" "card small flex items-center gap-2 flush")
("href" "/api/v1/auth/user/find/{{ item.owner }}")
(text "{{ components::avatar(username=item.owner, selector_type=\"id\") }}")
(span
(text "{{ item.owner }}"))
(span
("class" "fade date")
(text "{{ item.created }}")))
(div
("class" "card secondary flex flex-col gap-2")
(span
("class" "no_p_margin")
(text "{{ item.content|markdown|safe }}"))
(div
("class" "card w-full flex flex-wrap gap-2")
(button
("onclick" "open_reported_content('{{ item.asset }}', '{{ item.asset_type }}')")
(text "{{ icon \"external-link\" }}")
(span
(text "{{ text \"mod_panel:label.open_reported_content\" }}")))
(button
("onclick" "remove_report('{{ item.id }}')")
("class" "red quaternary")
(text "{{ icon \"trash\" }}")
(span
(text "{{ text \"general:action.delete\" }}"))))))
(text "{% endfor %}")
(text "{{ components::pagination(page=page, items=items|length) }}"))))
(script
(text "function open_reported_content(asset, asset_type) {
if (asset_type === \"Post\") {
window.open(`/post/${asset}`);
} else if (asset_type === \"Community\") {
window.open(`/community/${asset}`);
}
}
function remove_report(id) {
fetch(`/api/v1/reports/${id}`, {
method: \"DELETE\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}"))
(text "{% endblock %}")

View file

@ -1,31 +0,0 @@
{% extends "root.html" %} {% block head %}
<title>Server stats - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav() }}
<main class="flex flex-col gap-2">
<div class="card-nest w-full">
<div class="card small flex items-center gap-2">
{{ icon "chart-line" }}
<span>{{ text "general:link.stats" }}</span>
</div>
<div class="card flex flex-col gap-2">
<ul>
<li>
<b>Active user streams:</b>
<span>{{ active_users }}</span>
</li>
<li>
<b>Active chat subscriptions:</b>
<span>{{ active_users_chats }}</span>
</li>
<li>
<b>Socket tasks:</b>
<span>{{ (active_users_chats + active_users) * 3 }}</span>
</li>
</ul>
</div>
</div>
</main>
{% endblock %}

View file

@ -0,0 +1,34 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Server stats - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
(div
("class" "card-nest w-full")
(div
("class" "card small flex items-center gap-2")
(text "{{ icon \"chart-line\" }}")
(span
(text "{{ text \"general:link.stats\" }}")))
(div
("class" "card flex flex-col gap-2")
(ul
(li
(b
(text "Active user streams:"))
(span
(text "{{ active_users }}")))
(li
(b
(text "Active chat subscriptions:"))
(span
(text "{{ active_users_chats }}")))
(li
(b
(text "Socket tasks:"))
(span
(text "{{ (active_users_chats + active_users) * 3 }}")))))))
(text "{% endblock %}")

View file

@ -1,132 +0,0 @@
{% extends "root.html" %} {% block head %}
<title>User warnings - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav() }}
<main class="flex flex-col gap-2">
<div class="card-nest">
<div class="card small flex items-center justify-between gap-2">
<span class="flex items-center gap-2">
{{ icon "gavel" }}
<span>{{ text "mod_panel:label.create_warning" }}</span>
</span>
<a
href="/mod_panel/profile/{{ profile.id }}"
class="button quaternary small red"
>
{{ icon "x" }}
<span>{{ text "dialog:action.cancel" }}</span>
</a>
</div>
<form
class="card flex flex-col gap-2"
onsubmit="create_warning_from_form(event)"
>
<div class="flex flex-col gap-1">
<label for="content"
>{{ text "communities:label.content" }}</label
>
<textarea
type="text"
name="content"
id="content"
placeholder="content"
required
minlength="2"
maxlength="4096"
></textarea>
</div>
<button class="primary">
{{ text "communities:action.create" }}
</button>
</form>
</div>
<div class="card-nest">
<div class="card small flex items-center justify-between gap-2">
<span class="flex items-center gap-2">
{{ icon "message-circle-warning" }}
<span>{{ text "mod_panel:label.warnings" }}</span>
</span>
</div>
<div class="card flex flex-col gap-4">
{% for item in items %}
<div class="card-nest">
<div class="card small flex items-center justify-between gap-2">
<a
class="flex items-center gap-2 flush"
href="/api/v1/auth/user/find/{{ item.moderator }}"
title="Moderator"
>
<!-- prettier-ignore -->
{{ components::avatar(username=item.moderator, selector_type="id") }}
<span>{{ item.moderator }}</span>
<span class="fade date">{{ item.created }}</span>
</a>
<button
class="small quaternary red"
onclick="remove_warning('{{ item.id }}')"
>
{{ icon "trash" }}
<span>{{ text "general:action.delete" }}</span>
</button>
</div>
<div class="card secondary flex flex-col gap-2">
<span class="no_p_margin"
>{{ item.content|markdown|safe }}</span
>
</div>
</div>
{% endfor %}
<!-- prettier-ignore -->
{{ components::pagination(page=page, items=items|length) }}
</div>
</div>
</main>
<script>
async function create_warning_from_form(e) {
e.preventDefault();
await trigger("atto::debounce", ["warnings::create"]);
fetch("/api/v1/warnings/{{ profile.id }}", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
content: e.target.content.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
if (res.ok) {
e.target.reset();
}
});
}
function remove_warning(id) {
fetch(`/api/v1/warnings/${id}`, {
method: "DELETE",
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
}
</script>
{% endblock %}

View file

@ -0,0 +1,121 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "User warnings - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
(div
("class" "card-nest")
(div
("class" "card small flex items-center justify-between gap-2")
(span
("class" "flex items-center gap-2")
(text "{{ icon \"gavel\" }}")
(span
(text "{{ text \"mod_panel:label.create_warning\" }}")))
(a
("href" "/mod_panel/profile/{{ profile.id }}")
("class" "button quaternary small red")
(text "{{ icon \"x\" }}")
(span
(text "{{ text \"dialog:action.cancel\" }}"))))
(form
("class" "card flex flex-col gap-2")
("onsubmit" "create_warning_from_form(event)")
(div
("class" "flex flex-col gap-1")
(label
("for" "content")
(text "{{ text \"communities:label.content\" }}"))
(textarea
("type" "text")
("name" "content")
("id" "content")
("placeholder" "content")
("required" "")
("minlength" "2")
("maxlength" "4096")))
(button
("class" "primary")
(text "{{ text \"communities:action.create\" }}"))))
(div
("class" "card-nest")
(div
("class" "card small flex items-center justify-between gap-2")
(span
("class" "flex items-center gap-2")
(text "{{ icon \"message-circle-warning\" }}")
(span
(text "{{ text \"mod_panel:label.warnings\" }}"))))
(div
("class" "card flex flex-col gap-4")
(text "{% for item in items %}")
(div
("class" "card-nest")
(div
("class" "card small flex items-center justify-between gap-2")
(a
("class" "flex items-center gap-2 flush")
("href" "/api/v1/auth/user/find/{{ item.moderator }}")
("title" "Moderator")
(text "{{ components::avatar(username=item.moderator, selector_type=\"id\") }}")
(span
(text "{{ item.moderator }}"))
(span
("class" "fade date")
(text "{{ item.created }}")))
(button
("class" "small quaternary red")
("onclick" "remove_warning('{{ item.id }}')")
(text "{{ icon \"trash\" }}")
(span
(text "{{ text \"general:action.delete\" }}"))))
(div
("class" "card secondary flex flex-col gap-2")
(span
("class" "no_p_margin")
(text "{{ item.content|markdown|safe }}"))))
(text "{% endfor %}")
(text "{{ components::pagination(page=page, items=items|length) }}"))))
(script
(text "async function create_warning_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"warnings::create\"]);
fetch(\"/api/v1/warnings/{{ profile.id }}\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
content: e.target.content.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
e.target.reset();
}
});
}
function remove_warning(id) {
fetch(`/api/v1/warnings/${id}`, {
method: \"DELETE\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}"))
(text "{% endblock %}")

View file

@ -1,92 +0,0 @@
{% extends "root.html" %} {% block head %}
<title>Post quotes - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav() }}
<main class="flex flex-col gap-2">
{% if post.replying_to -%}
<a href="/post/{{ post.replying_to }}" class="button">
{{ icon "arrow-up" }}
<span>{{ text "communities:action.continue_thread" }}</span>
</a>
{%- endif %}
<!-- prettier-ignore -->
<div style="display: contents;">
{% if post.context.repost and post.context.repost.reposting -%}
{{ components::repost(repost=reposting, post=post, owner=owner, community=community, show_community=true, can_manage_post=can_manage_posts) }}
{% else %}
{{ components::post(post=post, owner=owner, question=question, community=community, show_community=true, can_manage_post=can_manage_posts) }}
{%- endif %}
</div>
<div class="pillmenu">
<a href="/post/{{ post.id }}#/replies">
{{ icon "newspaper" }}
<span>{{ text "communities:label.replies" }}</span>
</a>
<a href="/post/{{ post.id }}/reposts">
{{ icon "repeat-2" }}
<span>{{ text "communities:label.reposts" }}</span>
</a>
<a href="/post/{{ post.id }}/reposts?quotes=true">
{{ icon "quote" }}
<span>{{ text "communities:label.quotes" }}</span>
</a>
</div>
{% if (user and user.id == post.owner) or can_manage_posts -%}
<div class="pillmenu">
{% if user and user.id == post.owner -%}
<a href="/post/{{ post.id }}#/edit">
{{ icon "pen" }}
<span>{{ text "communities:label.edit_content" }}</span>
</a>
{%- endif %}
<a href="/post/{{ post.id }}/likes" class="active">
{{ icon "heart" }}
<span>{{ text "communities:label.likes" }}</span>
</a>
<a href="/post/{{ post.id }}#/configure">
{{ icon "settings" }}
<span>{{ text "communities:action.configure" }}</span>
</a>
</div>
{%- endif %}
<div class="card-nest w-full">
<div class="card small flex items-center gap-2">
{{ icon "heart" }}
<span>{{ text "communities:label.likes" }}</span>
</div>
<div class="card flex flex-wrap gap-4 flex-collapse">
<!-- prettier-ignore -->
{% for tu in list %}
{% set reaction = tu[0] %}
{% set user = tu[1] %}
<div
style="display: contents"
title="{% if reaction.is_like %}Like{% else %}Dislike{% endif %}"
>
{{ components::user_plate(user=user, secondary=true) }}
</div>
{% endfor %} {{ components::pagination(page=page, items=list|length)
}}
</div>
</div>
</main>
<style>
.user_plate {
width: calc(50% - 0.5rem);
}
@media screen and (max-width: 900px) {
.user_plate {
width: 100%;
}
}
</style>
{% endblock %}

View file

@ -0,0 +1,85 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Post likes - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
(text "{% if post.replying_to -%}")
(a
("href" "/post/{{ post.replying_to }}")
("class" "button")
(text "{{ icon \"arrow-up\" }}")
(span
(text "{{ text \"communities:action.continue_thread\" }}")))
(text "{%- endif %}")
(div
("style" "display: contents;")
(text "{% if post.context.repost and post.context.repost.reposting -%} {{ components::repost(repost=reposting, post=post, owner=owner, community=community, show_community=true, can_manage_post=can_manage_posts) }} {% else %} {{ components::post(post=post, owner=owner, question=question, community=community, show_community=true, can_manage_post=can_manage_posts) }} {%- endif %}"))
(div
("class" "pillmenu")
(a
("href" "/post/{{ post.id }}#/replies")
(text "{{ icon \"newspaper\" }}")
(span
(text "{{ text \"communities:label.replies\" }}")))
(a
("href" "/post/{{ post.id }}/reposts")
(text "{{ icon \"repeat-2\" }}")
(span
(text "{{ text \"communities:label.reposts\" }}")))
(a
("href" "/post/{{ post.id }}/reposts?quotes=true")
(text "{{ icon \"quote\" }}")
(span
(text "{{ text \"communities:label.quotes\" }}"))))
(text "{% if (user and user.id == post.owner) or can_manage_posts -%}")
(div
("class" "pillmenu")
(text "{% if user and user.id == post.owner -%}")
(a
("href" "/post/{{ post.id }}#/edit")
(text "{{ icon \"pen\" }}")
(span
(text "{{ text \"communities:label.edit_content\" }}")))
(text "{%- endif %}")
(a
("href" "/post/{{ post.id }}/likes")
("class" "active")
(text "{{ icon \"heart\" }}")
(span
(text "{{ text \"communities:label.likes\" }}")))
(a
("href" "/post/{{ post.id }}#/configure")
(text "{{ icon \"settings\" }}")
(span
(text "{{ text \"communities:action.configure\" }}"))))
(text "{%- endif %}")
(div
("class" "card-nest w-full")
(div
("class" "card small flex items-center gap-2")
(text "{{ icon \"heart\" }}")
(span
(text "{{ text \"communities:label.likes\" }}")))
(div
("class" "card flex flex-wrap gap-4 flex-collapse")
(text "{% for tu in list %} {% set reaction = tu[0] %} {% set user = tu[1] %}")
(div
("style" "display: contents")
("title" "{% if reaction.is_like %}Like{% else %}Dislike{% endif %}")
(text "{{ components::user_plate(user=user, secondary=true) }}"))
(text "{% endfor %} {{ components::pagination(page=page, items=list|length) }}"))))
(style
(text ".user_plate {
width: calc(50% - 0.5rem);
}
@media screen and (max-width: 900px) {
.user_plate {
width: 100%;
}
}"))
(text "{% endblock %}")

View file

@ -1,344 +0,0 @@
{% extends "root.html" %} {% block head %}
<title>Post - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav() }}
<main class="flex flex-col gap-2">
{% if post.replying_to -%}
<a href="/post/{{ post.replying_to }}" class="button">
{{ icon "arrow-up" }}
<span>{{ text "communities:action.continue_thread" }}</span>
</a>
{%- endif %}
<!-- prettier-ignore -->
<div style="display: contents;">
{% if post.context.repost and post.context.repost.reposting -%}
{{ components::repost(repost=reposting, post=post, owner=owner, community=community, show_community=true, can_manage_post=can_manage_posts) }}
{% else %}
{{ components::post(post=post, owner=owner, question=question, community=community, show_community=true, can_manage_post=can_manage_posts) }}
{%- endif %}
</div>
{% if user and post.context.comments_enabled -%}
<div class="card-nest">
<div class="card small">
<b>{{ text "communities:label.create_reply" }}</b>
</div>
<form
class="card flex flex-col gap-2"
onsubmit="create_reply_from_form(event)"
>
<div class="flex flex-col gap-1">
<label for="content"
>{{ text "communities:label.content" }}</label
>
<textarea
type="text"
name="content"
id="content"
placeholder="content"
required
minlength="2"
maxlength="4096"
></textarea>
</div>
<div id="files_list" class="flex gap-2 flex-wrap"></div>
<div class="flex gap-2">
{{ components::emoji_picker(element_id="content",
render_dialog=true) }} {% if is_supporter -%} {{
components::file_picker(files_list_id="files_list") }} {% endif
%}
<button class="primary">
{{ text "communities:action.create" }}
</button>
</div>
</form>
</div>
{%- endif %}
<div class="pillmenu">
<a href="#/replies" data-tab-button="replies" class="active">
{{ icon "newspaper" }}
<span>{{ text "communities:label.replies" }}</span>
</a>
<a href="/post/{{ post.id }}/reposts">
{{ icon "repeat-2" }}
<span>{{ text "communities:label.reposts" }}</span>
</a>
<a href="/post/{{ post.id }}/reposts?quotes=true">
{{ icon "quote" }}
<span>{{ text "communities:label.quotes" }}</span>
</a>
</div>
{% if (user and user.id == post.owner) or can_manage_posts -%}
<div class="pillmenu">
{% if user and user.id == post.owner -%}
<a href="/post/{{ post.id }}#/edit">
{{ icon "pen" }}
<span>{{ text "communities:label.edit_content" }}</span>
</a>
{%- endif %}
<a href="/post/{{ post.id }}/likes">
{{ icon "heart" }}
<span>{{ text "communities:label.likes" }}</span>
</a>
<a href="#/configure" data-tab-button="configure">
{{ icon "settings" }}
<span>{{ text "communities:action.configure" }}</span>
</a>
</div>
{%- endif %}
<div class="flex flex-col gap-2 hidden" data-tab="configure">
<div class="card-nest w-full">
<div class="card small flex items-center gap-2">
{{ icon "settings" }}
<span>{{ text "communities:action.configure" }}</span>
</div>
<div
class="card tertiary flex flex-col gap-4"
id="post_context"
></div>
</div>
<button onclick="save_context()">
{{ icon "check" }}
<span>{{ text "general:action.save" }}</span>
</button>
<script>
setTimeout(() => {
const ui = ns("ui");
const element = document.getElementById("post_context");
const settings = JSON.parse("{{ post_context_serde|safe }}");
globalThis.save_context = () => {
fetch("/api/v1/posts/{{ post.id }}/context", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
context: settings,
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
ui.refresh_container(element, []);
const can_manage_pins = "{{ can_manage_pins }}" === "true";
const is_owner =
"{{ user and user.id == post.owner }}" === "true";
const settings_fields = [
[
[
"comments_enabled",
"Allow people to comment on your post",
],
"{{ post.context.comments_enabled }}",
"checkbox",
],
[
[
"reposts_enabled",
"Allow people to repost/quote your post",
],
"{{ post.context.reposts_enabled }}",
"checkbox",
],
[
[
"reactions_enabled",
"Allow people to like/dislike your post",
],
"{{ post.context.reactions_enabled }}",
"checkbox",
],
[
["is_nsfw", "Hide from public timelines"],
"{{ community.context.is_nsfw }}",
"checkbox",
],
[
["content_warning", "Content warning"],
settings.content_warning,
"textarea",
],
[
["tags", "Tags"],
settings.tags.join(", "),
"input",
{
embed_html:
'<span class="fade">Tags should be separated by a comma.</span>',
},
],
];
if (can_manage_pins) {
settings_fields.push([
["is_pinned", "Pinned to community wall"],
"{{ post.context.is_pinned }}",
"checkbox",
]);
}
if (is_owner) {
settings_fields.push([
["is_profile_pinned", "Pinned to your profile"],
"{{ post.context.is_profile_pinned }}",
"checkbox",
]);
}
ui.generate_settings_ui(element, settings_fields, settings, {
tags: (new_tags) => {
settings.tags = new_tags
.split(",")
.map((t) => t.trim());
},
});
}, 250);
</script>
</div>
{% if user and user.id == post.owner -%}
<div class="card-nest w-full hidden" data-tab="edit">
<div class="card small flex items-center gap-2">
{{ icon "pen" }}
<span>{{ text "communities:label.edit_content" }}</span>
</div>
<form
class="card flex flex-col gap-2"
onsubmit="edit_post_from_form(event)"
>
<div class="flex flex-col gap-1">
<label for="content"
>{{ text "communities:label.content" }}</label
>
<textarea
type="text"
name="new_content"
id="new_content"
placeholder="content"
required
minlength="2"
maxlength="4096"
>
{{ post.content }}</textarea
>
</div>
<div class="flex gap-2">
{{ components::emoji_picker(element_id="new_content",
render_dialog=false) }}
<button class="primary">
{{ text "general:action.save" }}
</button>
</div>
</form>
</div>
<script>
async function edit_post_from_form(e) {
e.preventDefault();
await trigger("atto::debounce", ["posts::edit"]);
fetch("/api/v1/posts/{{ post.id }}/content", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
content: e.target.new_content.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
}
</script>
{%- endif %}
<div class="card-nest w-full" data-tab="replies">
<div class="card small flex items-center gap-2">
{{ icon "newspaper" }}
<span>{{ text "communities:label.replies" }}</span>
</div>
<div class="card flex flex-col gap-4">
<!-- prettier-ignore -->
{% for post in replies %}
{{ components::post(post=post[0], owner=post[1], question=post[3], secondary=true, show_community=false) }}
{% endfor %}
{{ components::pagination(page=page, items=replies|length) }}
</div>
</div>
</main>
<script>
async function create_reply_from_form(e) {
e.preventDefault();
await trigger("atto::debounce", ["posts::create"]);
// create body
const body = new FormData();
if (e.target.file_picker) {
for (const file of e.target.file_picker.files) {
body.append(file.name, file);
}
}
body.append(
"body",
JSON.stringify({
content: e.target.content.value,
community: "{{ community.id }}",
replying_to: "{{ post.id }}",
}),
);
// ...
fetch("/api/v1/posts", {
method: "POST",
body,
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
if (res.ok) {
setTimeout(() => {
window.location.href = `/post/${res.payload}`;
}, 100);
}
});
}
</script>
{% endblock %}

View file

@ -0,0 +1,321 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Post - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
(text "{% if post.replying_to -%}")
(a
("href" "/post/{{ post.replying_to }}")
("class" "button")
(text "{{ icon \"arrow-up\" }}")
(span
(text "{{ text \"communities:action.continue_thread\" }}")))
(text "{%- endif %}")
(div
("style" "display: contents;")
(text "{% if post.context.repost and post.context.repost.reposting -%} {{ components::repost(repost=reposting, post=post, owner=owner, community=community, show_community=true, can_manage_post=can_manage_posts) }} {% else %} {{ components::post(post=post, owner=owner, question=question, community=community, show_community=true, can_manage_post=can_manage_posts) }} {%- endif %}"))
(text "{% if user and post.context.comments_enabled -%}")
(div
("class" "card-nest")
(div
("class" "card small")
(b
(text "{{ text \"communities:label.create_reply\" }}")))
(form
("class" "card flex flex-col gap-2")
("onsubmit" "create_reply_from_form(event)")
(div
("class" "flex flex-col gap-1")
(label
("for" "content")
(text "{{ text \"communities:label.content\" }}"))
(textarea
("type" "text")
("name" "content")
("id" "content")
("placeholder" "content")
("required" "")
("minlength" "2")
("maxlength" "4096")))
(div
("id" "files_list")
("class" "flex gap-2 flex-wrap"))
(div
("class" "flex gap-2")
(text "{{ components::emoji_picker(element_id=\"content\", render_dialog=true) }} {% if is_supporter -%} {{ components::file_picker(files_list_id=\"files_list\") }} {% endif %}")
(button
("class" "primary")
(text "{{ text \"communities:action.create\" }}")))))
(text "{%- endif %}")
(div
("class" "pillmenu")
(a
("href" "#/replies")
("data-tab-button" "replies")
("class" "active")
(text "{{ icon \"newspaper\" }}")
(span
(text "{{ text \"communities:label.replies\" }}")))
(a
("href" "/post/{{ post.id }}/reposts")
(text "{{ icon \"repeat-2\" }}")
(span
(text "{{ text \"communities:label.reposts\" }}")))
(a
("href" "/post/{{ post.id }}/reposts?quotes=true")
(text "{{ icon \"quote\" }}")
(span
(text "{{ text \"communities:label.quotes\" }}"))))
(text "{% if (user and user.id == post.owner) or can_manage_posts -%}")
(div
("class" "pillmenu")
(text "{% if user and user.id == post.owner -%}")
(a
("href" "/post/{{ post.id }}#/edit")
(text "{{ icon \"pen\" }}")
(span
(text "{{ text \"communities:label.edit_content\" }}")))
(text "{%- endif %}")
(a
("href" "/post/{{ post.id }}/likes")
(text "{{ icon \"heart\" }}")
(span
(text "{{ text \"communities:label.likes\" }}")))
(a
("href" "#/configure")
("data-tab-button" "configure")
(text "{{ icon \"settings\" }}")
(span
(text "{{ text \"communities:action.configure\" }}"))))
(text "{%- endif %}")
(div
("class" "flex flex-col gap-2 hidden")
("data-tab" "configure")
(div
("class" "card-nest w-full")
(div
("class" "card small flex items-center gap-2")
(text "{{ icon \"settings\" }}")
(span
(text "{{ text \"communities:action.configure\" }}")))
(div
("class" "card tertiary flex flex-col gap-4")
("id" "post_context")))
(button
("onclick" "save_context()")
(text "{{ icon \"check\" }}")
(span
(text "{{ text \"general:action.save\" }}")))
(script
(text "setTimeout(() => {
const ui = ns(\"ui\");
const element = document.getElementById(\"post_context\");
const settings = JSON.parse(\"{{ post_context_serde|safe }}\");
globalThis.save_context = () => {
fetch(\"/api/v1/posts/{{ post.id }}/context\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
context: settings,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
ui.refresh_container(element, []);
const can_manage_pins = \"{{ can_manage_pins }}\" === \"true\";
const is_owner =
\"{{ user and user.id == post.owner }}\" === \"true\";
const settings_fields = [
[
[
\"comments_enabled\",
\"Allow people to comment on your post\",
],
\"{{ post.context.comments_enabled }}\",
\"checkbox\",
],
[
[
\"reposts_enabled\",
\"Allow people to repost/quote your post\",
],
\"{{ post.context.reposts_enabled }}\",
\"checkbox\",
],
[
[
\"reactions_enabled\",
\"Allow people to like/dislike your post\",
],
\"{{ post.context.reactions_enabled }}\",
\"checkbox\",
],
[
[\"is_nsfw\", \"Hide from public timelines\"],
\"{{ community.context.is_nsfw }}\",
\"checkbox\",
],
[
[\"content_warning\", \"Content warning\"],
settings.content_warning,
\"textarea\",
],
[
[\"tags\", \"Tags\"],
settings.tags.join(\", \"),
\"input\",
{
embed_html:
'<span class=\"fade\">Tags should be separated by a comma.</span>',
},
],
];
if (can_manage_pins) {
settings_fields.push([
[\"is_pinned\", \"Pinned to community wall\"],
\"{{ post.context.is_pinned }}\",
\"checkbox\",
]);
}
if (is_owner) {
settings_fields.push([
[\"is_profile_pinned\", \"Pinned to your profile\"],
\"{{ post.context.is_profile_pinned }}\",
\"checkbox\",
]);
}
ui.generate_settings_ui(element, settings_fields, settings, {
tags: (new_tags) => {
settings.tags = new_tags
.split(\",\")
.map((t) => t.trim());
},
});
}, 250);")))
(text "{% if user and user.id == post.owner -%}")
(div
("class" "card-nest w-full hidden")
("data-tab" "edit")
(div
("class" "card small flex items-center gap-2")
(text "{{ icon \"pen\" }}")
(span
(text "{{ text \"communities:label.edit_content\" }}")))
(form
("class" "card flex flex-col gap-2")
("onsubmit" "edit_post_from_form(event)")
(div
("class" "flex flex-col gap-1")
(label
("for" "content")
(text "{{ text \"communities:label.content\" }}"))
(textarea
("type" "text")
("name" "new_content")
("id" "new_content")
("placeholder" "content")
("required" "")
("minlength" "2")
("maxlength" "4096")
(text "{{ post.content }}")))
(div
("class" "flex gap-2")
(text "{{ components::emoji_picker(element_id=\"new_content\", render_dialog=false) }}")
(button
("class" "primary")
(text "{{ text \"general:action.save\" }}")))))
(script
(text "async function edit_post_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"posts::edit\"]);
fetch(\"/api/v1/posts/{{ post.id }}/content\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
content: e.target.new_content.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}"))
(text "{%- endif %}")
(div
("class" "card-nest w-full")
("data-tab" "replies")
(div
("class" "card small flex items-center gap-2")
(text "{{ icon \"newspaper\" }}")
(span
(text "{{ text \"communities:label.replies\" }}")))
(div
("class" "card flex flex-col gap-4")
(text "{% for post in replies %} {{ components::post(post=post[0], owner=post[1], question=post[3], secondary=true, show_community=false) }} {% endfor %} {{ components::pagination(page=page, items=replies|length) }}"))))
(script
(text "async function create_reply_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"posts::create\"]);
// create body
const body = new FormData();
if (e.target.file_picker) {
for (const file of e.target.file_picker.files) {
body.append(file.name, file);
}
}
body.append(
\"body\",
JSON.stringify({
content: e.target.content.value,
community: \"{{ community.id }}\",
replying_to: \"{{ post.id }}\",
}),
);
// ...
fetch(\"/api/v1/posts\", {
method: \"POST\",
body,
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
setTimeout(() => {
window.location.href = `/post/${res.payload}`;
}, 100);
}
});
}"))
(text "{% endblock %}")

View file

@ -1,74 +0,0 @@
{% extends "root.html" %} {% block head %}
<title>Post quotes - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav() }}
<main class="flex flex-col gap-2">
{% if post.replying_to -%}
<a href="/post/{{ post.replying_to }}" class="button">
{{ icon "arrow-up" }}
<span>{{ text "communities:action.continue_thread" }}</span>
</a>
{%- endif %}
<!-- prettier-ignore -->
<div style="display: contents;">
{% if post.context.repost and post.context.repost.reposting -%}
{{ components::repost(repost=reposting, post=post, owner=owner, community=community, show_community=true, can_manage_post=can_manage_posts) }}
{% else %}
{{ components::post(post=post, owner=owner, question=question, community=community, show_community=true, can_manage_post=can_manage_posts) }}
{%- endif %}
</div>
<div class="pillmenu">
<a href="/post/{{ post.id }}#/replies">
{{ icon "newspaper" }}
<span>{{ text "communities:label.replies" }}</span>
</a>
<a href="/post/{{ post.id }}/reposts">
{{ icon "repeat-2" }}
<span>{{ text "communities:label.reposts" }}</span>
</a>
<a href="/post/{{ post.id }}/reposts?quotes=true" class="active">
{{ icon "quote" }}
<span>{{ text "communities:label.quotes" }}</span>
</a>
</div>
{% if (user and user.id == post.owner) or can_manage_posts -%}
<div class="pillmenu">
{% if user and user.id == post.owner -%}
<a href="/post/{{ post.id }}#/edit">
{{ icon "pen" }}
<span>{{ text "communities:label.edit_content" }}</span>
</a>
{%- endif %}
<a href="/post/{{ post.id }}/likes">
{{ icon "heart" }}
<span>{{ text "communities:label.likes" }}</span>
</a>
<a href="/post/{{ post.id }}#/configure">
{{ icon "settings" }}
<span>{{ text "communities:action.configure" }}</span>
</a>
</div>
{%- endif %}
<div class="card-nest w-full">
<div class="card small flex items-center gap-2">
{{ icon "quote" }}
<span>{{ text "communities:label.quotes" }}</span>
</div>
<div class="card flex flex-col gap-4">
<!-- prettier-ignore -->
{% for post in list %}
{{ components::post(post=post[0], owner=post[1], question=post[3], secondary=true, show_community=false) }}
{% endfor %}
{{ components::pagination(page=page, items=list|length, key="quotes", value="true") }}
</div>
</div>
</main>
{% endblock %}

View file

@ -0,0 +1,69 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Post quotes - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
(text "{% if post.replying_to -%}")
(a
("href" "/post/{{ post.replying_to }}")
("class" "button")
(text "{{ icon \"arrow-up\" }}")
(span
(text "{{ text \"communities:action.continue_thread\" }}")))
(text "{%- endif %}")
(div
("style" "display: contents;")
(text "{% if post.context.repost and post.context.repost.reposting -%} {{ components::repost(repost=reposting, post=post, owner=owner, community=community, show_community=true, can_manage_post=can_manage_posts) }} {% else %} {{ components::post(post=post, owner=owner, question=question, community=community, show_community=true, can_manage_post=can_manage_posts) }} {%- endif %}"))
(div
("class" "pillmenu")
(a
("href" "/post/{{ post.id }}#/replies")
(text "{{ icon \"newspaper\" }}")
(span
(text "{{ text \"communities:label.replies\" }}")))
(a
("href" "/post/{{ post.id }}/reposts")
(text "{{ icon \"repeat-2\" }}")
(span
(text "{{ text \"communities:label.reposts\" }}")))
(a
("href" "/post/{{ post.id }}/reposts?quotes=true")
("class" "active")
(text "{{ icon \"quote\" }}")
(span
(text "{{ text \"communities:label.quotes\" }}"))))
(text "{% if (user and user.id == post.owner) or can_manage_posts -%}")
(div
("class" "pillmenu")
(text "{% if user and user.id == post.owner -%}")
(a
("href" "/post/{{ post.id }}#/edit")
(text "{{ icon \"pen\" }}")
(span
(text "{{ text \"communities:label.edit_content\" }}")))
(text "{%- endif %}")
(a
("href" "/post/{{ post.id }}/likes")
(text "{{ icon \"heart\" }}")
(span
(text "{{ text \"communities:label.likes\" }}")))
(a
("href" "/post/{{ post.id }}#/configure")
(text "{{ icon \"settings\" }}")
(span
(text "{{ text \"communities:action.configure\" }}"))))
(text "{%- endif %}")
(div
("class" "card-nest w-full")
(div
("class" "card small flex items-center gap-2")
(text "{{ icon \"quote\" }}")
(span
(text "{{ text \"communities:label.quotes\" }}")))
(div
("class" "card flex flex-col gap-4")
(text "{% for post in list %} {{ components::post(post=post[0], owner=post[1], question=post[3], secondary=true, show_community=false) }} {% endfor %} {{ components::pagination(page=page, items=list|length, key=\"quotes\", value=\"true\") }}"))))
(text "{% endblock %}")

View file

@ -1,93 +0,0 @@
{% extends "root.html" %} {% block head %}
<title>Post reposts - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav() }}
<main class="flex flex-col gap-2">
{% if post.replying_to -%}
<a href="/post/{{ post.replying_to }}" class="button">
{{ icon "arrow-up" }}
<span>{{ text "communities:action.continue_thread" }}</span>
</a>
{%- endif %}
<!-- prettier-ignore -->
<div style="display: contents;">
{% if post.context.repost and post.context.repost.reposting -%}
{{ components::repost(repost=reposting, post=post, owner=owner, community=community, show_community=true, can_manage_post=can_manage_posts) }}
{% else %}
{{ components::post(post=post, owner=owner, question=question, community=community, show_community=true, can_manage_post=can_manage_posts) }}
{%- endif %}
</div>
<div class="pillmenu">
<a href="/post/{{ post.id }}#/replies">
{{ icon "newspaper" }}
<span>{{ text "communities:label.replies" }}</span>
</a>
<a href="/post/{{ post.id }}/reposts" class="active">
{{ icon "repeat-2" }}
<span>{{ text "communities:label.reposts" }}</span>
</a>
<a href="/post/{{ post.id }}/reposts?quotes=true">
{{ icon "quote" }}
<span>{{ text "communities:label.quotes" }}</span>
</a>
</div>
{% if (user and user.id == post.owner) or can_manage_posts -%}
<div class="pillmenu">
{% if user and user.id == post.owner -%}
<a href="/post/{{ post.id }}#/edit">
{{ icon "pen" }}
<span>{{ text "communities:label.edit_content" }}</span>
</a>
{%- endif %}
<a href="/post/{{ post.id }}/likes">
{{ icon "heart" }}
<span>{{ text "communities:label.likes" }}</span>
</a>
<a href="/post/{{ post.id }}#/configure">
{{ icon "settings" }}
<span>{{ text "communities:action.configure" }}</span>
</a>
</div>
{%- endif %}
<div class="card-nest w-full">
<div class="card small flex items-center gap-2">
{{ icon "repeat-2" }}
<span>{{ text "communities:label.reposts" }}</span>
</div>
<div class="card flex flex-col gap-4">
{% for post in list %}
<div class="card-nest">
<div class="card flex items-center gap-2">
<a href="/@{{ post[1].username }}">
{{ components::avatar(username=post[1].username,
size="24px", selector_type="username") }}
</a>
<div class="name">
{{ components::full_username(user=post[1]) }}
</div>
</div>
<div class="card flex items-center gap-2 flex-wrap secondary">
<a
href="/post/{{ post[0].id }}"
class="quaternary small button"
>
{{ icon "external-link" }}
<span>{{ text "general:action.open" }}</span>
</a>
</div>
</div>
{% endfor %} {{ components::pagination(page=page, items=list|length,
key="quotes", value="false") }}
</div>
</div>
</main>
{% endblock %}

View file

@ -0,0 +1,88 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Post reposts - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
(text "{% if post.replying_to -%}")
(a
("href" "/post/{{ post.replying_to }}")
("class" "button")
(text "{{ icon \"arrow-up\" }}")
(span
(text "{{ text \"communities:action.continue_thread\" }}")))
(text "{%- endif %}")
(div
("style" "display: contents;")
(text "{% if post.context.repost and post.context.repost.reposting -%} {{ components::repost(repost=reposting, post=post, owner=owner, community=community, show_community=true, can_manage_post=can_manage_posts) }} {% else %} {{ components::post(post=post, owner=owner, question=question, community=community, show_community=true, can_manage_post=can_manage_posts) }} {%- endif %}"))
(div
("class" "pillmenu")
(a
("href" "/post/{{ post.id }}#/replies")
(text "{{ icon \"newspaper\" }}")
(span
(text "{{ text \"communities:label.replies\" }}")))
(a
("href" "/post/{{ post.id }}/reposts")
("class" "active")
(text "{{ icon \"repeat-2\" }}")
(span
(text "{{ text \"communities:label.reposts\" }}")))
(a
("href" "/post/{{ post.id }}/reposts?quotes=true")
(text "{{ icon \"quote\" }}")
(span
(text "{{ text \"communities:label.quotes\" }}"))))
(text "{% if (user and user.id == post.owner) or can_manage_posts -%}")
(div
("class" "pillmenu")
(text "{% if user and user.id == post.owner -%}")
(a
("href" "/post/{{ post.id }}#/edit")
(text "{{ icon \"pen\" }}")
(span
(text "{{ text \"communities:label.edit_content\" }}")))
(text "{%- endif %}")
(a
("href" "/post/{{ post.id }}/likes")
(text "{{ icon \"heart\" }}")
(span
(text "{{ text \"communities:label.likes\" }}")))
(a
("href" "/post/{{ post.id }}#/configure")
(text "{{ icon \"settings\" }}")
(span
(text "{{ text \"communities:action.configure\" }}"))))
(text "{%- endif %}")
(div
("class" "card-nest w-full")
(div
("class" "card small flex items-center gap-2")
(text "{{ icon \"repeat-2\" }}")
(span
(text "{{ text \"communities:label.reposts\" }}")))
(div
("class" "card flex flex-col gap-4")
(text "{% for post in list %}")
(div
("class" "card-nest")
(div
("class" "card flex items-center gap-2")
(a
("href" "/@{{ post[1].username }}")
(text "{{ components::avatar(username=post[1].username, size=\"24px\", selector_type=\"username\") }}"))
(div
("class" "name")
(text "{{ components::full_username(user=post[1]) }}")))
(div
("class" "card flex items-center gap-2 flex-wrap secondary")
(a
("href" "/post/{{ post[0].id }}")
("class" "quaternary small button")
(text "{{ icon \"external-link\" }}")
(span
(text "{{ text \"general:action.open\" }}")))))
(text "{% endfor %} {{ components::pagination(page=page, items=list|length, key=\"quotes\", value=\"false\") }}"))))
(text "{% endblock %}")

View file

@ -1,27 +0,0 @@
{% extends "root.html" %} {% block head %}
<title>{{ profile.username }} (banned) - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav() }}
<main class="flex flex-col gap-2">
<div class="card-nest">
<div class="card small flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
{{ components::avatar(username=profile.username, size="24px") }}
<span>{{ profile.username }}</span>
</div>
<b class="notification chip">{{ text "auth:label.banned" }}</b>
</div>
<div class="card flex flex-col gap-2">
<span>{{ text "auth:label.banned_message" }}</span>
<div class="card w-full secondary flex gap-2">
<a href="/" class="button red quaternary">
{{ icon "x" }}
<span>{{ text "general:action.back" }}</span>
</a>
</div>
</div>
</div>
</main>
{% endblock %}

View file

@ -0,0 +1,33 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "{{ profile.username }} (banned) - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
(div
("class" "card-nest")
(div
("class" "card small flex items-center justify-between gap-2")
(div
("class" "flex items-center gap-2")
(text "{{ components::avatar(username=profile.username, size=\"24px\") }}")
(span
(text "{{ profile.username }}")))
(b
("class" "notification chip")
(text "{{ text \"auth:label.banned\" }}")))
(div
("class" "card flex flex-col gap-2")
(span
(text "{{ text \"auth:label.banned_message\" }}"))
(div
("class" "card w-full secondary flex gap-2")
(a
("href" "/")
("class" "button red quaternary")
(text "{{ icon \"x\" }}")
(span
(text "{{ text \"general:action.back\" }}")))))))
(text "{% endblock %}")

View file

@ -1,386 +0,0 @@
{% extends "root.html" %} {% block head %}
<title>{{ profile.username }} - {{ config.name }}</title>
<meta name="og:title" content="{{ profile.username }}" />
<meta
name="description"
content="View @{{ profile.username }}'s profile on {{ config.name }}!"
/>
<meta
name="og:description"
content="View @{{ profile.username }}'s profile on {{ config.name }}!"
/>
<meta property="og:type" content="profile" />
<meta property="profile:username" content="{{ profile.username }}" />
<meta
name="og:image"
content="{{ config.host|safe }}/api/v1/auth/user/{{ profile.username }}/avatar?selector_type=username"
/>
<meta
name="twitter:image"
content="{{ config.host|safe }}/api/v1/auth/user/{{ profile.username }}/avatar?selector_type=username"
/>
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="{{ profile.username }}" />
<meta
name="twitter:description"
content="View @{{ profile.username }}'s profile on {{ config.name }}!"
/>
{% endblock %} {% block body %} {{ macros::nav() }}
<article>
<div class="content_container flex flex-col gap-4">
{{ components::banner(username=profile.username) }}
<div class="w-full flex gap-4 flex-collapse">
<div
class="lhs flex flex-col gap-2 sm:w-full"
style="width: 22rem; min-width: 22rem"
>
<div class="card-nest w-full">
<div class="card flex gap-2" id="user_avatar_and_name">
{{
components::avatar(username=profile.username,size="72px")
}}
<div class="flex flex-col">
<!-- prettier-ignore -->
<h3 id="username" class="username flex items-center gap-2 flex-wrap w-full">
<span class="name shorter">{{ components::username(user=profile) }}</span>
{% if profile.is_verified -%}
<span title="Verified" style="color: var(--color-primary);" class="flex items-center">
{{ icon "badge-check" }}
</span>
{%- endif %}
{% if profile.permissions|has_supporter -%}
<span title="Supporter" style="color: var(--color-primary);" class="flex items-center">
{{ icon "star" }}
</span>
{%- endif %}
{% if profile.permissions|has_staff_badge -%}
<span title="Staff" style="color: var(--color-primary);" class="flex items-center">
{{ icon "shield-user" }}
</span>
{%- endif %}
{% if profile.permissions|has_banned -%}
<span title="Banned" style="color: var(--color-primary);" class="flex items-center">
{{ icon "shield-ban" }}
</span>
{%- endif %}
</h3>
<span class="fade">{{ profile.username }}</span>
</div>
</div>
<div
class="card flex flex-col items-center gap-2"
id="social"
>
{% if profile.settings.status -%}
<p>{{ profile.settings.status }}</p>
{%- endif %}
<div class="w-full flex">
<a
href="/@{{ profile.username }}/followers"
class="w-full flex justify-center items-center gap-2"
>
<h4>{{ profile.follower_count }}</h4>
<span>{{ text "auth:label.followers" }}</span>
</a>
<a
href="/@{{ profile.username }}/following"
class="w-full flex justify-center items-center gap-2"
>
<h4>{{ profile.following_count }}</h4>
<span>{{ text "auth:label.following" }}</span>
</a>
</div>
{% if is_following_you -%}
<b
class="notification chip w-content flex items-center gap-2"
>
{{ icon "heart" }}
<span>Follows you</span>
</b>
{%- endif %}
</div>
</div>
<div class="card-nest flex flex-col">
<div id="bio" class="card small no_p_margin">
{{ profile.settings.biography|markdown|safe }}
</div>
<div class="card flex flex-col gap-2">
<!-- prettier-ignore -->
<div style="display: contents;">
{% if profile.connections.Spotify and profile.connections.Spotify[0].data.name -%}
{{ components::spotify_playing(state=profile.connections.Spotify[1]) }}
{% elif profile.connections.LastFm and profile.connections.LastFm[0].data.name %}
{{ components::last_fm_playing(state=profile.connections.LastFm[1]) }}
{%- endif %}
</div>
<div class="w-full flex justify-between items-center">
<span class="notification chip">ID</span>
<button
title="Copy"
onclick="trigger('atto::copy_text', ['{{ profile.id }}'])"
class="camo small"
>
{{ icon "copy" }}
</button>
</div>
<div class="w-full flex justify-between items-center">
<span class="notification chip">Joined</span>
<span class="date">{{ profile.created }}</span>
</div>
<div class="w-full flex justify-between items-center">
<span class="notification chip">Posts</span>
<span>{{ profile.post_count }}</span>
</div>
{% if not profile.settings.private_last_seen or is_self
or is_helper %}
<div class="w-full flex justify-between items-center">
<span class="notification chip">Last seen</span>
<div class="flex">
{{ components::online_indicator(user=profile) }}
<span class="date">
{{ profile.last_seen }}
</span>
</div>
</div>
{%- endif %}
</div>
</div>
{% if not is_self and user -%}
<div class="card-nest">
<div class="card small">
<b>{{ text "auth:label.relationship" }}</b>
</div>
<div class="card flex gap-2 flex-wrap">
{% if not is_blocking -%}
<button
onclick="toggle_follow_user(event)"
class="{% if is_following %} hidden{% endif %}"
atto_tag="user.follow"
>
{{ icon "user-plus" }}
<span>{{ text "auth:action.follow" }}</span>
</button>
<button
onclick="toggle_follow_user(event)"
class="quaternary red{% if not is_following %} hidden{% endif %}"
atto_tag="user.unfollow"
>
{{ icon "user-minus" }}
<span>{{ text "auth:action.unfollow" }}</span>
</button>
<button
onclick="toggle_block_user()"
class="quaternary red"
>
{{ icon "shield" }}
<span>{{ text "auth:action.block" }}</span>
</button>
{% else %}
<button
onclick="toggle_block_user()"
class="quaternary red"
>
{{ icon "shield-off" }}
<span>{{ text "auth:action.unblock" }}</span>
</button>
{%- endif %} {% if not user.settings.private_chats or
is_following_you %}
<button
onclick="create_group_chat()"
class="quaternary"
>
{{ icon "message-circle" }}
<span>{{ text "auth:action.message" }}</span>
</button>
{%- endif %} {% if is_helper -%}
<a
href="/mod_panel/profile/{{ profile.id }}"
class="button quaternary"
>
{{ icon "shield" }}
<span>{{ text "general:action.manage" }}</span>
</a>
{%- endif %}
<script>
globalThis.create_group_chat = async () => {
fetch("/api/v1/channels/group", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: "{{ user.username }} & {{ profile.username }}",
members: ["{{ profile.id }}"],
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
if (res.ok) {
window.location.href = `/chats/0/${res.payload}`;
}
});
};
globalThis.toggle_follow_user = async (e) => {
await trigger("atto::debounce", [
"users::follow",
]);
fetch(
"/api/v1/auth/user/{{ profile.id }}/follow",
{
method: "POST",
},
)
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
if (
e.target.getAttribute(
"atto_tag",
) === "user.follow"
) {
document
.querySelector(
'[atto_tag="user.follow"]',
)
.classList.add("hidden");
document
.querySelector(
'[atto_tag="user.unfollow"]',
)
.classList.remove("hidden");
} else {
document
.querySelector(
'[atto_tag="user.unfollow"]',
)
.classList.add("hidden");
document
.querySelector(
'[atto_tag="user.follow"]',
)
.classList.remove("hidden");
}
});
};
globalThis.toggle_block_user = async () => {
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this?",
]))
) {
return;
}
fetch(
"/api/v1/auth/user/{{ profile.id }}/block",
{
method: "POST",
},
)
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
</script>
</div>
</div>
{%- endif %} {% if not profile.settings.private_communities or
is_self or is_helper %}
<div class="card-nest">
<div class="card small flex gap-2 items-center">
{{ icon "users-round" }}
<span>{{ text "auth:label.joined_communities" }}</span>
</div>
<div class="card flex flex-wrap gap-2">
{% for community in communities %}
<a href="/community/{{ community.title }}">
{{ components::community_avatar(id=community.id,
community=community, size="48px") }}
</a>
{% endfor %}
</div>
</div>
{%- endif %}
<div class="flex flex-col gap-2" id="connections">
{% for key, value in profile.connections %} {% if
value[0].data.name and value[0].show_on_profile %}
<a
class="card small flush flex items-center justify-between gap-2"
href="{{ components::connection_url(key=key, value=value) }}"
>
<div class="flex items-center gap-2">
{{ components::connection_icon(key=key) }}
<b>{{ value[0].data.name }}</b>
</div>
<button class="camo small">
{{ icon "external-link" }}
</button>
</a>
{%- endif %} {% endfor %}
</div>
</div>
<div class="rhs w-full flex flex-col gap-4">
{% block content %}{% endblock %}
</div>
</div>
</div>
</article>
{% if not is_self and profile.settings.warning -%}
<script>
setTimeout(() => {
// check for warning
trigger("warnings::open", [
"{{ profile.id }}",
"{{ warning_hash }}",
"?warning=true",
]);
}, 150);
</script>
{%- endif %} {% if not use_user_theme -%} {{ components::theme(user=profile,
theme_preference=profile.settings.profile_theme) }} {%- endif %} {% endblock %}

View file

@ -0,0 +1,371 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "{{ profile.username }} - {{ config.name }}"))
(meta
("name" "og:title")
("content" "{{ profile.username }}"))
(meta
("name" "description")
("content" "View @{{ profile.username }}'s profile on {{ config.name }}!"))
(meta
("name" "og:description")
("content" "View @{{ profile.username }}'s profile on {{ config.name }}!"))
(meta
("property" "og:type")
("content" "profile"))
(meta
("property" "profile:username")
("content" "{{ profile.username }}"))
(meta
("name" "og:image")
("content" "{{ config.host|safe }}/api/v1/auth/user/{{ profile.username }}/avatar?selector_type=username"))
(meta
("name" "twitter:image")
("content" "{{ config.host|safe }}/api/v1/auth/user/{{ profile.username }}/avatar?selector_type=username"))
(meta
("name" "twitter:card")
("content" "summary"))
(meta
("name" "twitter:title")
("content" "{{ profile.username }}"))
(meta
("name" "twitter:description")
("content" "View @{{ profile.username }}'s profile on {{ config.name }}!"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(article
(div
("class" "content_container flex flex-col gap-4")
(text "{{ components::banner(username=profile.username) }}")
(div
("class" "w-full flex gap-4 flex-collapse")
(div
("class" "lhs flex flex-col gap-2 sm:w-full")
("style" "width: 22rem; min-width: 22rem")
(div
("class" "card-nest w-full")
(div
("class" "card flex gap-2")
("id" "user_avatar_and_name")
(text "{{ components::avatar(username=profile.username,size=\"72px\") }}")
(div
("class" "flex flex-col")
(h3
("id" "username")
("class" "username flex items-center gap-2 flex-wrap w-full")
(span
("class" "name shorter")
(text "{{ components::username(user=profile) }}"))
(text "{% if profile.is_verified -%}")
(span
("title" "Verified")
("style" "color: var(--color-primary);")
("class" "flex items-center")
(text "{{ icon \"badge-check\" }}"))
(text "{%- endif %} {% if profile.permissions|has_supporter -%}")
(span
("title" "Supporter")
("style" "color: var(--color-primary);")
("class" "flex items-center")
(text "{{ icon \"star\" }}"))
(text "{%- endif %} {% if profile.permissions|has_staff_badge -%}")
(span
("title" "Staff")
("style" "color: var(--color-primary);")
("class" "flex items-center")
(text "{{ icon \"shield-user\" }}"))
(text "{%- endif %} {% if profile.permissions|has_banned -%}")
(span
("title" "Banned")
("style" "color: var(--color-primary);")
("class" "flex items-center")
(text "{{ icon \"shield-ban\" }}"))
(text "{%- endif %}"))
(span
("class" "fade")
(text "{{ profile.username }}"))))
(div
("class" "card flex flex-col items-center gap-2")
("id" "social")
(text "{% if profile.settings.status -%}")
(p
(text "{{ profile.settings.status }}"))
(text "{%- endif %}")
(div
("class" "w-full flex")
(a
("href" "/@{{ profile.username }}/followers")
("class" "w-full flex justify-center items-center gap-2")
(h4
(text "{{ profile.follower_count }}"))
(span
(text "{{ text \"auth:label.followers\" }}")))
(a
("href" "/@{{ profile.username }}/following")
("class" "w-full flex justify-center items-center gap-2")
(h4
(text "{{ profile.following_count }}"))
(span
(text "{{ text \"auth:label.following\" }}"))))
(text "{% if is_following_you -%}")
(b
("class" "notification chip w-content flex items-center gap-2")
(text "{{ icon \"heart\" }}")
(span
(text "Follows you")))
(text "{%- endif %}")))
(div
("class" "card-nest flex flex-col")
(div
("id" "bio")
("class" "card small no_p_margin")
(text "{{ profile.settings.biography|markdown|safe }}"))
(div
("class" "card flex flex-col gap-2")
(div
("style" "display: contents;")
(text "{% if profile.connections.Spotify and profile.connections.Spotify[0].data.name -%} {{ components::spotify_playing(state=profile.connections.Spotify[1]) }} {% elif profile.connections.LastFm and profile.connections.LastFm[0].data.name %} {{ components::last_fm_playing(state=profile.connections.LastFm[1]) }} {%- endif %}"))
(div
("class" "w-full flex justify-between items-center")
(span
("class" "notification chip")
(text "ID"))
(button
("title" "Copy")
("onclick" "trigger('atto::copy_text', ['{{ profile.id }}'])")
("class" "camo small")
(text "{{ icon \"copy\" }}")))
(div
("class" "w-full flex justify-between items-center")
(span
("class" "notification chip")
(text "Joined"))
(span
("class" "date")
(text "{{ profile.created }}")))
(div
("class" "w-full flex justify-between items-center")
(span
("class" "notification chip")
(text "Posts"))
(span
(text "{{ profile.post_count }}")))
(text "{% if not profile.settings.private_last_seen or is_self or is_helper %}")
(div
("class" "w-full flex justify-between items-center")
(span
("class" "notification chip")
(text "Last seen"))
(div
("class" "flex")
(text "{{ components::online_indicator(user=profile) }}")
(span
("class" "date")
(text "{{ profile.last_seen }}"))))
(text "{%- endif %}")))
(text "{% if not is_self and user -%}")
(div
("class" "card-nest")
(div
("class" "card small")
(b
(text "{{ text \"auth:label.relationship\" }}")))
(div
("class" "card flex gap-2 flex-wrap")
(text "{% if not is_blocking -%}")
(button
("onclick" "toggle_follow_user(event)")
("class" "{% if is_following %} hidden{% endif %}")
("atto_tag" "user.follow")
(text "{{ icon \"user-plus\" }}")
(span
(text "{{ text \"auth:action.follow\" }}")))
(button
("onclick" "toggle_follow_user(event)")
("class" "quaternary red{% if not is_following %} hidden{% endif %}")
("atto_tag" "user.unfollow")
(text "{{ icon \"user-minus\" }}")
(span
(text "{{ text \"auth:action.unfollow\" }}")))
(button
("onclick" "toggle_block_user()")
("class" "quaternary red")
(text "{{ icon \"shield\" }}")
(span
(text "{{ text \"auth:action.block\" }}")))
(text "{% else %}")
(button
("onclick" "toggle_block_user()")
("class" "quaternary red")
(text "{{ icon \"shield-off\" }}")
(span
(text "{{ text \"auth:action.unblock\" }}")))
(text "{%- endif %} {% if not user.settings.private_chats or is_following_you %}")
(button
("onclick" "create_group_chat()")
("class" "quaternary")
(text "{{ icon \"message-circle\" }}")
(span
(text "{{ text \"auth:action.message\" }}")))
(text "{%- endif %} {% if is_helper -%}")
(a
("href" "/mod_panel/profile/{{ profile.id }}")
("class" "button quaternary")
(text "{{ icon \"shield\" }}")
(span
(text "{{ text \"general:action.manage\" }}")))
(text "{%- endif %}")
(script
(text "globalThis.create_group_chat = async () => {
fetch(\"/api/v1/channels/group\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
title: \"{{ user.username }} & {{ profile.username }}\",
members: [\"{{ profile.id }}\"],
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
window.location.href = `/chats/0/${res.payload}`;
}
});
};
globalThis.toggle_follow_user = async (e) => {
await trigger(\"atto::debounce\", [
\"users::follow\",
]);
fetch(
\"/api/v1/auth/user/{{ profile.id }}/follow\",
{
method: \"POST\",
},
)
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (
e.target.getAttribute(
\"atto_tag\",
) === \"user.follow\"
) {
document
.querySelector(
'[atto_tag=\"user.follow\"]',
)
.classList.add(\"hidden\");
document
.querySelector(
'[atto_tag=\"user.unfollow\"]',
)
.classList.remove(\"hidden\");
} else {
document
.querySelector(
'[atto_tag=\"user.unfollow\"]',
)
.classList.add(\"hidden\");
document
.querySelector(
'[atto_tag=\"user.follow\"]',
)
.classList.remove(\"hidden\");
}
});
};
globalThis.toggle_block_user = async () => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(
\"/api/v1/auth/user/{{ profile.id }}/block\",
{
method: \"POST\",
},
)
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};"))))
(text "{%- endif %} {% if not profile.settings.private_communities or is_self or is_helper %}")
(div
("class" "card-nest")
(div
("class" "card small flex gap-2 items-center")
(text "{{ icon \"users-round\" }}")
(span
(text "{{ text \"auth:label.joined_communities\" }}")))
(div
("class" "card flex flex-wrap gap-2")
(text "{% for community in communities %}")
(a
("href" "/community/{{ community.title }}")
(text "{{ components::community_avatar(id=community.id, community=community, size=\"48px\") }}"))
(text "{% endfor %}")))
(text "{%- endif %}")
(div
("class" "flex flex-col gap-2")
("id" "connections")
(text "{% for key, value in profile.connections %} {% if value[0].data.name and value[0].show_on_profile %}")
(a
("class" "card small flush flex items-center justify-between gap-2")
("href" "{{ components::connection_url(key=key, value=value) }}")
(div
("class" "flex items-center gap-2")
(text "{{ components::connection_icon(key=key) }}")
(b
(text "{{ value[0].data.name }}")))
(button
("class" "camo small")
(text "{{ icon \"external-link\" }}")))
(text "{%- endif %} {% endfor %}")))
(div
("class" "rhs w-full flex flex-col gap-4")
(text "{% block content %}{% endblock %}")))))
(text "{% if not is_self and profile.settings.warning -%}")
(script
(text "setTimeout(() => {
// check for warning
trigger(\"warnings::open\", [
\"{{ profile.id }}\",
\"{{ warning_hash }}\",
\"?warning=true\",
]);
}, 150);"))
(text "{%- endif %} {% if not use_user_theme -%} {{ components::theme(user=profile, theme_preference=profile.settings.profile_theme) }} {%- endif %} {% endblock %}")

View file

@ -1,65 +0,0 @@
{% extends "root.html" %} {% block head %}
<title>{{ profile.username }} (blocked) - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav() }}
<main class="flex flex-col gap-2">
<div class="card-nest">
<div class="card small flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
{{ components::avatar(username=profile.username, size="24px") }}
<span>{{ profile.username }}</span>
</div>
<b class="notification chip"
>{{ text "auth:label.blocked_profile" }}</b
>
</div>
<div class="card flex flex-col gap-2">
<span>{{ text "auth:label.blocked_profile_message" }}</span>
<div class="card w-full secondary flex gap-2">
{% if user -%} {% if not is_blocking -%}
<button onclick="toggle_block_user()" class="quaternary red">
{{ icon "shield" }}
<span>{{ text "auth:action.block" }}</span>
</button>
{% else %}
<button onclick="toggle_block_user()" class="quaternary red">
{{ icon "shield-off" }}
<span>{{ text "auth:action.unblock" }}</span>
</button>
{%- endif %}
<script>
globalThis.toggle_block_user = async () => {
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this?",
]))
) {
return;
}
fetch("/api/v1/auth/user/{{ profile.id }}/block", {
method: "POST",
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
</script>
{%- endif %}
<a href="/" class="button red quaternary">
{{ icon "x" }}
<span>{{ text "general:action.back" }}</span>
</a>
</div>
</div>
</div>
</main>
{% endblock %}

View file

@ -0,0 +1,70 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "{{ profile.username }} (blocked) - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
(div
("class" "card-nest")
(div
("class" "card small flex items-center justify-between gap-2")
(div
("class" "flex items-center gap-2")
(text "{{ components::avatar(username=profile.username, size=\"24px\") }}")
(span
(text "{{ profile.username }}")))
(b
("class" "notification chip")
(text "{{ text \"auth:label.blocked_profile\" }}")))
(div
("class" "card flex flex-col gap-2")
(span
(text "{{ text \"auth:label.blocked_profile_message\" }}"))
(div
("class" "card w-full secondary flex gap-2")
(text "{% if user -%} {% if not is_blocking -%}")
(button
("onclick" "toggle_block_user()")
("class" "quaternary red")
(text "{{ icon \"shield\" }}")
(span
(text "{{ text \"auth:action.block\" }}")))
(text "{% else %}")
(button
("onclick" "toggle_block_user()")
("class" "quaternary red")
(text "{{ icon \"shield-off\" }}")
(span
(text "{{ text \"auth:action.unblock\" }}")))
(text "{%- endif %}")
(script
(text "globalThis.toggle_block_user = async () => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(\"/api/v1/auth/user/{{ profile.id }}/block\", {
method: \"POST\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};"))
(text "{%- endif %}")
(a
("href" "/")
("class" "button red quaternary")
(text "{{ icon \"x\" }}")
(span
(text "{{ text \"general:action.back\" }}")))))))
(text "{% endblock %}")

View file

@ -1,27 +0,0 @@
{% extends "profile/base.html" %} {% block content %}
<div class="card-nest">
<div class="card small flex gap-2 items-center">
{{ icon "users-round" }}
<span>{{ text "auth:label.followers" }}</span>
</div>
<div class="card flex flex-wrap gap-4 flex-collapse">
<!-- prettier-ignore -->
{% for item in list %}
{{ components::user_plate(user=item[1], secondary=true) }}
{% endfor %} {{ components::pagination(page=page, items=list|length) }}
</div>
</div>
<style>
.user_plate {
width: calc(50% - 0.5rem);
}
@media screen and (max-width: 900px) {
.user_plate {
width: 100%;
}
}
</style>
{% endblock %}

View file

@ -0,0 +1,24 @@
(text "{% extends \"profile/base.html\" %} {% block content %}")
(div
("class" "card-nest")
(div
("class" "card small flex gap-2 items-center")
(text "{{ icon \"users-round\" }}")
(span
(text "{{ text \"auth:label.followers\" }}")))
(div
("class" "card flex flex-wrap gap-4 flex-collapse")
(text "{% for item in list %} {{ components::user_plate(user=item[1], secondary=true) }} {% endfor %} {{ components::pagination(page=page, items=list|length) }}")))
(style
(text ".user_plate {
width: calc(50% - 0.5rem);
}
@media screen and (max-width: 900px) {
.user_plate {
width: 100%;
}
}"))
(text "{% endblock %}")

View file

@ -1,27 +0,0 @@
{% extends "profile/base.html" %} {% block content %}
<div class="card-nest">
<div class="card small flex gap-2 items-center">
{{ icon "users-round" }}
<span>{{ text "auth:label.following" }}</span>
</div>
<div class="card flex flex-wrap gap-4 flex-collapse">
<!-- prettier-ignore -->
{% for item in list %}
{{ components::user_plate(user=item[1], secondary=true) }}
{% endfor %} {{ components::pagination(page=page, items=list|length) }}
</div>
</div>
<style>
.user_plate {
width: calc(50% - 0.5rem);
}
@media screen and (max-width: 900px) {
.user_plate {
width: 100%;
}
}
</style>
{% endblock %}

View file

@ -0,0 +1,24 @@
(text "{% extends \"profile/base.html\" %} {% block content %}")
(div
("class" "card-nest")
(div
("class" "card small flex gap-2 items-center")
(text "{{ icon \"users-round\" }}")
(span
(text "{{ text \"auth:label.following\" }}")))
(div
("class" "card flex flex-wrap gap-4 flex-collapse")
(text "{% for item in list %} {{ components::user_plate(user=item[1], secondary=true) }} {% endfor %} {{ components::pagination(page=page, items=list|length) }}")))
(style
(text ".user_plate {
width: calc(50% - 0.5rem);
}
@media screen and (max-width: 900px) {
.user_plate {
width: 100%;
}
}"))
(text "{% endblock %}")

View file

@ -1,42 +0,0 @@
{% extends "profile/base.html" %} {% block content %} {% if
profile.settings.enable_questions and (user or
profile.settings.allow_anonymous_questions) %}
<div style="display: contents">
{{ components::create_question_form(receiver=profile.id,
header=profile.settings.motivational_header) }}
</div>
{%- endif %} {{ macros::profile_nav(selected="media") }}
<div class="card-nest">
<div class="card small flex gap-2 justify-between items-center">
<div class="flex gap-2 items-center">
{{ icon "clock" }}
<span>{{ text "auth:label.recent_posts_with_media" }}</span>
</div>
{% if user -%}
<a
href="/search?profile={{ profile.id }}"
class="button quaternary small"
>
{{ icon "search" }}
<span>{{ text "general:link.search" }}</span>
</a>
{%- endif %}
</div>
<div class="card flex flex-col gap-4">
<!-- prettier-ignore -->
{% for post in posts %}
{% if post[2].read_access == "Everybody" -%}
{% if post[0].context.repost and post[0].context.repost.reposting -%}
{{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }}
{% else %}
{{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self) }}
{%- endif %}
{%- endif %}
{% endfor %}
{{ components::pagination(page=page, items=posts|length) }}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,28 @@
(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
(div
("style" "display: contents")
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header) }}"))
(text "{%- endif %} {{ macros::profile_nav(selected=\"media\") }}")
(div
("class" "card-nest")
(div
("class" "card small flex gap-2 justify-between items-center")
(div
("class" "flex gap-2 items-center")
(text "{{ icon \"clock\" }}")
(span
(text "{{ text \"auth:label.recent_posts_with_media\" }}")))
(text "{% if user -%}")
(a
("href" "/search?profile={{ profile.id }}")
("class" "button quaternary small")
(text "{{ icon \"search\" }}")
(span
(text "{{ text \"general:link.search\" }}")))
(text "{%- endif %}"))
(div
("class" "card flex flex-col gap-4")
(text "{% for post in posts %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self) }} {%- endif %} {%- endif %} {% endfor %} {{ components::pagination(page=page, items=posts|length) }}")))
(text "{% endblock %}")

View file

@ -1,67 +0,0 @@
{% extends "profile/base.html" %} {% block content %} {% if
profile.settings.enable_questions and (user or
profile.settings.allow_anonymous_questions) %}
<div style="display: contents">
{{ components::create_question_form(receiver=profile.id,
header=profile.settings.motivational_header) }}
</div>
{%- endif %} {% if not tag and pinned|length != 0 -%}
<div class="card-nest">
<div class="card small flex gap-2 items-center">
{{ icon "pin" }}
<span>{{ text "communities:label.pinned" }}</span>
</div>
<div class="card flex flex-col gap-4">
<!-- prettier-ignore -->
{% for post in pinned %}
{% if post[2].read_access == "Everybody" -%}
{% if post[0].context.repost and post[0].context.repost.reposting -%}
{{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }}
{% else %}
{{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self) }}
{%- endif %}
{%- endif %}
{% endfor %}
</div>
</div>
{%- endif %} {{ macros::profile_nav(selected="posts") }}
<div class="card-nest">
<div class="card small flex gap-2 justify-between items-center">
<div class="flex gap-2 items-center">
{% if not tag -%} {{ icon "clock" }}
<span>{{ text "auth:label.recent_posts" }}</span>
{% else %} {{ icon "tag" }}
<span
>{{ text "auth:label.recent_with_tag" }}: <b>{{ tag }}</b></span
>
{%- endif %}
</div>
{% if user -%}
<a
href="/search?profile={{ profile.id }}"
class="button quaternary small"
>
{{ icon "search" }}
<span>{{ text "general:link.search" }}</span>
</a>
{%- endif %}
</div>
<div class="card flex flex-col gap-4">
<!-- prettier-ignore -->
{% for post in posts %}
{% if post[2].read_access == "Everybody" -%}
{% if post[0].context.repost and post[0].context.repost.reposting -%}
{{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }}
{% else %}
{{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self) }}
{%- endif %}
{%- endif %}
{% endfor %}
{{ components::pagination(page=page, items=posts|length, key="&tag=", value=tag) }}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,46 @@
(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
(div
("style" "display: contents")
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header) }}"))
(text "{%- endif %} {% if not tag and pinned|length != 0 -%}")
(div
("class" "card-nest")
(div
("class" "card small flex gap-2 items-center")
(text "{{ icon \"pin\" }}")
(span
(text "{{ text \"communities:label.pinned\" }}")))
(div
("class" "card flex flex-col gap-4")
(text "{% for post in pinned %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self) }} {%- endif %} {%- endif %} {% endfor %}")))
(text "{%- endif %} {{ macros::profile_nav(selected=\"posts\") }}")
(div
("class" "card-nest")
(div
("class" "card small flex gap-2 justify-between items-center")
(div
("class" "flex gap-2 items-center")
(text "{% if not tag -%} {{ icon \"clock\" }}")
(span
(text "{{ text \"auth:label.recent_posts\" }}"))
(text "{% else %} {{ icon \"tag\" }}")
(span
(text "{{ text \"auth:label.recent_with_tag\" }}:")
(b
(text "{{ tag }}")))
(text "{%- endif %}"))
(text "{% if user -%}")
(a
("href" "/search?profile={{ profile.id }}")
("class" "button quaternary small")
(text "{{ icon \"search\" }}")
(span
(text "{{ text \"general:link.search\" }}")))
(text "{%- endif %}"))
(div
("class" "card flex flex-col gap-4")
(text "{% for post in posts %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self) }} {%- endif %} {%- endif %} {% endfor %} {{ components::pagination(page=page, items=posts|length, key=\"&tag=\", value=tag) }}")))
(text "{% endblock %}")

View file

@ -1,162 +0,0 @@
{% extends "root.html" %} {% block head %}
<title>{{ profile.username }} (private profile) - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav() }}
<main class="flex flex-col gap-2">
<div class="card-nest">
<div class="card small flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
{{ components::avatar(username=profile.username, size="24px") }}
<span>{{ profile.username }}</span>
</div>
<b class="notification chip"
>{{ text "auth:label.private_profile" }}</b
>
</div>
<div class="card flex flex-col gap-2">
<span>{{ text "auth:label.private_profile_message" }}</span>
<div class="card w-full secondary flex gap-2">
{% if user -%} {% if not is_following -%}
<button
onclick="toggle_follow_user(event)"
class="{% if follow_requested -%} hidden{%- endif %}"
atto_tag="user.follow_request"
>
{{ icon "user-plus" }}
<span>{{ text "auth:action.request_to_follow" }}</span>
</button>
<button
onclick="cancel_follow_user(event)"
class="quaternary red{% if not follow_requested -%} hidden{%- endif %}"
atto_tag="user.cancel_request"
>
{{ icon "user-minus" }}
<span>{{ text "auth:action.cancel_follow_request" }}</span>
</button>
{% else %}
<button
onclick="toggle_follow_user(event)"
class="quaternary red"
atto_tag="user.unfollow"
>
{{ icon "user-minus" }}
<span>{{ text "auth:action.unfollow" }}</span>
</button>
{%- endif %} {% if not is_blocking -%}
<button onclick="toggle_block_user()" class="quaternary red">
{{ icon "shield" }}
<span>{{ text "auth:action.block" }}</span>
</button>
{% else %}
<button onclick="toggle_block_user()" class="quaternary red">
{{ icon "shield-off" }}
<span>{{ text "auth:action.unblock" }}</span>
</button>
{%- endif %}
<script>
globalThis.toggle_follow_user = async (e) => {
await trigger("atto::debounce", ["users::follow"]);
fetch("/api/v1/auth/user/{{ profile.id }}/follow", {
method: "POST",
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
if (
e.target.getAttribute("atto_tag") ===
"user.follow_request"
) {
document
.querySelector(
'[atto_tag="user.follow_request"]',
)
.classList.add("hidden");
document
.querySelector(
'[atto_tag="user.cancel_request"]',
)
.classList.remove("hidden");
} else {
window.location.reload();
}
});
};
globalThis.cancel_follow_user = async (e) => {
await trigger("atto::debounce", ["users::follow"]);
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this?",
]))
) {
return;
}
fetch(
"/api/v1/auth/user/{{ profile.id }}/follow/cancel",
{
method: "POST",
},
)
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
document
.querySelector(
'[atto_tag="user.cancel_request"]',
)
.classList.add("hidden");
document
.querySelector(
'[atto_tag="user.follow_request"]',
)
.classList.remove("hidden");
});
};
globalThis.toggle_block_user = async () => {
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this?",
]))
) {
return;
}
fetch("/api/v1/auth/user/{{ profile.id }}/block", {
method: "POST",
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
</script>
{%- endif %}
<a href="/" class="button red quaternary">
{{ icon "x" }}
<span>{{ text "general:action.back" }}</span>
</a>
</div>
</div>
</div>
</main>
{% endblock %}

View file

@ -0,0 +1,163 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "{{ profile.username }} (private profile) - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
(div
("class" "card-nest")
(div
("class" "card small flex items-center justify-between gap-2")
(div
("class" "flex items-center gap-2")
(text "{{ components::avatar(username=profile.username, size=\"24px\") }}")
(span
(text "{{ profile.username }}")))
(b
("class" "notification chip")
(text "{{ text \"auth:label.private_profile\" }}")))
(div
("class" "card flex flex-col gap-2")
(span
(text "{{ text \"auth:label.private_profile_message\" }}"))
(div
("class" "card w-full secondary flex gap-2")
(text "{% if user -%} {% if not is_following -%}")
(button
("onclick" "toggle_follow_user(event)")
("class" "{% if follow_requested -%} hidden{%- endif %}")
("atto_tag" "user.follow_request")
(text "{{ icon \"user-plus\" }}")
(span
(text "{{ text \"auth:action.request_to_follow\" }}")))
(button
("onclick" "cancel_follow_user(event)")
("class" "quaternary red{% if not follow_requested -%} hidden{%- endif %}")
("atto_tag" "user.cancel_request")
(text "{{ icon \"user-minus\" }}")
(span
(text "{{ text \"auth:action.cancel_follow_request\" }}")))
(text "{% else %}")
(button
("onclick" "toggle_follow_user(event)")
("class" "quaternary red")
("atto_tag" "user.unfollow")
(text "{{ icon \"user-minus\" }}")
(span
(text "{{ text \"auth:action.unfollow\" }}")))
(text "{%- endif %} {% if not is_blocking -%}")
(button
("onclick" "toggle_block_user()")
("class" "quaternary red")
(text "{{ icon \"shield\" }}")
(span
(text "{{ text \"auth:action.block\" }}")))
(text "{% else %}")
(button
("onclick" "toggle_block_user()")
("class" "quaternary red")
(text "{{ icon \"shield-off\" }}")
(span
(text "{{ text \"auth:action.unblock\" }}")))
(text "{%- endif %}")
(script
(text "globalThis.toggle_follow_user = async (e) => {
await trigger(\"atto::debounce\", [\"users::follow\"]);
fetch(\"/api/v1/auth/user/{{ profile.id }}/follow\", {
method: \"POST\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (
e.target.getAttribute(\"atto_tag\") ===
\"user.follow_request\"
) {
document
.querySelector(
'[atto_tag=\"user.follow_request\"]',
)
.classList.add(\"hidden\");
document
.querySelector(
'[atto_tag=\"user.cancel_request\"]',
)
.classList.remove(\"hidden\");
} else {
window.location.reload();
}
});
};
globalThis.cancel_follow_user = async (e) => {
await trigger(\"atto::debounce\", [\"users::follow\"]);
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(
\"/api/v1/auth/user/{{ profile.id }}/follow/cancel\",
{
method: \"POST\",
},
)
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
document
.querySelector(
'[atto_tag=\"user.cancel_request\"]',
)
.classList.add(\"hidden\");
document
.querySelector(
'[atto_tag=\"user.follow_request\"]',
)
.classList.remove(\"hidden\");
});
};
globalThis.toggle_block_user = async () => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(\"/api/v1/auth/user/{{ profile.id }}/block\", {
method: \"POST\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};"))
(text "{%- endif %}")
(a
("href" "/")
("class" "button red quaternary")
(text "{{ icon \"x\" }}")
(span
(text "{{ text \"general:action.back\" }}")))))))
(text "{% endblock %}")

View file

@ -1,42 +0,0 @@
{% extends "profile/base.html" %} {% block content %} {% if
profile.settings.enable_questions and (user or
profile.settings.allow_anonymous_questions) %}
<div style="display: contents">
{{ components::create_question_form(receiver=profile.id,
header=profile.settings.motivational_header) }}
</div>
{%- endif %} {{ macros::profile_nav(selected="replies") }}
<div class="card-nest">
<div class="card small flex gap-2 justify-between items-center">
<div class="flex gap-2 items-center">
{{ icon "clock" }}
<span>{{ text "auth:label.recent_replies" }}</span>
</div>
{% if user -%}
<a
href="/search?profile={{ profile.id }}"
class="button quaternary small"
>
{{ icon "search" }}
<span>{{ text "general:link.search" }}</span>
</a>
{%- endif %}
</div>
<div class="card flex flex-col gap-4">
<!-- prettier-ignore -->
{% for post in posts %}
{% if post[2].read_access == "Everybody" -%}
{% if post[0].context.repost and post[0].context.repost.reposting -%}
{{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }}
{% else %}
{{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self) }}
{%- endif %}
{%- endif %}
{% endfor %}
{{ components::pagination(page=page, items=posts|length) }}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,28 @@
(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
(div
("style" "display: contents")
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header) }}"))
(text "{%- endif %} {{ macros::profile_nav(selected=\"replies\") }}")
(div
("class" "card-nest")
(div
("class" "card small flex gap-2 justify-between items-center")
(div
("class" "flex gap-2 items-center")
(text "{{ icon \"clock\" }}")
(span
(text "{{ text \"auth:label.recent_replies\" }}")))
(text "{% if user -%}")
(a
("href" "/search?profile={{ profile.id }}")
("class" "button quaternary small")
(text "{{ icon \"search\" }}")
(span
(text "{{ text \"general:link.search\" }}")))
(text "{%- endif %}"))
(div
("class" "card flex flex-col gap-4")
(text "{% for post in posts %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self) }} {%- endif %} {%- endif %} {% endfor %} {{ components::pagination(page=page, items=posts|length) }}")))
(text "{% endblock %}")

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,37 +0,0 @@
{% extends "root.html" %} {% block head %}
<title>{{ profile.username }} (warning) - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav() }}
<main class="flex flex-col gap-2">
<div class="card-nest">
<div class="card small flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
{{ components::avatar(username=profile.username, size="24px") }}
<span>{{ profile.username }}</span>
</div>
<b class="notification chip"
>{{ text "auth:label.before_you_view" }}</b
>
</div>
<div class="card flex flex-col gap-2">
<span class="no_p_margin"
>{{ profile.settings.warning|markdown|safe }}</span
>
<div class="card w-full secondary flex gap-2">
<button
onclick="trigger('warnings::accept', ['{{ profile.id }}', '{{ warning_hash }}'])"
>
{{ icon "check" }}
<span>{{ text "dialog:action.continue" }}</span>
</button>
<a href="/" class="button red quaternary">
{{ icon "x" }}
<span>{{ text "general:action.back" }}</span>
</a>
</div>
</div>
</div>
</main>
{% endblock %}

View file

@ -0,0 +1,39 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "{{ profile.username }} (warning) - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
(div
("class" "card-nest")
(div
("class" "card small flex items-center justify-between gap-2")
(div
("class" "flex items-center gap-2")
(text "{{ components::avatar(username=profile.username, size=\"24px\") }}")
(span
(text "{{ profile.username }}")))
(b
("class" "notification chip")
(text "{{ text \"auth:label.before_you_view\" }}")))
(div
("class" "card flex flex-col gap-2")
(span
("class" "no_p_margin")
(text "{{ profile.settings.warning|markdown|safe }}"))
(div
("class" "card w-full secondary flex gap-2")
(button
("onclick" "trigger('warnings::accept', ['{{ profile.id }}', '{{ warning_hash }}'])")
(text "{{ icon \"check\" }}")
(span
(text "{{ text \"dialog:action.continue\" }}")))
(a
("href" "/")
("class" "button red quaternary")
(text "{{ icon \"x\" }}")
(span
(text "{{ text \"general:action.back\" }}")))))))
(text "{% endblock %}")

View file

@ -10,7 +10,7 @@
(meta ("http-equiv" "content-security-policy") ("content" "default-src 'self' blob: *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' blob: *; script-src 'self' 'unsafe-inline' blob: *; object-src 'self' blob: *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' blob: data: *"))
(link ("rel" "icon") ("href" "/public/favicon.svg"))
(link ("rel" "stylesheet") ("href" "/css/style.css"))f
(link ("rel" "stylesheet") ("href" "/css/style.css"))
(text "{% if user -%}
<script>
@ -44,7 +44,7 @@
(meta ("name" "theme-color") ("content" "{{ config.color }}"))
(meta ("name" "description") ("content" "{{ config.description }}"))
(meta ("property" "og:type") ("content" "website"))
(meta ("property" "og:site_name") ("content" "{{ config.color }}"))
(meta ("property" "og:site_name") ("content" "{{ config.name }}"))
(meta ("name" "turbo-prefetch") ("content" "false"))
(meta ("name" "turbo-refresh-method") ("content" "morph"))

View file

@ -1,93 +0,0 @@
{% extends "root.html" %} {% block head %}
<title>My stacks - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav() }}
<main class="flex flex-col gap-2">
{{ macros::timelines_nav(selected="stacks") }} {% if user -%}
<div class="card-nest">
<div class="card small">
<b>{{ text "stacks:label.create_new" }}</b>
</div>
<form
class="card flex flex-col gap-2"
onsubmit="create_stack_from_form(event)"
>
<div class="flex flex-col gap-1">
<label for="name">{{ text "communities:label.name" }}</label>
<input
type="text"
name="name"
id="name"
placeholder="name"
required
minlength="2"
maxlength="32"
/>
</div>
<button class="primary">
{{ text "communities:action.create" }}
</button>
</form>
</div>
{%- endif %}
<div class="card-nest w-full">
<div class="card small flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
{{ icon "award" }}
<span>{{ text "stacks:label.my_stacks" }}</span>
</div>
</div>
<div class="card flex flex-col gap-2">
{% for item in list %}
<a
href="/stacks/{{ item.id }}"
class="card secondary flex flex-col gap-2"
>
<div class="flex items-center gap-2">
{{ icon "list" }}
<b>{{ item.name }}</b>
</div>
<span
>Created <span class="date">{{ item.created }}</span>; {{
item.privacy }}; {{ item.users|length }} users</span
>
</a>
{% endfor %}
</div>
</div>
</main>
<script>
async function create_stack_from_form(e) {
e.preventDefault();
await trigger("atto::debounce", ["stacks::create"]);
fetch("/api/v1/stacks", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: e.target.name.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
if (res.ok) {
setTimeout(() => {
window.location.href = `/stacks/${res.payload}`;
}, 100);
}
});
}
</script>
{% endblock %}

View file

@ -0,0 +1,93 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "My stacks - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
(text "{{ macros::timelines_nav(selected=\"stacks\") }} {% if user -%}")
(div
("class" "card-nest")
(div
("class" "card small")
(b
(text "{{ text \"stacks:label.create_new\" }}")))
(form
("class" "card flex flex-col gap-2")
("onsubmit" "create_stack_from_form(event)")
(div
("class" "flex flex-col gap-1")
(label
("for" "name")
(text "{{ text \"communities:label.name\" }}"))
(input
("type" "text")
("name" "name")
("id" "name")
("placeholder" "name")
("required" "")
("minlength" "2")
("maxlength" "32")))
(button
("class" "primary")
(text "{{ text \"communities:action.create\" }}"))))
(text "{%- endif %}")
(div
("class" "card-nest w-full")
(div
("class" "card small flex items-center justify-between gap-2")
(div
("class" "flex items-center gap-2")
(text "{{ icon \"award\" }}")
(span
(text "{{ text \"stacks:label.my_stacks\" }}"))))
(div
("class" "card flex flex-col gap-2")
(text "{% for item in list %}")
(a
("href" "/stacks/{{ item.id }}")
("class" "card secondary flex flex-col gap-2")
(div
("class" "flex items-center gap-2")
(text "{{ icon \"list\" }}")
(b
(text "{{ item.name }}")))
(span
(text "Created")
(span
("class" "date")
(text "{{ item.created }}"))
(text "; {{
item.privacy }}; {{ item.users|length }} users")))
(text "{% endfor %}"))))
(script
(text "async function create_stack_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"stacks::create\"]);
fetch(\"/api/v1/stacks\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
name: e.target.name.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
setTimeout(() => {
window.location.href = `/stacks/${res.payload}`;
}, 100);
}
});
}"))
(text "{% endblock %}")

View file

@ -1,325 +0,0 @@
{% extends "root.html" %} {% block head %}
<title>Stack settings - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav() }}
<main class="flex flex-col gap-2">
<div class="pillmenu">
<a href="#/general" data-tab-button="general" class="active">
{{ icon "settings" }}
<span>{{ text "stacks:tab.general" }}</span>
</a>
<a href="#/users" data-tab-button="users">
{{ icon "users" }}
<span>{{ text "stacks:tab.users" }}</span>
</a>
</div>
<div class="w-full flex flex-col gap-2" data-tab="general">
<div id="manage_fields" class="card tertiary flex flex-col gap-2">
<div class="card-nest" ui_ident="privacy">
<div class="card small">
<b>Privacy</b>
</div>
<div class="card">
<select onchange="save_privacy(event)">
<option
value="Private"
selected="{% if stack.privacy == 'Private' -%}true{% else %}false{%- endif %}"
>
Private
</option>
<option
value="Public"
selected="{% if stack.privacy == 'Public' -%}true{% else %}false{%- endif %}"
>
Public
</option>
</select>
</div>
</div>
<div class="card-nest" ui_ident="mode">
<div class="card small">
<b>Mode</b>
</div>
<div class="card">
<select onchange="save_mode(event)">
<option
value="Include"
selected="{% if stack.mode == 'Include' -%}true{% else %}false{%- endif %}"
>
Include
</option>
<option
value="Exclude"
selected="{% if stack.mode == 'Exclude' -%}true{% else %}false{%- endif %}"
>
Exclude
</option>
</select>
</div>
</div>
<div class="card-nest" ui_ident="sort">
<div class="card small">
<b>Sort</b>
</div>
<div class="card">
<select onchange="save_sort(event)">
<option
value="Created"
selected="{% if stack.sort == 'Created' -%}true{% else %}false{%- endif %}"
>
Created
</option>
<option
value="Likes"
selected="{% if stack.sort == 'Likes' -%}true{% else %}false{%- endif %}"
>
Likes
</option>
</select>
</div>
</div>
<div class="card-nest" ui_ident="change_name">
<div class="card small">
<b>{{ text "stacks:label.change_name" }}</b>
</div>
<form
class="card flex flex-col gap-2"
onsubmit="change_name(event)"
>
<div class="flex flex-col gap-1">
<label for="new_title"
>{{ text "communities:label.name" }}</label
>
<input
type="text"
name="name"
id="name"
placeholder="name"
required
minlength="2"
/>
</div>
<button class="primary">
{{ icon "check" }}
<span>{{ text "general:action.save" }}</span>
</button>
</form>
</div>
</div>
<div class="card-nest" ui_ident="danger_zone">
<div class="card small flex gap-1 items-center red">
{{ icon "skull" }}
<b> {{ text "communities:label.danger_zone" }} </b>
</div>
<div class="card flex flex-wrap gap-2">
<button class="red quaternary" onclick="delete_stack()">
{{ icon "trash" }}
<span>{{ text "general:action.delete" }}</span>
</button>
</div>
</div>
</div>
<div class="card w-full flex flex-col gap-2 hidden" data-tab="users">
<button onclick="add_user()">
{{ icon "plus" }}
<span>{{ text "stacks:label.add_user" }}</span>
</button>
{% for user in users %}
<div
class="card secondary flex flex-wrap gap-2 items-center justify-between"
>
<div class="flex gap-2">
{{ components::avatar(username=user.username) }} {{
components::full_username(user=user) }}
</div>
<button
class="quaternary small red"
onclick="remove_user('{{ user.username }}')"
>
{{ icon "x" }}
<span>{{ text "stacks:label.remove" }}</span>
</button>
</div>
{% endfor %}
</div>
<div class="flex gap-2 flex-wrap">
<a href="/stacks/{{ stack.id }}" class="button secondary">
{{ icon "arrow-left" }}
<span>{{ text "general:action.back" }}</span>
</a>
</div>
</main>
<script>
globalThis.add_user = async () => {
await trigger("atto::debounce", ["stacks::add_user"]);
const username = await trigger("atto::prompt", ["Username:"]);
if (!username) {
return;
}
fetch(`/api/v1/stacks/{{ stack.id }}/users`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username,
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
globalThis.remove_user = async (username) => {
await trigger("atto::debounce", ["stacks::remove_user"]);
fetch(`/api/v1/stacks/{{ stack.id }}/users`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username,
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
globalThis.save_privacy = (event, mode) => {
const selected = event.target.selectedOptions[0];
fetch(`/api/v1/stacks/{{ stack.id }}/privacy`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
privacy: selected.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
globalThis.save_mode = (event, mode) => {
const selected = event.target.selectedOptions[0];
fetch(`/api/v1/stacks/{{ stack.id }}/mode`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
mode: selected.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
globalThis.save_sort = (event, mode) => {
const selected = event.target.selectedOptions[0];
fetch(`/api/v1/stacks/{{ stack.id }}/sort`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
sort: selected.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
globalThis.change_name = async (e) => {
e.preventDefault();
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this?",
]))
) {
return;
}
fetch("/api/v1/stacks/{{ stack.id }}/name", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: e.target.name.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
globalThis.delete_stack = async () => {
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this? This action is permanent.",
]))
) {
return;
}
fetch("/api/v1/stacks/{{ stack.id }}", {
method: "DELETE",
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
</script>
{% endblock %}

View file

@ -0,0 +1,316 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Stack settings - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
(div
("class" "pillmenu")
(a
("href" "#/general")
("data-tab-button" "general")
("class" "active")
(text "{{ icon \"settings\" }}")
(span
(text "{{ text \"stacks:tab.general\" }}")))
(a
("href" "#/users")
("data-tab-button" "users")
(text "{{ icon \"users\" }}")
(span
(text "{{ text \"stacks:tab.users\" }}"))))
(div
("class" "w-full flex flex-col gap-2")
("data-tab" "general")
(div
("id" "manage_fields")
("class" "card tertiary flex flex-col gap-2")
(div
("class" "card-nest")
("ui_ident" "privacy")
(div
("class" "card small")
(b
(text "Privacy")))
(div
("class" "card")
(select
("onchange" "save_privacy(event)")
(option
("value" "Private")
("selected" "{% if stack.privacy == 'Private' -%}true{% else %}false{%- endif %}")
(text "Private"))
(option
("value" "Public")
("selected" "{% if stack.privacy == 'Public' -%}true{% else %}false{%- endif %}")
(text "Public")))))
(div
("class" "card-nest")
("ui_ident" "mode")
(div
("class" "card small")
(b
(text "Mode")))
(div
("class" "card")
(select
("onchange" "save_mode(event)")
(option
("value" "Include")
("selected" "{% if stack.mode == 'Include' -%}true{% else %}false{%- endif %}")
(text "Include"))
(option
("value" "Exclude")
("selected" "{% if stack.mode == 'Exclude' -%}true{% else %}false{%- endif %}")
(text "Exclude")))))
(div
("class" "card-nest")
("ui_ident" "sort")
(div
("class" "card small")
(b
(text "Sort")))
(div
("class" "card")
(select
("onchange" "save_sort(event)")
(option
("value" "Created")
("selected" "{% if stack.sort == 'Created' -%}true{% else %}false{%- endif %}")
(text "Created"))
(option
("value" "Likes")
("selected" "{% if stack.sort == 'Likes' -%}true{% else %}false{%- endif %}")
(text "Likes")))))
(div
("class" "card-nest")
("ui_ident" "change_name")
(div
("class" "card small")
(b
(text "{{ text \"stacks:label.change_name\" }}")))
(form
("class" "card flex flex-col gap-2")
("onsubmit" "change_name(event)")
(div
("class" "flex flex-col gap-1")
(label
("for" "new_title")
(text "{{ text \"communities:label.name\" }}"))
(input
("type" "text")
("name" "name")
("id" "name")
("placeholder" "name")
("required" "")
("minlength" "2")))
(button
("class" "primary")
(text "{{ icon \"check\" }}")
(span
(text "{{ text \"general:action.save\" }}"))))))
(div
("class" "card-nest")
("ui_ident" "danger_zone")
(div
("class" "card small flex gap-1 items-center red")
(text "{{ icon \"skull\" }}")
(b
(text "{{ text \"communities:label.danger_zone\" }}")))
(div
("class" "card flex flex-wrap gap-2")
(button
("class" "red quaternary")
("onclick" "delete_stack()")
(text "{{ icon \"trash\" }}")
(span
(text "{{ text \"general:action.delete\" }}"))))))
(div
("class" "card w-full flex flex-col gap-2 hidden")
("data-tab" "users")
(button
("onclick" "add_user()")
(text "{{ icon \"plus\" }}")
(span
(text "{{ text \"stacks:label.add_user\" }}")))
(text "{% for user in users %}")
(div
("class" "card secondary flex flex-wrap gap-2 items-center justify-between")
(div
("class" "flex gap-2")
(text "{{ components::avatar(username=user.username) }} {{ components::full_username(user=user) }}"))
(button
("class" "quaternary small red")
("onclick" "remove_user('{{ user.username }}')")
(text "{{ icon \"x\" }}")
(span
(text "{{ text \"stacks:label.remove\" }}"))))
(text "{% endfor %}"))
(div
("class" "flex gap-2 flex-wrap")
(a
("href" "/stacks/{{ stack.id }}")
("class" "button secondary")
(text "{{ icon \"arrow-left\" }}")
(span
(text "{{ text \"general:action.back\" }}")))))
(script
(text "globalThis.add_user = async () => {
await trigger(\"atto::debounce\", [\"stacks::add_user\"]);
const username = await trigger(\"atto::prompt\", [\"Username:\"]);
if (!username) {
return;
}
fetch(`/api/v1/stacks/{{ stack.id }}/users`, {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
username,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
globalThis.remove_user = async (username) => {
await trigger(\"atto::debounce\", [\"stacks::remove_user\"]);
fetch(`/api/v1/stacks/{{ stack.id }}/users`, {
method: \"DELETE\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
username,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
globalThis.save_privacy = (event, mode) => {
const selected = event.target.selectedOptions[0];
fetch(`/api/v1/stacks/{{ stack.id }}/privacy`, {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
privacy: selected.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
globalThis.save_mode = (event, mode) => {
const selected = event.target.selectedOptions[0];
fetch(`/api/v1/stacks/{{ stack.id }}/mode`, {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
mode: selected.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
globalThis.save_sort = (event, mode) => {
const selected = event.target.selectedOptions[0];
fetch(`/api/v1/stacks/{{ stack.id }}/sort`, {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
sort: selected.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
globalThis.change_name = async (e) => {
e.preventDefault();
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(\"/api/v1/stacks/{{ stack.id }}/name\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
name: e.target.name.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
globalThis.delete_stack = async () => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this? This action is permanent.\",
]))
) {
return;
}
fetch(\"/api/v1/stacks/{{ stack.id }}\", {
method: \"DELETE\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};"))
(text "{% endblock %}")

View file

@ -1,74 +0,0 @@
{% extends "root.html" %} {% block head %}
<title>{{ stack.name }} - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav() }}
<main class="flex flex-col gap-2">
{{ macros::timelines_nav(selected="stacks") }}
<div class="card-nest w-full">
<div class="card small flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
{{ icon "list" }}
<span>{{ stack.name }}</span>
</div>
{% if user and user.id == stack.owner -%}
<a
href="/stacks/{{ stack.id }}/manage"
class="button quaternary small"
>
{{ icon "pencil" }}
<span>{{ text "general:action.manage" }}</span>
</a>
{%- endif %}
</div>
<!-- prettier-ignore -->
<div class="card w-full flex flex-col gap-2">
{% if list|length == 0 -%}
<p>No posts yet! Maybe <a href="/stacks/{{ stack.id }}/manage#/users">add a user to this stack</a>!</p>
{%- endif %}
{% for post in list %}
{% if post[2].read_access == "Everybody" -%}
{% if post[0].context.repost and post[0].context.repost.reposting -%}
{{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true) }}
{% else %}
{{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2]) }}
{%- endif %}
{%- endif %}
{% endfor %}
{{ components::pagination(page=page, items=list|length) }}
</div>
</div>
</main>
<script>
async function create_stack_from_form(e) {
e.preventDefault();
await trigger("atto::debounce", ["stacks::create"]);
fetch("/api/v1/stacks", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: e.target.name.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
if (res.ok) {
setTimeout(() => {
window.location.href = `/stacks/${res.payload}`;
}, 100);
}
});
}
</script>
{% endblock %}

View file

@ -0,0 +1,37 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "{{ stack.name }} - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
(text "{{ macros::timelines_nav(selected=\"stacks\") }}")
(div
("class" "card-nest w-full")
(div
("class" "card small flex items-center justify-between gap-2")
(div
("class" "flex items-center gap-2")
(text "{{ icon \"list\" }}")
(span
(text "{{ stack.name }}")))
(text "{% if user and user.id == stack.owner -%}")
(a
("href" "/stacks/{{ stack.id }}/manage")
("class" "button quaternary small")
(text "{{ icon \"pencil\" }}")
(span
(text "{{ text \"general:action.manage\" }}")))
(text "{%- endif %}"))
(div
("class" "card w-full flex flex-col gap-2")
(text "{% if list|length == 0 -%}")
(p
(text "No posts yet! Maybe ")
(a
("href" "/stacks/{{ stack.id }}/manage#/users")
(text "add a user to this stack"))
(text "!"))
(text "{%- endif %} {% for post in list %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2]) }} {%- endif %} {%- endif %} {% endfor %} {{ components::pagination(page=page, items=list|length) }}"))))
(text "{% endblock %}")

View file

@ -1,43 +0,0 @@
{% extends "root.html" %} {% block head %}
<title>Latest posts - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav() }}
<main class="flex flex-col gap-2">
{{ macros::timelines_nav(selected="all") }} {{
macros::timelines_secondary_nav(posts="/all", questions="/all/questions") }}
{% if not user -%}
<div class="card-nest">
<div class="card small flex items-center gap-2">
{{ icon "heart" }}
<b>{{ text "general:label.better_with_account" }}</b>
</div>
<div class="card flex gap-2">
<a href="/auth/login" class="button">
{{ icon "log-in" }}
<span>{{ text "auth:action.login" }}</span>
</a>
<a href="/auth/register" class="button secondary">
{{ icon "user-plus" }}
<span>{{ text "auth:action.register" }}</span>
</a>
</div>
</div>
{%- endif %}
<!-- prettier-ignore -->
<div class="card w-full flex flex-col gap-2">
{% for post in list %}
{% if post[2].read_access == "Everybody" -%}
{% if post[0].context.repost and post[0].context.repost.reposting -%}
{{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true) }}
{% else %}
{{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2]) }}
{%- endif %}
{%- endif %}
{% endfor %}
{{ components::pagination(page=page, items=list|length) }}
</div>
</main>
{% endblock %}

View file

@ -0,0 +1,35 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Latest posts - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
(text "{{ macros::timelines_nav(selected=\"all\") }} {{ macros::timelines_secondary_nav(posts=\"/all\", questions=\"/all/questions\") }} {% if not user -%}")
(div
("class" "card-nest")
(div
("class" "card small flex items-center gap-2")
(text "{{ icon \"heart\" }}")
(b
(text "{{ text \"general:label.better_with_account\" }}")))
(div
("class" "card flex gap-2")
(a
("href" "/auth/login")
("class" "button")
(text "{{ icon \"log-in\" }}")
(span
(text "{{ text \"auth:action.login\" }}")))
(a
("href" "/auth/register")
("class" "button secondary")
(text "{{ icon \"user-plus\" }}")
(span
(text "{{ text \"auth:action.register\" }}")))))
(text "{%- endif %}")
(div
("class" "card w-full flex flex-col gap-2")
(text "{% for post in list %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2]) }} {%- endif %} {%- endif %} {% endfor %} {{ components::pagination(page=page, items=list|length) }}")))
(text "{% endblock %}")

View file

@ -1,18 +0,0 @@
{% extends "root.html" %} {% block head %}
<title>Latest questions - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav() }}
<main class="flex flex-col gap-2">
{{ macros::timelines_nav(selected="all") }} {{
macros::timelines_secondary_nav(posts="/all", questions="/all/questions",
selected="questions") }}
<!-- prettier-ignore -->
<div class="card w-full flex flex-col gap-2">
{% for question in list %}
{{ components::global_question(question=question, can_manage_questions=false, secondary=true) }}
{% endfor %}
{{ components::pagination(page=page, items=list|length) }}
</div>
</main>
{% endblock %}

View file

@ -0,0 +1,13 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Latest questions - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
("class" "flex flex-col gap-2")
(text "{{ macros::timelines_nav(selected=\"all\") }} {{ macros::timelines_secondary_nav(posts=\"/all\", questions=\"/all/questions\", selected=\"questions\") }}")
(div
("class" "card w-full flex flex-col gap-2")
(text "{% for question in list %} {{ components::global_question(question=question, can_manage_questions=false, secondary=true) }} {% endfor %} {{ components::pagination(page=page, items=list|length) }}")))
(text "{% endblock %}")

View file

@ -1,24 +0,0 @@
{% extends "root.html" %} {% block head %}
<title>Following - {{ config.name }}</title>
{% endblock %} {% block body %} {{ macros::nav() }}
<main class="flex flex-col gap-2">
{{ macros::timelines_nav(selected="following") }} {{
macros::timelines_secondary_nav(posts="/following",
questions="/following/questions") }}
<!-- prettier-ignore -->
<div class="card w-full flex flex-col gap-2">
{% for post in list %}
{% if post[2].read_access == "Everybody" -%}
{% if post[0].context.repost and post[0].context.repost.reposting -%}
{{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true) }}
{% else %}
{{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2]) }}
{%- endif %}
{%- endif %}
{% endfor %}
{{ components::pagination(page=page, items=list|length) }}
</div>
</main>
{% endblock %}

Some files were not shown because too many files have changed in this diff Show more