add: finish ui rewrite
This commit is contained in:
parent
e9846016e6
commit
5dec98d698
119 changed files with 8776 additions and 9350 deletions
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "tetratto"
|
||||
version = "4.0.0"
|
||||
version = "4.5.0"
|
||||
edition = "2024"
|
||||
|
||||
[features]
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 %}
|
16
crates/app/src/public/html/auth/base.lisp
Normal file
16
crates/app/src/public/html/auth/base.lisp
Normal 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 %}")
|
|
@ -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 %}
|
80
crates/app/src/public/html/auth/connection.lisp
Normal file
80
crates/app/src/public/html/auth/connection.lisp
Normal 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 %}")
|
|
@ -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 %}
|
121
crates/app/src/public/html/auth/login.lisp
Normal file
121
crates/app/src/public/html/auth/login.lisp
Normal 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 %}")
|
|
@ -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 %}
|
123
crates/app/src/public/html/auth/register.lisp
Normal file
123
crates/app/src/public/html/auth/register.lisp
Normal 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 %}")
|
|
@ -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 %}")
|
|
@ -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>
|
60
crates/app/src/public/html/chats/channels.lisp
Normal file
60
crates/app/src/public/html/chats/channels.lisp
Normal 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 %}"))
|
|
@ -1,2 +0,0 @@
|
|||
{%- import "components.html" as components -%} {{ components::message(user=user,
|
||||
message=message, grouped=grouped) }}
|
1
crates/app/src/public/html/chats/message.lisp
Normal file
1
crates/app/src/public/html/chats/message.lisp
Normal file
|
@ -0,0 +1 @@
|
|||
(text "{%- import \"components.html\" as components -%} {{ components::message(user=user, message=message, grouped=grouped) }}")
|
|
@ -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>
|
55
crates/app/src/public/html/chats/stream.lisp
Normal file
55
crates/app/src/public/html/chats/stream.lisp
Normal 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;
|
||||
}")))
|
|
@ -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 %}
|
300
crates/app/src/public/html/communities/base.lisp
Normal file
300
crates/app/src/public/html/communities/base.lisp
Normal 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 %}")
|
|
@ -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 %}
|
406
crates/app/src/public/html/communities/create_post.lisp
Normal file
406
crates/app/src/public/html/communities/create_post.lisp
Normal 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 %}")
|
|
@ -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 %}
|
27
crates/app/src/public/html/communities/feed.lisp
Normal file
27
crates/app/src/public/html/communities/feed.lisp
Normal 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 %}")
|
|
@ -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 %}
|
97
crates/app/src/public/html/communities/list.lisp
Normal file
97
crates/app/src/public/html/communities/list.lisp
Normal 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 %}")
|
|
@ -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 %}
|
44
crates/app/src/public/html/communities/members.lisp
Normal file
44
crates/app/src/public/html/communities/members.lisp
Normal 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 %}")
|
|
@ -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 %}
|
99
crates/app/src/public/html/communities/question.lisp
Normal file
99
crates/app/src/public/html/communities/question.lisp
Normal 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 %}")
|
|
@ -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 %}
|
21
crates/app/src/public/html/communities/questions.lisp
Normal file
21
crates/app/src/public/html/communities/questions.lisp
Normal 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 %}")
|
|
@ -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 %}
|
44
crates/app/src/public/html/communities/search.lisp
Normal file
44
crates/app/src/public/html/communities/search.lisp
Normal 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 %}")
|
|
@ -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 %}
|
912
crates/app/src/public/html/communities/settings.lisp
Normal file
912
crates/app/src/public/html/communities/settings.lisp
Normal 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
1310
crates/app/src/public/html/components.lisp
Normal file
1310
crates/app/src/public/html/components.lisp
Normal file
File diff suppressed because it is too large
Load diff
|
@ -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 %}")
|
||||
|
|
|
@ -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 %}
|
76
crates/app/src/public/html/misc/notifications.lisp
Normal file
76
crates/app/src/public/html/misc/notifications.lisp
Normal 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 %}")
|
|
@ -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 %}
|
252
crates/app/src/public/html/misc/requests.lisp
Normal file
252
crates/app/src/public/html/misc/requests.lisp
Normal 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 %}")
|
|
@ -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 %}
|
37
crates/app/src/public/html/mod/audit_log.lisp
Normal file
37
crates/app/src/public/html/mod/audit_log.lisp
Normal 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 %}")
|
|
@ -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 %}
|
63
crates/app/src/public/html/mod/file_report.lisp
Normal file
63
crates/app/src/public/html/mod/file_report.lisp
Normal 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 %}")
|
|
@ -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 %}
|
86
crates/app/src/public/html/mod/ip_bans.lisp
Normal file
86
crates/app/src/public/html/mod/ip_bans.lisp
Normal 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 %}")
|
|
@ -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 %}
|
237
crates/app/src/public/html/mod/profile.lisp
Normal file
237
crates/app/src/public/html/mod/profile.lisp
Normal 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 %}")
|
|
@ -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 %}
|
72
crates/app/src/public/html/mod/reports.lisp
Normal file
72
crates/app/src/public/html/mod/reports.lisp
Normal 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 %}")
|
|
@ -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 %}
|
34
crates/app/src/public/html/mod/stats.lisp
Normal file
34
crates/app/src/public/html/mod/stats.lisp
Normal 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 %}")
|
|
@ -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 %}
|
121
crates/app/src/public/html/mod/warnings.lisp
Normal file
121
crates/app/src/public/html/mod/warnings.lisp
Normal 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 %}")
|
|
@ -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 %}
|
85
crates/app/src/public/html/post/likes.lisp
Normal file
85
crates/app/src/public/html/post/likes.lisp
Normal 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 %}")
|
|
@ -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 %}
|
321
crates/app/src/public/html/post/post.lisp
Normal file
321
crates/app/src/public/html/post/post.lisp
Normal 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 %}")
|
|
@ -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 %}
|
69
crates/app/src/public/html/post/quotes.lisp
Normal file
69
crates/app/src/public/html/post/quotes.lisp
Normal 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 %}")
|
|
@ -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 %}
|
88
crates/app/src/public/html/post/reposts.lisp
Normal file
88
crates/app/src/public/html/post/reposts.lisp
Normal 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 %}")
|
|
@ -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 %}
|
33
crates/app/src/public/html/profile/banned.lisp
Normal file
33
crates/app/src/public/html/profile/banned.lisp
Normal 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 %}")
|
|
@ -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 %}
|
371
crates/app/src/public/html/profile/base.lisp
Normal file
371
crates/app/src/public/html/profile/base.lisp
Normal 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 %}")
|
|
@ -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 %}
|
70
crates/app/src/public/html/profile/blocked.lisp
Normal file
70
crates/app/src/public/html/profile/blocked.lisp
Normal 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 %}")
|
|
@ -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 %}
|
24
crates/app/src/public/html/profile/followers.lisp
Normal file
24
crates/app/src/public/html/profile/followers.lisp
Normal 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 %}")
|
|
@ -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 %}
|
24
crates/app/src/public/html/profile/following.lisp
Normal file
24
crates/app/src/public/html/profile/following.lisp
Normal 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 %}")
|
|
@ -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 %}
|
28
crates/app/src/public/html/profile/media.lisp
Normal file
28
crates/app/src/public/html/profile/media.lisp
Normal 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 %}")
|
|
@ -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 %}
|
46
crates/app/src/public/html/profile/posts.lisp
Normal file
46
crates/app/src/public/html/profile/posts.lisp
Normal 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 %}")
|
|
@ -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 %}
|
163
crates/app/src/public/html/profile/private.lisp
Normal file
163
crates/app/src/public/html/profile/private.lisp
Normal 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 %}")
|
|
@ -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 %}
|
28
crates/app/src/public/html/profile/replies.lisp
Normal file
28
crates/app/src/public/html/profile/replies.lisp
Normal 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
1544
crates/app/src/public/html/profile/settings.lisp
Normal file
1544
crates/app/src/public/html/profile/settings.lisp
Normal file
File diff suppressed because it is too large
Load diff
|
@ -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 %}
|
39
crates/app/src/public/html/profile/warning.lisp
Normal file
39
crates/app/src/public/html/profile/warning.lisp
Normal 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 %}")
|
|
@ -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"))
|
||||
|
|
|
@ -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 %}
|
93
crates/app/src/public/html/stacks/list.lisp
Normal file
93
crates/app/src/public/html/stacks/list.lisp
Normal 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 %}")
|
|
@ -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 %}
|
316
crates/app/src/public/html/stacks/manage.lisp
Normal file
316
crates/app/src/public/html/stacks/manage.lisp
Normal 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 %}")
|
|
@ -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 %}
|
37
crates/app/src/public/html/stacks/posts.lisp
Normal file
37
crates/app/src/public/html/stacks/posts.lisp
Normal 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 %}")
|
|
@ -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 %}
|
35
crates/app/src/public/html/timelines/all.lisp
Normal file
35
crates/app/src/public/html/timelines/all.lisp
Normal 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 %}")
|
|
@ -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 %}
|
13
crates/app/src/public/html/timelines/all_questions.lisp
Normal file
13
crates/app/src/public/html/timelines/all_questions.lisp
Normal 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 %}")
|
|
@ -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 %}
|
13
crates/app/src/public/html/timelines/following.lisp
Normal file
13
crates/app/src/public/html/timelines/following.lisp
Normal file
|
@ -0,0 +1,13 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "Following - {{ config.name }}"))
|
||||
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
|
||||
(main
|
||||
("class" "flex flex-col gap-2")
|
||||
(text "{{ macros::timelines_nav(selected=\"following\") }} {{ macros::timelines_secondary_nav(posts=\"/following\", questions=\"/following/questions\") }}")
|
||||
(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 %}")
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue