Compare commits
74 commits
Author | SHA1 | Date | |
---|---|---|---|
a799c777ea | |||
8d70f65863 | |||
5dd9fa01cb | |||
b860f74124 | |||
e7c4cf14aa | |||
45ea91a768 | |||
4b7808e70b | |||
904944f5d3 | |||
5bfbd4e110 | |||
f622fb1125 | |||
87b61d7717 | |||
aeaa230162 | |||
2cd04b0db0 | |||
59581f69c9 | |||
6e0f2985b9 | |||
ffdb767518 | |||
c2dbe2f114 | |||
2676340fba | |||
66beef6b1d | |||
5fbf454b52 | |||
0ae64de989 | |||
9528d71b2a | |||
339aa59434 | |||
253f11b00c | |||
4843688fcf | |||
2a77c61bf2 | |||
8c969cd56f | |||
aceb51c21c | |||
69fc3ca490 | |||
dc74c5d63c | |||
38ddf6cde1 | |||
efd4ac8104 | |||
2f83497f98 | |||
626c6711ef | |||
d1a074eaeb | |||
958979cfa1 | |||
612fbf5eb4 | |||
5961999ce4 | |||
52c8983634 | |||
d67bf26955 | |||
0c509b7001 | |||
af6fbdf04e | |||
a37312fecf | |||
a4298f95f6 | |||
16843a6ab8 | |||
6be729de50 | |||
ffdf320c14 | |||
fa72d6a59d | |||
dc50f3a8af | |||
f0d1a1e8e4 | |||
eb5a0d146f | |||
1b1c1c0bea | |||
97b7e873ed | |||
57a69eea50 | |||
c1568ad866 | |||
c08a26ae8d | |||
1aab2f1b97 | |||
42421bd906 | |||
102ea0ee35 | |||
0f48a46c40 | |||
3027b679db | |||
2b253c811c | |||
822aaed0c8 | |||
c55d8bd38b | |||
a6aa2488c4 | |||
844e60df30 | |||
dd8e6561e6 | |||
83c6df6f6e | |||
a43e586e4c | |||
b7b84d15b7 | |||
8c5d8bf0ba | |||
9443bfb58d | |||
0af95e517d | |||
a7c0046762 |
131 changed files with 8201 additions and 1066 deletions
8
Cargo.lock
generated
8
Cargo.lock
generated
|
@ -3231,7 +3231,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tetratto"
|
||||
version = "8.0.0"
|
||||
version = "10.0.0"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"async-stripe",
|
||||
|
@ -3262,7 +3262,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tetratto-core"
|
||||
version = "8.0.0"
|
||||
version = "10.0.0"
|
||||
dependencies = [
|
||||
"async-recursion",
|
||||
"base16ct",
|
||||
|
@ -3284,7 +3284,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tetratto-l10n"
|
||||
version = "8.0.0"
|
||||
version = "10.0.0"
|
||||
dependencies = [
|
||||
"pathbufd",
|
||||
"serde",
|
||||
|
@ -3293,7 +3293,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tetratto-shared"
|
||||
version = "8.0.0"
|
||||
version = "10.0.0"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"chrono",
|
||||
|
|
|
@ -33,6 +33,8 @@ Tetratto **requires** Cloudflare Turnstile for registrations. Testing keys are l
|
|||
|
||||
A `docs` directory will be generated in the same directory that you ran the `tetratto` binary in. **Markdown** files placed here will be served at `/doc/{*file_name}`. For other types of assets, you can place them in the generated `public` directory. This directory serves everything at `/public/{*file_name}`.
|
||||
|
||||
You can configure your port through the `port` key of the configuration file. You can also run the server with the `PORT` environment variable, which will override whatever is set in the configuration file. You can also use the `CACHE_BREAKER` environment variable to specify a version number to be used in static asset links in order to break cache entries.
|
||||
|
||||
## Usage (as a user)
|
||||
|
||||
Tetratto is very simple once you get the hang of it! At the top of the page (or bottom if you're on mobile), you'll see the navigation bar. Once logged in, you'll be able to access "Home", "Popular", and "Communities" from there! You can also press your profile picture (on the right) to view your own profile, settings, or log out!
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "tetratto"
|
||||
version = "8.0.0"
|
||||
version = "10.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
@ -9,7 +9,7 @@ serde = { version = "1.0.219", features = ["derive"] }
|
|||
tera = "1.20.0"
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||
tower-http = { version = "0.6.6", features = ["trace", "fs", "catch-panic"] }
|
||||
tower-http = { version = "0.6.6", features = ["trace", "fs", "catch-panic", "set-header"] }
|
||||
axum = { version = "0.8.4", features = ["macros", "ws"] }
|
||||
tokio = { version = "1.45.1", features = ["macros", "rt-multi-thread"] }
|
||||
axum-extra = { version = "0.10.1", features = ["cookie", "multipart"] }
|
||||
|
|
|
@ -32,12 +32,14 @@ pub const FAVICON: &str = include_str!("./public/images/favicon.svg");
|
|||
pub const STYLE_CSS: &str = include_str!("./public/css/style.css");
|
||||
pub const ROOT_CSS: &str = include_str!("./public/css/root.css");
|
||||
pub const UTILITY_CSS: &str = include_str!("./public/css/utility.css");
|
||||
pub const CHATS_CSS: &str = include_str!("./public/css/chats.css");
|
||||
|
||||
// js
|
||||
pub const LOADER_JS: &str = include_str!("./public/js/loader.js");
|
||||
pub const ATTO_JS: &str = include_str!("./public/js/atto.js");
|
||||
pub const ME_JS: &str = include_str!("./public/js/me.js");
|
||||
pub const STREAMS_JS: &str = include_str!("./public/js/streams.js");
|
||||
pub const CARP_JS: &str = include_str!("./public/js/carp.js");
|
||||
|
||||
// html
|
||||
pub const BODY: &str = include_str!("./public/html/body.lisp");
|
||||
|
@ -49,6 +51,7 @@ pub const MISC_ERROR: &str = include_str!("./public/html/misc/error.lisp");
|
|||
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.lisp");
|
||||
pub const MISC_ACHIEVEMENTS: &str = include_str!("./public/html/misc/achievements.lisp");
|
||||
|
||||
pub const AUTH_BASE: &str = include_str!("./public/html/auth/base.lisp");
|
||||
pub const AUTH_LOGIN: &str = include_str!("./public/html/auth/login.lisp");
|
||||
|
@ -97,6 +100,7 @@ pub const TIMELINES_FOLLOWING_QUESTIONS: &str =
|
|||
pub const TIMELINES_ALL_QUESTIONS: &str =
|
||||
include_str!("./public/html/timelines/all_questions.lisp");
|
||||
pub const TIMELINES_SEARCH: &str = include_str!("./public/html/timelines/search.lisp");
|
||||
pub const TIMELINES_SWISS_ARMY: &str = include_str!("./public/html/timelines/swiss_army.lisp");
|
||||
|
||||
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");
|
||||
|
@ -114,6 +118,7 @@ pub const CHATS_CHANNELS: &str = include_str!("./public/html/chats/channels.lisp
|
|||
pub const STACKS_LIST: &str = include_str!("./public/html/stacks/list.lisp");
|
||||
pub const STACKS_FEED: &str = include_str!("./public/html/stacks/feed.lisp");
|
||||
pub const STACKS_MANAGE: &str = include_str!("./public/html/stacks/manage.lisp");
|
||||
pub const STACKS_ADD_USER: &str = include_str!("./public/html/stacks/add_user.lisp");
|
||||
|
||||
pub const FORGE_HOME: &str = include_str!("./public/html/forge/home.lisp");
|
||||
pub const FORGE_BASE: &str = include_str!("./public/html/forge/base.lisp");
|
||||
|
@ -124,6 +129,8 @@ pub const DEVELOPER_HOME: &str = include_str!("./public/html/developer/home.lisp
|
|||
pub const DEVELOPER_APP: &str = include_str!("./public/html/developer/app.lisp");
|
||||
pub const DEVELOPER_LINK: &str = include_str!("./public/html/developer/link.lisp");
|
||||
|
||||
pub const JOURNALS_APP: &str = include_str!("./public/html/journals/app.lisp");
|
||||
|
||||
// langs
|
||||
pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml");
|
||||
|
||||
|
@ -149,7 +156,7 @@ pub(crate) async fn pull_icon(icon: &str, icons_dir: &str) {
|
|||
"https://raw.githubusercontent.com/lucide-icons/lucide/refs/heads/main/icons/{icon}.svg"
|
||||
);
|
||||
|
||||
let file_path = PathBufD::current().extend(&[icons_dir, icon]);
|
||||
let file_path = PathBufD::current().extend(&[icons_dir, &format!("{icon}.svg")]);
|
||||
|
||||
if exists(&file_path).unwrap() {
|
||||
writer.insert(icon.to_string(), read_to_string(&file_path).unwrap());
|
||||
|
@ -174,7 +181,8 @@ macro_rules! vendor_icon {
|
|||
let writer = &mut ICONS.write().await;
|
||||
writer.insert($name.to_string(), $icon.to_string());
|
||||
|
||||
let file_path = PathBufD::current().extend(&[$icons_dir.clone(), $name.to_string()]);
|
||||
let file_path =
|
||||
PathBufD::current().extend(&[$icons_dir.clone(), format!("{}.svg", $name.to_string())]);
|
||||
std::fs::write(file_path, $icon).unwrap();
|
||||
}};
|
||||
}
|
||||
|
@ -342,6 +350,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
|
|||
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 --lisp plugins);
|
||||
write_template!(html_path->"misc/achievements.html"(crate::assets::MISC_ACHIEVEMENTS) --config=config --lisp plugins);
|
||||
|
||||
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);
|
||||
|
@ -385,6 +394,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
|
|||
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->"timelines/swiss_army.html"(crate::assets::TIMELINES_SWISS_ARMY) --config=config --lisp plugins);
|
||||
|
||||
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);
|
||||
|
@ -402,6 +412,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
|
|||
write_template!(html_path->"stacks/list.html"(crate::assets::STACKS_LIST) -d "stacks" --config=config --lisp plugins);
|
||||
write_template!(html_path->"stacks/feed.html"(crate::assets::STACKS_FEED) --config=config --lisp plugins);
|
||||
write_template!(html_path->"stacks/manage.html"(crate::assets::STACKS_MANAGE) --config=config --lisp plugins);
|
||||
write_template!(html_path->"stacks/add_user.html"(crate::assets::STACKS_ADD_USER) --config=config --lisp plugins);
|
||||
|
||||
write_template!(html_path->"forge/home.html"(crate::assets::FORGE_HOME) -d "forge" --config=config --lisp plugins);
|
||||
write_template!(html_path->"forge/base.html"(crate::assets::FORGE_BASE) --config=config --lisp plugins);
|
||||
|
@ -412,6 +423,8 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
|
|||
write_template!(html_path->"developer/app.html"(crate::assets::DEVELOPER_APP) --config=config --lisp plugins);
|
||||
write_template!(html_path->"developer/link.html"(crate::assets::DEVELOPER_LINK) --config=config --lisp plugins);
|
||||
|
||||
write_template!(html_path->"journals/app.html"(crate::assets::JOURNALS_APP) -d "journals" --config=config --lisp plugins);
|
||||
|
||||
html_path
|
||||
}
|
||||
|
||||
|
@ -487,6 +500,13 @@ pub(crate) async fn initial_context(
|
|||
}
|
||||
|
||||
ctx.insert("lang", &lang.data);
|
||||
ctx.insert("random_cache_breaker", &CACHE_BREAKER.clone());
|
||||
ctx.insert(
|
||||
"random_cache_breaker",
|
||||
&if let Ok(c) = std::env::var("CACHE_BREAKER") {
|
||||
c
|
||||
} else {
|
||||
CACHE_BREAKER.clone()
|
||||
},
|
||||
);
|
||||
ctx
|
||||
}
|
||||
|
|
|
@ -16,6 +16,8 @@ version = "1.0.0"
|
|||
"general:link.ip_bans" = "IP bans"
|
||||
"general:link.stats" = "Stats"
|
||||
"general:link.search" = "Search"
|
||||
"general:link.journals" = "Journals"
|
||||
"general:link.achievements" = "Achievements"
|
||||
"general:action.save" = "Save"
|
||||
"general:action.delete" = "Delete"
|
||||
"general:action.purge" = "Purge"
|
||||
|
@ -38,6 +40,9 @@ version = "1.0.0"
|
|||
"general:label.account_banned" = "Account banned"
|
||||
"general:label.account_banned_body" = "Your account has been banned for violating our policies."
|
||||
"general:label.better_with_account" = "It's better with an account! Login or sign up to explore more."
|
||||
"general:label.could_not_find_post" = "Could not find original post..."
|
||||
"general:label.timeline_end" = "That's a wrap!"
|
||||
"general:label.loading" = "Working on it!"
|
||||
|
||||
"general:label.supporter_motivation" = "Become a supporter!"
|
||||
"general:action.become_supporter" = "Become supporter"
|
||||
|
@ -131,6 +136,8 @@ version = "1.0.0"
|
|||
"communities:label.file" = "File"
|
||||
"communities:label.drafts" = "Drafts"
|
||||
"communities:label.load" = "Load"
|
||||
"communities:action.draw" = "Draw"
|
||||
"communities:action.remove_drawing" = "Remove drawing"
|
||||
|
||||
"notifs:action.mark_as_read" = "Mark as read"
|
||||
"notifs:action.mark_as_unread" = "Mark as unread"
|
||||
|
@ -162,10 +169,13 @@ version = "1.0.0"
|
|||
"settings:label.export" = "Export"
|
||||
"settings:label.manage_blocks" = "Manage blocks"
|
||||
"settings:label.users" = "Users"
|
||||
"settings:label.generate_invites" = "Generate invites"
|
||||
"settings:label.add_to_stack" = "Add to stack"
|
||||
"settings:tab.security" = "Security"
|
||||
"settings:tab.blocks" = "Blocks"
|
||||
"settings:tab.billing" = "Billing"
|
||||
"settings:tab.uploads" = "Uploads"
|
||||
"settings:tab.invites" = "Invites"
|
||||
|
||||
"mod_panel:label.open_reported_content" = "Open reported content"
|
||||
"mod_panel:label.manage_profile" = "Manage profile"
|
||||
|
@ -173,6 +183,9 @@ version = "1.0.0"
|
|||
"mod_panel:label.warnings" = "Warnings"
|
||||
"mod_panel:label.create_warning" = "Create warning"
|
||||
"mod_panel:label.associations" = "Associations"
|
||||
"mod_panel:label.invited_by" = "Invited by"
|
||||
"mod_panel:label.send_debug_payload" = "Send debug payload"
|
||||
"mod_panel:action.send" = "Send"
|
||||
|
||||
"requests:label.requests" = "Requests"
|
||||
"requests:label.community_join_request" = "Community join request"
|
||||
|
@ -228,3 +241,22 @@ version = "1.0.0"
|
|||
"developer:label.guides_and_help" = "Guides & help"
|
||||
"developer:action.delete" = "Delete app"
|
||||
"developer:action.authorize" = "Authorize"
|
||||
|
||||
"journals:label.my_journals" = "My journals"
|
||||
"journals:action.create_journal" = "Create journal"
|
||||
"journals:action.create_note" = "Create note"
|
||||
"journals:label.welcome" = "Welcome to Journals!"
|
||||
"journals:label.select_a_journal" = "Select or create a journal to get started."
|
||||
"journals:label.select_a_note" = "Select or create a note in this journal to get started."
|
||||
"journals:label.mobile_click_my_journals" = "Click \"My journals\" at the top to open the sidebar."
|
||||
"journals:label.editor" = "Editor"
|
||||
"journals:label.preview_pane" = "Preview"
|
||||
"journals:action.edit_tags" = "Edit tags"
|
||||
"journals:action.tags" = "Tags"
|
||||
"journals:label.directories" = "Directories"
|
||||
"journals:action.create_subdir" = "Create subdirectory"
|
||||
"journals:action.create_root_dir" = "Create root directory"
|
||||
"journals:action.move" = "Move"
|
||||
"journals:action.publish" = "Publish"
|
||||
"journals:action.unpublish" = "Unpublish"
|
||||
"journals:action.view" = "View"
|
||||
|
|
|
@ -193,7 +193,10 @@ macro_rules! check_user_blocked_or_private {
|
|||
// check if other user is banned
|
||||
if $other_user.permissions.check_banned() {
|
||||
if let Some(ref ua) = $user {
|
||||
if !ua.permissions.check(FinePermission::MANAGE_USERS) {
|
||||
if !ua
|
||||
.permissions
|
||||
.check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS)
|
||||
{
|
||||
$crate::user_banned!($user, $other_user, $data, $jar);
|
||||
}
|
||||
} else {
|
||||
|
@ -213,7 +216,9 @@ macro_rules! check_user_blocked_or_private {
|
|||
.get_user_stack_blocked_users($other_user.id)
|
||||
.await
|
||||
.contains(&ua.id))
|
||||
&& !ua.permissions.check(FinePermission::MANAGE_USERS)
|
||||
&& !ua
|
||||
.permissions
|
||||
.check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS)
|
||||
{
|
||||
let lang = get_lang!($jar, $data.0);
|
||||
let mut context = initial_context(&$data.0.0.0, lang, &$user).await;
|
||||
|
@ -238,7 +243,9 @@ macro_rules! check_user_blocked_or_private {
|
|||
if $other_user.settings.private_profile {
|
||||
if let Some(ref ua) = $user {
|
||||
if (ua.id != $other_user.id)
|
||||
&& !ua.permissions.check(FinePermission::MANAGE_USERS)
|
||||
&& !ua
|
||||
.permissions
|
||||
.check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS)
|
||||
&& $data
|
||||
.0
|
||||
.get_userfollow_by_initiator_receiver($other_user.id, ua.id)
|
||||
|
|
|
@ -11,12 +11,16 @@ use assets::{init_dirs, write_assets};
|
|||
use tetratto_core::model::{permissions::FinePermission, uploads::CustomEmoji};
|
||||
pub use tetratto_core::*;
|
||||
|
||||
use axum::{Extension, Router};
|
||||
use axum::{
|
||||
http::{HeaderName, HeaderValue},
|
||||
Extension, Router,
|
||||
};
|
||||
use reqwest::Client;
|
||||
use tera::{Tera, Value};
|
||||
use tower_http::{
|
||||
trace::{self, TraceLayer},
|
||||
catch_panic::CatchPanicLayer,
|
||||
set_header::SetResponseHeaderLayer,
|
||||
trace::{self, TraceLayer},
|
||||
};
|
||||
use tracing::{Level, info};
|
||||
|
||||
|
@ -34,6 +38,10 @@ fn render_markdown(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Va
|
|||
)
|
||||
}
|
||||
|
||||
fn render_emojis(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
|
||||
Ok(CustomEmoji::replace(value.as_str().unwrap()).into())
|
||||
}
|
||||
|
||||
fn color_escape(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
|
||||
Ok(sanitize::color_escape(value.as_str().unwrap()).into())
|
||||
}
|
||||
|
@ -74,7 +82,11 @@ async fn main() {
|
|||
.compact()
|
||||
.init();
|
||||
|
||||
let config = config::Config::get_config();
|
||||
let mut config = config::Config::get_config();
|
||||
if let Ok(port) = var("PORT") {
|
||||
let port = port.parse::<u16>().expect("port should be a u16");
|
||||
config.port = port;
|
||||
}
|
||||
|
||||
// init
|
||||
init_dirs(&config).await;
|
||||
|
@ -98,6 +110,7 @@ async fn main() {
|
|||
tera.register_filter("has_staff_badge", check_staff_badge);
|
||||
tera.register_filter("has_banned", check_banned);
|
||||
tera.register_filter("remove_script_tags", remove_script_tags);
|
||||
tera.register_filter("emojis", render_emojis);
|
||||
|
||||
let client = Client::new();
|
||||
|
||||
|
@ -115,6 +128,10 @@ async fn main() {
|
|||
.make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO))
|
||||
.on_response(trace::DefaultOnResponse::new().level(Level::INFO)),
|
||||
)
|
||||
.layer(SetResponseHeaderLayer::if_not_present(
|
||||
HeaderName::from_static("content-security-policy"),
|
||||
HeaderValue::from_static("default-src 'self' blob: *.spotify.com musicbrainz.org; frame-ancestors 'self'; 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: *"),
|
||||
))
|
||||
.layer(CatchPanicLayer::new());
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", config.port))
|
||||
|
|
232
crates/app/src/public/css/chats.css
Normal file
232
crates/app/src/public/css/chats.css
Normal file
|
@ -0,0 +1,232 @@
|
|||
:root {
|
||||
--list-bar-width: 64px;
|
||||
--channels-bar-width: 256px;
|
||||
--sidebar-height: calc(100dvh - 42px);
|
||||
--channel-header-height: 48px;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.name.shortest {
|
||||
max-width: 165px;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
.send_button {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.send_button .icon {
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
}
|
||||
|
||||
a.channel_icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
a.channel_icon .icon {
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
a.channel_icon.small {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
a.channel_icon.small .icon {
|
||||
min-width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
a.channel_icon:has(img) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a.channel_icon img {
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
a.channel_icon img,
|
||||
a.channel_icon:has(.icon) {
|
||||
transition:
|
||||
outline 0.25s,
|
||||
background 0.15s !important;
|
||||
}
|
||||
|
||||
a.channel_icon:not(.selected):hover img,
|
||||
a.channel_icon:not(.selected):hover:has(.icon) {
|
||||
outline: solid 1px var(--color-text);
|
||||
}
|
||||
a.channel_icon.selected img,
|
||||
a.channel_icon.selected:has(.icon) {
|
||||
outline: solid 2px var(--color-text);
|
||||
}
|
||||
|
||||
nav {
|
||||
background: var(--color-raised);
|
||||
color: var(--color-text-raised) !important;
|
||||
height: 42px;
|
||||
position: sticky !important;
|
||||
}
|
||||
|
||||
nav::after {
|
||||
display: block;
|
||||
position: absolute;
|
||||
background: var(--color-super-lowered);
|
||||
height: 1px;
|
||||
width: calc(100% - var(--list-bar-width));
|
||||
bottom: 0;
|
||||
left: var(--list-bar-width);
|
||||
content: "";
|
||||
}
|
||||
|
||||
nav .content_container {
|
||||
max-width: 100% !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chats_nav {
|
||||
display: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chats_nav button {
|
||||
justify-content: flex-start;
|
||||
width: 100% !important;
|
||||
flex-direction: row !important;
|
||||
font-size: 16px !important;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.chats_nav button svg {
|
||||
margin-right: var(--pad-4);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: var(--color-raised);
|
||||
color: var(--color-text-raised);
|
||||
border-right: solid 1px var(--color-super-lowered);
|
||||
padding: 0.4rem;
|
||||
width: max-content;
|
||||
height: var(--sidebar-height);
|
||||
overflow: auto;
|
||||
transition: left 0.15s;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.sidebar .title:not(.dropdown *) {
|
||||
padding: var(--pad-4);
|
||||
border-bottom: solid 1px var(--color-super-lowered);
|
||||
}
|
||||
|
||||
.sidebar#channels_list {
|
||||
width: var(--channels-bar-width);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.sidebar#notes_list {
|
||||
width: calc(var(--channels-bar-width) + var(--list-bar-width));
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
#stream {
|
||||
width: calc(
|
||||
100dvw - var(--list-bar-width) - var(--channels-bar-width)
|
||||
) !important;
|
||||
height: var(--sidebar-height);
|
||||
}
|
||||
|
||||
.message {
|
||||
transition: background 0.15s;
|
||||
box-shadow: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message:hover {
|
||||
background: var(--color-raised);
|
||||
}
|
||||
|
||||
.message:hover .hidden,
|
||||
.message:focus .hidden,
|
||||
.message:active .hidden {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.message.grouped {
|
||||
padding: var(--pad-1) var(--pad-4) var(--pad-1)
|
||||
calc(var(--pad-4) + var(--pad-2) + 42px);
|
||||
}
|
||||
|
||||
turbo-frame {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.channel_header {
|
||||
height: var(--channel-header-height);
|
||||
}
|
||||
|
||||
.members_list_half {
|
||||
padding-top: var(--pad-4);
|
||||
border-top: solid 1px var(--color-super-lowered);
|
||||
}
|
||||
|
||||
.channels_list_half:not(.no_members),
|
||||
.members_list_half {
|
||||
overflow: auto;
|
||||
height: calc(
|
||||
(var(--sidebar-height) - var(--channel-header-height) - 8rem) / 2
|
||||
);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 900px) {
|
||||
:root {
|
||||
--sidebar-height: calc(100dvh - 42px * 2);
|
||||
}
|
||||
|
||||
.message.grouped {
|
||||
padding: var(--pad-1) var(--pad-4) var(--pad-1)
|
||||
calc(var(--pad-4) + var(--pad-2) + 31px);
|
||||
}
|
||||
|
||||
body:not(.sidebars_shown) .sidebar {
|
||||
position: absolute;
|
||||
left: -200%;
|
||||
}
|
||||
|
||||
body.sidebars_shown .sidebar {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
#stream {
|
||||
width: 100dvw !important;
|
||||
height: var(--sidebar-height);
|
||||
}
|
||||
|
||||
nav::after {
|
||||
width: 100dvw;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.chats_nav {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
nav:has(+ .chats_nav) .dropdown .inner {
|
||||
top: calc(100% + 44px);
|
||||
}
|
||||
|
||||
.padded_section {
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
|
@ -116,7 +116,7 @@ article {
|
|||
padding: 0;
|
||||
}
|
||||
|
||||
body .card:not(.card *):not(#stream *):not(.user_plate),
|
||||
body .card:not(.card *):not(.user_plate),
|
||||
body .pillmenu:not(.card *) > a,
|
||||
body .card-nest:not(.card *) > .card,
|
||||
body .banner {
|
||||
|
@ -213,6 +213,14 @@ ol {
|
|||
margin-left: var(--pad-4);
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: var(--pad-4);
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
pre,
|
||||
code {
|
||||
font-family: "Jetbrains Mono", "Fire Code", monospace;
|
||||
|
@ -221,18 +229,12 @@ code {
|
|||
overflow: auto;
|
||||
background: var(--color-lowered);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--pad-1);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: var(--pad-4);
|
||||
}
|
||||
|
||||
svg.icon {
|
||||
stroke: currentColor;
|
||||
width: 18px;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
|
@ -263,7 +265,6 @@ code {
|
|||
overflow-wrap: normal;
|
||||
text-wrap: pretty;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
h1,
|
||||
|
@ -275,7 +276,6 @@ h6 {
|
|||
margin: 0;
|
||||
font-weight: 700;
|
||||
width: -moz-max-content;
|
||||
width: max-content;
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
@ -344,3 +344,56 @@ img.emoji {
|
|||
height: 1em;
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
padding-left: 1rem;
|
||||
border-left: solid 5px var(--color-super-lowered);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.skel {
|
||||
display: block;
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-raised);
|
||||
animation: skel ease-in-out infinite 2s forwards running;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
@keyframes skel {
|
||||
from {
|
||||
background: var(--color-raised);
|
||||
}
|
||||
|
||||
50% {
|
||||
background: var(--color-lowered);
|
||||
}
|
||||
|
||||
to {
|
||||
background: var(--color-raised);
|
||||
}
|
||||
}
|
||||
|
||||
.loader {
|
||||
animation: spin linear infinite 2s forwards running;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotateZ(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotateZ(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
canvas {
|
||||
border-radius: var(--radius);
|
||||
border: solid 5px var(--color-primary);
|
||||
background: white;
|
||||
box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size)
|
||||
var(--color-shadow);
|
||||
}
|
||||
|
|
|
@ -273,6 +273,12 @@ button,
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
.button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 50%;
|
||||
}
|
||||
|
||||
button.small,
|
||||
.button.small {
|
||||
/* min-height: max-content; */
|
||||
|
@ -407,6 +413,44 @@ select:focus {
|
|||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
--color: #c9b1bc;
|
||||
appearance: none;
|
||||
border-radius: var(--radius);
|
||||
transition:
|
||||
box-shadow 0.15s,
|
||||
background 0.15s;
|
||||
background-color: var(--color-super-raised);
|
||||
background-position: center;
|
||||
background-origin: padding-box;
|
||||
background-size: 14px;
|
||||
background-repeat: no-repeat;
|
||||
width: 1em !important;
|
||||
height: 1em;
|
||||
min-width: 1em;
|
||||
outline: none;
|
||||
border: solid 1px var(--color-super-lowered);
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
input[type="checkbox"]:hover {
|
||||
box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size)
|
||||
var(--color-shadow);
|
||||
}
|
||||
|
||||
input[type="checkbox"]:focus {
|
||||
outline: solid 2px var(--color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
input[type="checkbox"]:checked {
|
||||
border-color: var(--color);
|
||||
background-color: var(--color);
|
||||
background-image: url("/icons/check.svg");
|
||||
}
|
||||
|
||||
/* pillmenu */
|
||||
.pillmenu {
|
||||
display: flex;
|
||||
|
@ -565,11 +609,9 @@ select:focus {
|
|||
nav {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-primary) !important;
|
||||
color: inherit;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--color-text);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 6374;
|
||||
|
@ -716,19 +758,25 @@ nav .button:not(.title):not(.active):hover {
|
|||
border-bottom-right-radius: var(--radius) !important;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 900px) {
|
||||
.mobile_nav:not(.mobile) {
|
||||
border-radius: var(--radius);
|
||||
border: solid 1px var(--color-super-lowered);
|
||||
}
|
||||
}
|
||||
|
||||
/* dialog */
|
||||
dialog {
|
||||
padding: 0;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
display: flex;
|
||||
display: none;
|
||||
background: var(--color-surface);
|
||||
border: solid 1px var(--color-super-lowered) !important;
|
||||
border-radius: var(--radius);
|
||||
max-width: 100%;
|
||||
border-style: none;
|
||||
display: none;
|
||||
margin: auto;
|
||||
color: var(--color-text);
|
||||
animation: popin ease-in-out 1 0.1s forwards running;
|
||||
|
@ -1055,14 +1103,14 @@ details summary::-webkit-details-marker {
|
|||
display: none;
|
||||
}
|
||||
|
||||
details[open] summary {
|
||||
details[open] > summary {
|
||||
position: relative;
|
||||
color: var(--color-primary);
|
||||
background: var(--color-super-lowered);
|
||||
color: var(--color-text-lowered) !important;
|
||||
background: var(--color-super-lowered) !important;
|
||||
margin-bottom: var(--pad-1);
|
||||
}
|
||||
|
||||
details[open] summary::after {
|
||||
details[open] > summary::after {
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 5px;
|
||||
|
@ -1075,7 +1123,7 @@ details[open] summary::after {
|
|||
animation: fadein ease-in-out 1 0.1s forwards running;
|
||||
}
|
||||
|
||||
details .card {
|
||||
details > .card {
|
||||
background: var(--color-super-raised);
|
||||
}
|
||||
|
||||
|
@ -1116,3 +1164,156 @@ details.accordion .inner {
|
|||
border: solid 1px var(--color-super-lowered);
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
/* codemirror */
|
||||
.CodeMirror {
|
||||
color: var(--color-text) !important;
|
||||
}
|
||||
|
||||
.CodeMirror {
|
||||
background: transparent !important;
|
||||
font-family: inherit !important;
|
||||
height: 10rem !important;
|
||||
min-height: 100%;
|
||||
max-height: 100%;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.CodeMirror-cursor {
|
||||
border-color: rgb(0, 0, 0) !important;
|
||||
}
|
||||
|
||||
.CodeMirror-cursor:is(.dark *) {
|
||||
border-color: rgb(255, 255, 255) !important;
|
||||
}
|
||||
|
||||
.CodeMirror-cursor {
|
||||
height: 22px !important;
|
||||
}
|
||||
|
||||
[role="presentation"]::-moz-selection,
|
||||
[role="presentation"] *::-moz-selection {
|
||||
background-color: rgb(191, 219, 254) !important;
|
||||
}
|
||||
|
||||
[role="presentation"]::selection,
|
||||
[role="presentation"] *::selection,
|
||||
.CodeMirror-selected {
|
||||
background-color: rgb(191, 219, 254) !important;
|
||||
}
|
||||
|
||||
[role="presentation"]:is(.dark *)::-moz-selection,
|
||||
[role="presentation"] *:is(.dark *)::-moz-selection {
|
||||
background-color: rgb(64, 64, 64) !important;
|
||||
}
|
||||
|
||||
[role="presentation"]:is(.dark *)::selection,
|
||||
[role="presentation"] *:is(.dark *)::selection,
|
||||
.CodeMirror-selected:is(.dark *) {
|
||||
background-color: rgb(64, 64, 64) !important;
|
||||
}
|
||||
|
||||
.cm-header {
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
.cm-variable-2,
|
||||
.cm-quote,
|
||||
.cm-keyword,
|
||||
.cm-string,
|
||||
.cm-atom {
|
||||
color: rgb(63, 98, 18) !important;
|
||||
}
|
||||
|
||||
.cm-variable-2:is(.dark *),
|
||||
.cm-quote:is(.dark *),
|
||||
.cm-keyword:is(.dark *),
|
||||
.cm-string:is(.dark *),
|
||||
.cm-atom:is(.dark *) {
|
||||
color: rgb(217, 249, 157) !important;
|
||||
}
|
||||
|
||||
.cm-comment {
|
||||
color: rgb(153 27 27) !important;
|
||||
}
|
||||
|
||||
.cm-comment:is(.dark *) {
|
||||
color: rgb(254, 202, 202) !important;
|
||||
}
|
||||
|
||||
.cm-comment {
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.cm-link {
|
||||
color: var(--color-link) !important;
|
||||
}
|
||||
|
||||
.cm-url,
|
||||
.cm-property,
|
||||
.cm-qualifier {
|
||||
color: rgb(29, 78, 216) !important;
|
||||
}
|
||||
|
||||
.cm-url:is(.dark *),
|
||||
.cm-property:is(.dark *),
|
||||
.cm-qualifier:is(.dark *) {
|
||||
color: rgb(191, 219, 254) !important;
|
||||
}
|
||||
|
||||
.cm-variable-3,
|
||||
.cm-tag,
|
||||
.cm-def,
|
||||
.cm-attribute,
|
||||
.cm-number {
|
||||
color: rgb(91, 33, 182) !important;
|
||||
}
|
||||
|
||||
.cm-variable-3:is(.dark *),
|
||||
.cm-tag:is(.dark *),
|
||||
.cm-def:is(.dark *),
|
||||
.cm-attribute:is(.dark *),
|
||||
.cm-number:is(.dark *) {
|
||||
color: rgb(221, 214, 254) !important;
|
||||
}
|
||||
|
||||
.CodeMirror {
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.CodeMirror-line {
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
.CodeMirror-focused .CodeMirror-placeholder {
|
||||
opacity: 50%;
|
||||
}
|
||||
|
||||
.CodeMirror-gutters {
|
||||
border-color: var(--color-super-lowered) !important;
|
||||
background-color: var(--color-lowered) !important;
|
||||
}
|
||||
|
||||
.CodeMirror-hints {
|
||||
background: var(--color-raised) !important;
|
||||
box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size)
|
||||
var(--color-shadow);
|
||||
border-radius: var(--radius) !important;
|
||||
padding: var(--pad-1) !important;
|
||||
border-color: var(--color-super-lowered) !important;
|
||||
}
|
||||
|
||||
.CodeMirror-hints li {
|
||||
color: var(--color-text-raised) !important;
|
||||
border-radius: var(--radius) !important;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
font-size: 10px;
|
||||
padding: calc(var(--pad-1) / 2) var(--pad-2);
|
||||
}
|
||||
|
||||
.CodeMirror-hints li.CodeMirror-hint-active {
|
||||
background-color: var(--color-primary) !important;
|
||||
color: var(--color-text-primary) !important;
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
|
||||
(text "{% if connection_type == \"Spotify\" and user and user.connections.Spotify and config.connections.spotify_client_id %}")
|
||||
(script
|
||||
("defer" "true")
|
||||
(text "setTimeout(async () => {
|
||||
const code = new URLSearchParams(window.location.search).get(\"code\");
|
||||
const client_id = \"{{ config.connections.spotify_client_id }}\";
|
||||
|
@ -46,10 +47,11 @@
|
|||
setTimeout(() => {
|
||||
window.location.href = \"/settings#/connections\";
|
||||
}, 500);
|
||||
}, 150);"))
|
||||
}, 1000);"))
|
||||
|
||||
(text "{% elif connection_type == \"LastFm\" and user and user.connections.LastFm and config.connections.last_fm_key %}")
|
||||
(script
|
||||
("defer" "true")
|
||||
(text "setTimeout(async () => {
|
||||
const token = new URLSearchParams(window.location.search).get(\"token\");
|
||||
const api_key = \"{{ config.connections.last_fm_key }}\";
|
||||
|
|
|
@ -90,7 +90,7 @@
|
|||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
.then(async (res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
|
@ -98,7 +98,7 @@
|
|||
|
||||
if (res.ok) {
|
||||
// update tokens
|
||||
const new_tokens = ns(\"me\").LOGIN_ACCOUNT_TOKENS;
|
||||
const new_tokens = (await ns(\"me\")).LOGIN_ACCOUNT_TOKENS;
|
||||
new_tokens[e.target.username.value] = res.message;
|
||||
trigger(\"me::set_login_account_tokens\", [new_tokens]);
|
||||
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
(div
|
||||
("class" "flex flex-col gap-1")
|
||||
(label
|
||||
("for" "username")
|
||||
("for" "password")
|
||||
(b
|
||||
(text "Password")))
|
||||
(input
|
||||
|
@ -34,6 +34,20 @@
|
|||
("required" "")
|
||||
("name" "password")
|
||||
("id" "password")))
|
||||
(text "{% if config.security.enable_invite_codes -%}")
|
||||
(div
|
||||
("class" "flex flex-col gap-1")
|
||||
(label
|
||||
("for" "invite_code")
|
||||
(b
|
||||
(text "Invite code")))
|
||||
(input
|
||||
("type" "text")
|
||||
("placeholder" "invite code")
|
||||
("required" "")
|
||||
("name" "invite_code")
|
||||
("id" "invite_code")))
|
||||
(text "{%- endif %}")
|
||||
(hr)
|
||||
(div
|
||||
("class" "card-nest w-full")
|
||||
|
@ -56,7 +70,7 @@
|
|||
("href" "{{ config.policies.privacy }}")
|
||||
(text "Privacy policy"))))
|
||||
(div
|
||||
("class" "flex gap-2")
|
||||
("class" "flex items-center gap-2")
|
||||
(input
|
||||
("type" "checkbox")
|
||||
("name" "policy_consent")
|
||||
|
@ -89,10 +103,11 @@
|
|||
captcha_response: e.target.querySelector(
|
||||
\"[name=cf-turnstile-response]\",
|
||||
).value,
|
||||
invite_code: (e.target.invite_code || { value: \"\" }).value,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
.then(async (res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
|
@ -100,7 +115,7 @@
|
|||
|
||||
if (res.ok) {
|
||||
// update tokens
|
||||
const new_tokens = ns(\"me\").LOGIN_ACCOUNT_TOKENS;
|
||||
const new_tokens = (await ns(\"me\")).LOGIN_ACCOUNT_TOKENS;
|
||||
new_tokens[e.target.username.value] = res.message;
|
||||
trigger(\"me::set_login_account_tokens\", [new_tokens]);
|
||||
|
||||
|
|
|
@ -1,9 +1,75 @@
|
|||
(div ("id" "toast_zone"))
|
||||
|
||||
; large text
|
||||
(text "{% if user and user.settings.large_text -%}")
|
||||
(style
|
||||
(text "button, a, p, span, b, strone, em, i, pre, code {
|
||||
font-size: 18px !important;
|
||||
}
|
||||
|
||||
nav .icon {
|
||||
font-size: 15px !important;
|
||||
}"))
|
||||
(text "{%- endif %}")
|
||||
|
||||
; templates
|
||||
(template
|
||||
("id" "loading_skeleton")
|
||||
(div
|
||||
("class" "flex flex-col gap-2")
|
||||
("ui_ident" "loading_skel")
|
||||
(div
|
||||
("class" "card lowered green flex items-center gap-2")
|
||||
(div ("class" "loader") (icon (text "loader-circle")))
|
||||
(span (str (text "general:label.loading"))))
|
||||
(div
|
||||
("class" "card secondary flex gap-2")
|
||||
(div ("class" "skel avatar"))
|
||||
(div
|
||||
("class" "flex flex-col gap-2 w-full")
|
||||
(div ("class" "skel") ("style" "width: 25%; height: 25px;"))
|
||||
(div ("class" "skel") ("style" "width: 100%; height: 150px"))))))
|
||||
|
||||
(template
|
||||
("id" "carp_canvas")
|
||||
(div
|
||||
("class" "flex flex-col gap-2")
|
||||
(div ("ui_ident" "canvas_loc"))
|
||||
(div
|
||||
("class" "flex justify-between gap-2")
|
||||
(div
|
||||
("class" "flex gap-2")
|
||||
(input
|
||||
("type" "color")
|
||||
("style" "width: 5rem")
|
||||
("ui_ident" "color_picker"))
|
||||
|
||||
(input
|
||||
("type" "range")
|
||||
("min" "1")
|
||||
("max" "25")
|
||||
("step" "1")
|
||||
("value" "2")
|
||||
("ui_ident" "stroke_range")))
|
||||
|
||||
(div
|
||||
("class" "flex gap-2")
|
||||
(button
|
||||
("title" "Undo")
|
||||
("ui_ident" "undo")
|
||||
("type" "button")
|
||||
(icon (text "undo")))
|
||||
|
||||
(button
|
||||
("title" "Redo")
|
||||
("ui_ident" "redo")
|
||||
("type" "button")
|
||||
(icon (text "redo")))))))
|
||||
|
||||
; random js
|
||||
(text "<script data-turbo-permanent=\"true\" id=\"init-script\">
|
||||
document.documentElement.addEventListener(\"turbo:load\", () => {
|
||||
const atto = ns(\"atto\");
|
||||
document.documentElement.addEventListener(\"turbo:load\", async () => {
|
||||
const atto = await ns(\"atto\");
|
||||
|
||||
if (!atto) {
|
||||
window.location.reload();
|
||||
|
@ -53,6 +119,10 @@
|
|||
console.log(\"socket disconnect\");
|
||||
}
|
||||
}
|
||||
|
||||
if (window.location.pathname.startsWith(\"/reference\")) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{%- endif %}")
|
||||
|
@ -228,6 +298,17 @@
|
|||
return;
|
||||
}
|
||||
|
||||
while (!ns(\"spotify\")) {
|
||||
console.log(\"still no spotify\");
|
||||
await (() => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, 500);
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
window.spotify_init = true;
|
||||
const client_id = \"{{ config.connections.spotify_client_id }}\";
|
||||
let token = \"{{ user.connections.Spotify[0].data.token }}\";
|
||||
|
@ -243,21 +324,11 @@
|
|||
|
||||
if (playing.error) {
|
||||
// refresh token
|
||||
const [new_token, new_refresh_token, expires_in] =
|
||||
await trigger(\"spotify::refresh_token\", [
|
||||
client_id,
|
||||
const [new_token, new_refresh_token] =
|
||||
await trigger(\"spotify::refresh\", [
|
||||
refresh_token,
|
||||
]);
|
||||
|
||||
await trigger(\"connections::push_con_data\", [
|
||||
\"Spotify\",
|
||||
{
|
||||
token: new_token,
|
||||
expires_in: expires_in.toString(),
|
||||
name: profile.display_name,
|
||||
},
|
||||
]);
|
||||
|
||||
token = new_token;
|
||||
refresh_token = new_refresh_token;
|
||||
return;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "Chats - {{ config.name }}"))
|
||||
|
||||
(link ("rel" "stylesheet") ("data-turbo-temporary" "true") ("href" "/css/chats.css?v=tetratto-{{ random_cache_breaker }}"))
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"chats\", hide_user_menu=true) }}")
|
||||
(nav
|
||||
("class" "chats_nav")
|
||||
|
@ -16,7 +16,6 @@
|
|||
(b
|
||||
(text "{{ text \"chats:label.my_chats\" }}"))
|
||||
(text "{%- endif %}")))
|
||||
|
||||
(div
|
||||
("class" "flex")
|
||||
(div
|
||||
|
@ -87,7 +86,7 @@
|
|||
(text "{{ components::user_plate(user=user, show_menu=true) }}"))
|
||||
(text "{% if channel -%}")
|
||||
(div
|
||||
("class" "w-full flex flex-col gap-2")
|
||||
("class" "w-full flex flex-col gap-2 padded_section")
|
||||
("id" "stream")
|
||||
("style" "padding: var(--pad-4)")
|
||||
(turbo-frame
|
||||
|
@ -110,225 +109,23 @@
|
|||
("title" "Send")
|
||||
(text "{{ icon \"send-horizontal\" }}"))))
|
||||
(text "{%- endif %}")
|
||||
(style
|
||||
(text ":root {
|
||||
--list-bar-width: 64px;
|
||||
--channels-bar-width: 256px;
|
||||
--sidebar-height: calc(100dvh - 42px);
|
||||
--channel-header-height: 48px;
|
||||
|
||||
; emoji picker
|
||||
(text "{{ components::emoji_picker(element_id=\"\", render_dialog=true, render_button=false) }}")
|
||||
(input ("id" "react_emoji_picker_field") ("class" "hidden") ("type" "hidden"))
|
||||
|
||||
(script
|
||||
(text "window.EMOJI_PICKER_MODE = \"replace\";
|
||||
document.getElementById(\"react_emoji_picker_field\").addEventListener(\"change\", (e) => {
|
||||
if (!EMOJI_PICKER_REACTION_MESSAGE_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
const emoji = e.target.value === \"::\" ? \":heart:\" : e.target.value;
|
||||
trigger(\"me::message_react\", [document.getElementById(`message-${EMOJI_PICKER_REACTION_MESSAGE_ID}`), EMOJI_PICKER_REACTION_MESSAGE_ID, emoji]);
|
||||
});"))
|
||||
|
||||
.name.shortest {
|
||||
max-width: 165px;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
.send_button {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.send_button .icon {
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
}
|
||||
|
||||
a.channel_icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
a.channel_icon .icon {
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
a.channel_icon.small {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
a.channel_icon.small .icon {
|
||||
min-width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
a.channel_icon:has(img) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a.channel_icon img {
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
a.channel_icon img,
|
||||
a.channel_icon:has(.icon) {
|
||||
transition:
|
||||
outline 0.25s,
|
||||
background 0.15s !important;
|
||||
}
|
||||
|
||||
a.channel_icon:not(.selected):hover img,
|
||||
a.channel_icon:not(.selected):hover:has(.icon) {
|
||||
outline: solid 1px var(--color-text);
|
||||
}
|
||||
a.channel_icon.selected img,
|
||||
a.channel_icon.selected:has(.icon) {
|
||||
outline: solid 2px var(--color-text);
|
||||
}
|
||||
|
||||
nav {
|
||||
background: var(--color-raised);
|
||||
color: var(--color-text-raised) !important;
|
||||
height: 42px;
|
||||
position: sticky !important;
|
||||
}
|
||||
|
||||
nav::after {
|
||||
display: block;
|
||||
position: absolute;
|
||||
background: var(--color-super-lowered);
|
||||
height: 1px;
|
||||
width: calc(100% - var(--list-bar-width));
|
||||
bottom: 0;
|
||||
left: var(--list-bar-width);
|
||||
content: \"\";
|
||||
}
|
||||
|
||||
nav .content_container {
|
||||
max-width: 100% !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chats_nav {
|
||||
display: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chats_nav button {
|
||||
justify-content: flex-start;
|
||||
width: 100% !important;
|
||||
flex-direction: row !important;
|
||||
font-size: 16px !important;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.chats_nav button svg {
|
||||
margin-right: var(--pad-4);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: var(--color-raised);
|
||||
color: var(--color-text-raised);
|
||||
border-right: solid 1px var(--color-super-lowered);
|
||||
padding: 0.4rem;
|
||||
width: max-content;
|
||||
height: var(--sidebar-height);
|
||||
overflow: auto;
|
||||
transition: left 0.15s;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.sidebar .title:not(.dropdown *) {
|
||||
padding: var(--pad-4);
|
||||
border-bottom: solid 1px var(--color-super-lowered);
|
||||
}
|
||||
|
||||
.sidebar#channels_list {
|
||||
width: var(--channels-bar-width);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
#stream {
|
||||
width: calc(
|
||||
100dvw - var(--list-bar-width) - var(--channels-bar-width)
|
||||
) !important;
|
||||
height: var(--sidebar-height);
|
||||
}
|
||||
|
||||
.message {
|
||||
transition: background 0.15s;
|
||||
box-shadow: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message:hover {
|
||||
background: var(--color-raised);
|
||||
}
|
||||
|
||||
.message:hover .hidden,
|
||||
.message:focus .hidden,
|
||||
.message:active .hidden {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.message.grouped {
|
||||
padding: var(--pad-1) var(--pad-4) var(--pad-1) calc(var(--pad-4) + var(--pad-2) + 42px);
|
||||
}
|
||||
|
||||
turbo-frame {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.channel_header {
|
||||
height: var(--channel-header-height);
|
||||
}
|
||||
|
||||
.members_list_half {
|
||||
padding-top: var(--pad-4);
|
||||
border-top: solid 1px var(--color-super-lowered);
|
||||
}
|
||||
|
||||
.channels_list_half:not(.no_members),
|
||||
.members_list_half {
|
||||
overflow: auto;
|
||||
height: calc(
|
||||
(var(--sidebar-height) - var(--channel-header-height) - 8rem) /
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 900px) {
|
||||
:root {
|
||||
--sidebar-height: calc(100dvh - 42px * 2);
|
||||
}
|
||||
|
||||
.message.grouped {
|
||||
padding: var(--pad-1) var(--pad-4) var(--pad-1) calc(var(--pad-4) + var(--pad-2) + 31px);
|
||||
}
|
||||
|
||||
body:not(.sidebars_shown) .sidebar {
|
||||
position: absolute;
|
||||
left: -200%;
|
||||
}
|
||||
|
||||
body.sidebars_shown .sidebar {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
#stream {
|
||||
width: 100dvw !important;
|
||||
height: var(--sidebar-height);
|
||||
}
|
||||
|
||||
nav::after {
|
||||
width: 100dvw;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.chats_nav {
|
||||
display: flex;
|
||||
}
|
||||
}"))
|
||||
; ...
|
||||
(script
|
||||
(text "window.CURRENT_PAGE = Number.parseInt(\"{{ page }}\");
|
||||
window.VIEWING_SINGLE = \"{{ message }}\".length > 0;
|
||||
|
@ -654,6 +451,7 @@
|
|||
const clean_text = () => {
|
||||
trigger(\"atto::clean_date_codes\");
|
||||
trigger(\"atto::hooks::online_indicator\");
|
||||
trigger(\"atto::hooks::check_message_reactions\");
|
||||
};
|
||||
|
||||
document.addEventListener(
|
||||
|
@ -684,5 +482,4 @@
|
|||
}
|
||||
}, 100);"))
|
||||
(text "{%- endif %}"))
|
||||
|
||||
(text "{% endblock %}")
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
(b
|
||||
(text "{{ text \"chats:label.viewing_old_messages\" }}"))
|
||||
(a
|
||||
("href" "/chats/{{ community }}/{{ channel }}/_stream?page={{ page - 1}}")
|
||||
("href" "/chats/{{ community }}/{{ channel.id }}/_stream?page={{ page - 1}}")
|
||||
("class" "button small")
|
||||
("onclick" "window.CURRENT_PAGE -= 1")
|
||||
(text "{{ text \"chats:label.go_back\" }}")))
|
||||
|
@ -20,7 +20,7 @@
|
|||
(b
|
||||
(text "{{ text \"chats:label.viewing_single_message\" }}"))
|
||||
(a
|
||||
("href" "/chats/{{ community }}/{{ channel }}?page={{ page }}")
|
||||
("href" "/chats/{{ community }}/{{ channel.id }}?page={{ page }}")
|
||||
("class" "button small")
|
||||
("onclick" "window.VIEWING_SINGLE = false")
|
||||
("target" "_top")
|
||||
|
@ -30,7 +30,7 @@
|
|||
("class" "flex gap-2 w-full justify-center")
|
||||
(a
|
||||
("class" "button")
|
||||
("href" "/chats/{{ community }}/{{ channel }}/_stream?page={{ page + 1 }}")
|
||||
("href" "/chats/{{ community }}/{{ channel.id }}/_stream?page={{ page + 1 }}")
|
||||
("onclick" "window.CURRENT_PAGE += 1")
|
||||
(text "{{ icon \"clock\" }}")
|
||||
(span
|
||||
|
|
|
@ -139,7 +139,7 @@
|
|||
("id" "files_list")
|
||||
("class" "flex gap-2 flex-wrap"))
|
||||
(div
|
||||
("class" "flex justify-between gap-2")
|
||||
("class" "flex justify-between flex-collapse gap-2")
|
||||
(text "{{ components::create_post_options() }}")
|
||||
(div
|
||||
("class" "flex gap-2")
|
||||
|
|
|
@ -572,9 +572,9 @@
|
|||
(text "{%- endif %}"))
|
||||
|
||||
(script
|
||||
(text "setTimeout(() => {
|
||||
(text "setTimeout(async () => {
|
||||
const element = document.getElementById(\"membership_info\");
|
||||
const ui = ns(\"ui\");
|
||||
const ui = await ns(\"ui\");
|
||||
|
||||
const uid = new URLSearchParams(window.location.search).get(\"uid\");
|
||||
if (uid) {
|
||||
|
@ -665,7 +665,7 @@
|
|||
`/api/v1/communities/{{ community.id }}/memberships/${e.target.uid.value}`,
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
.then(async (res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
|
@ -676,7 +676,7 @@
|
|||
}
|
||||
|
||||
// permissions manager
|
||||
const get_permissions_html = trigger(
|
||||
const get_permissions_html = await trigger(
|
||||
\"ui::generate_permissions_ui\",
|
||||
[
|
||||
{
|
||||
|
@ -750,8 +750,8 @@
|
|||
(text "{{ community.context|json_encode()|safe }}"))
|
||||
|
||||
(script
|
||||
(text "setTimeout(() => {
|
||||
const ui = ns(\"ui\");
|
||||
(text "setTimeout(async () => {
|
||||
const ui = await ns(\"ui\");
|
||||
const settings = JSON.parse(
|
||||
document.getElementById(\"settings_json\").innerHTML,
|
||||
);
|
||||
|
|
|
@ -72,7 +72,7 @@
|
|||
("style" "display: contents")
|
||||
(text "{% if user.settings.display_name -%} {{ user.settings.display_name }} {% else %} {{ user.username }} {%- endif %}"))
|
||||
|
||||
(text "{%- endmacro %} {% macro likes(id, asset_type, likes=0, dislikes=0, secondary=false) -%}")
|
||||
(text "{%- endmacro %} {% macro likes(id, asset_type, likes=0, dislikes=0, secondary=false, disable_dislikes=false) -%}")
|
||||
(button
|
||||
("title" "Like")
|
||||
("class" "{% if secondary -%}lowered{% else %}camo{%- endif %} small")
|
||||
|
@ -83,7 +83,7 @@
|
|||
(text "{{ likes }}"))
|
||||
(text "{%- endif %}"))
|
||||
|
||||
(text "{% if not user or not user.settings.hide_dislikes -%}")
|
||||
(text "{% if not user or not user.settings.hide_dislikes and not disable_dislikes -%}")
|
||||
(button
|
||||
("title" "Dislike")
|
||||
("class" "{% if secondary -%}lowered{% else %}camo{%- endif %} small")
|
||||
|
@ -94,7 +94,7 @@
|
|||
(text "{{ dislikes }}"))
|
||||
(text "{%- endif %}"))
|
||||
|
||||
(text "{%- endif %} {%- endmacro %} {% macro full_username(user) -%}")
|
||||
(text "{%- endif %} {%- endmacro %} {% macro full_username(user) -%} {% if user and user.username -%}")
|
||||
(div
|
||||
("class" "flex items-center")
|
||||
(a
|
||||
|
@ -110,14 +110,14 @@
|
|||
("class" "flex items-center")
|
||||
(text "{{ icon \"badge-check\" }}"))
|
||||
(text "{%- endif %}"))
|
||||
|
||||
(text "{%- endmacro %} {% macro repost(repost, post, owner, secondary=false, community=false, show_community=true, can_manage_post=false) -%}")
|
||||
(text "{%- endif %} {%- endmacro %} {% macro repost(repost, post, owner, secondary=false, community=false, show_community=true, can_manage_post=false) -%}")
|
||||
(div
|
||||
("style" "display: contents")
|
||||
(text "{{ self::post(post=post, owner=owner, secondary=secondary, community=community, show_community=show_community, can_manage_post=can_manage_post, repost=repost, expect_repost=true) }}"))
|
||||
(text "{%- endmacro %} {% macro post(post, owner, question=false, secondary=false, community=false, show_community=true, can_manage_post=false, repost=false, expect_repost=false, poll=false, dont_show_title=false) -%} {% if community and show_community and community.id != config.town_square or question %}")
|
||||
(text "{%- endmacro %} {% macro post(post, owner, question=false, secondary=false, community=false, show_community=true, can_manage_post=false, repost=false, expect_repost=false, poll=false, dont_show_title=false, is_repost=false) -%} {% if community and show_community and community.id != config.town_square or question %}")
|
||||
(div
|
||||
("class" "card-nest")
|
||||
("class" "card-nest post_outer:{{ post.id }} post_outer")
|
||||
("is_repost" "{{ is_repost }}")
|
||||
(text "{% if question -%} {{ self::question(question=question[0], owner=question[1], profile=owner) }} {% else %}")
|
||||
(div
|
||||
("class" "card small")
|
||||
|
@ -130,10 +130,10 @@
|
|||
(text "{% if post.context.is_pinned or post.context.is_profile_pinned -%} {{ icon \"pin\" }} {%- endif %}")))
|
||||
(text "{%- endif %} {%- endif %}")
|
||||
(div
|
||||
("class" "card flex flex-col gap-2 {% if secondary -%}secondary{%- endif %}")
|
||||
("id" "post:{{ post.id }}")
|
||||
("class" "card flex flex-col post gap-2 post:{{ post.id }} {% if secondary -%}secondary{%- endif %}")
|
||||
("data-community" "{{ post.community }}")
|
||||
("data-ownsup" "{{ owner.permissions|has_supporter }}")
|
||||
("data-id" "{{ post.id }}")
|
||||
("hook" "verify_emojis")
|
||||
(div
|
||||
("class" "w-full flex gap-2")
|
||||
|
@ -174,10 +174,11 @@
|
|||
("style" "color: var(--color-primary)")
|
||||
(text "{{ icon \"square-asterisk\" }}"))
|
||||
(text "{%- endif %} {% if post.stack -%}")
|
||||
(span
|
||||
(a
|
||||
("title" "Posted to a stack you're in")
|
||||
("class" "flex items-center")
|
||||
("class" "flex items-center flush")
|
||||
("style" "color: var(--color-primary)")
|
||||
("href" "/stacks/{{ post.stack }}")
|
||||
(text "{{ icon \"layers\" }}"))
|
||||
(text "{%- endif %} {% if community and community.is_forge -%} {% if post.is_open -%}")
|
||||
(span
|
||||
|
@ -215,7 +216,7 @@
|
|||
("class" "flush")
|
||||
("href" "/post/{{ post.id }}")
|
||||
(h2
|
||||
("id" "post-content:{{ post.id }}")
|
||||
("id" "post_content:{{ post.id }}")
|
||||
("class" "no_p_margin post_content")
|
||||
("hook" "long")
|
||||
(text "{{ post.title }}"))
|
||||
|
@ -224,7 +225,6 @@
|
|||
(text "{% else %}")
|
||||
(text "{% if not post.context.content_warning -%}")
|
||||
(span
|
||||
("id" "post-content:{{ post.id }}")
|
||||
("class" "no_p_margin post_content")
|
||||
("hook" "long")
|
||||
|
||||
|
@ -235,12 +235,13 @@
|
|||
(text "{%- endif %}")
|
||||
|
||||
; content
|
||||
(text "{{ post.content|markdown|safe }} {% if expect_repost -%} {% if repost -%} {{ self::post(post=repost[1], owner=repost[0], secondary=not secondary, community=false, show_community=false, can_manage_post=false) }} {% else %}")
|
||||
(span ("id" "post_content:{{ post.id }}") (text "{{ post.content|markdown|safe }}"))
|
||||
(text "{% if expect_repost -%} {% if repost -%} {{ self::post(post=repost[1], owner=repost[0], secondary=not secondary, community=false, show_community=false, can_manage_post=false, is_repost=true) }} {% else %}")
|
||||
(div
|
||||
("class" "card lowered red flex items-center gap-2")
|
||||
(text "{{ icon \"frown\" }}")
|
||||
(span
|
||||
(text "Could not find original post...")))
|
||||
(str (text "general:label.could_not_find_post"))))
|
||||
(text "{%- endif %} {%- endif %}"))
|
||||
(text "{{ self::post_media(upload_ids=post.uploads) }} {% else %}")
|
||||
(details
|
||||
|
@ -252,7 +253,6 @@
|
|||
(div
|
||||
("class" "flex flex-col gap-2")
|
||||
(span
|
||||
("id" "post-content:{{ post.id }}")
|
||||
("class" "no_p_margin post_content")
|
||||
("hook" "long")
|
||||
|
||||
|
@ -262,7 +262,8 @@
|
|||
(text "{% endif %}")
|
||||
|
||||
; content
|
||||
(text "{{ post.content|markdown|safe }} {% if expect_repost -%} {% if repost -%} {{ self::post(post=repost[1], owner=repost[0], secondary=not secondary, community=false, show_community=false, can_manage_post=false) }} {% else %}")
|
||||
(span ("id" "post_content:{{ post.id }}") (text "{{ post.content|markdown|safe }}"))
|
||||
(text "{% if expect_repost -%} {% if repost -%} {{ self::post(post=repost[1], owner=repost[0], secondary=not secondary, community=false, show_community=false, can_manage_post=false) }} {% else %}")
|
||||
(div
|
||||
("class" "card lowered red flex items-center gap-2")
|
||||
(text "{{ icon \"frown\" }}")
|
||||
|
@ -289,7 +290,7 @@
|
|||
("class" "flex gap-1 reactions_box")
|
||||
("hook" "check_reactions")
|
||||
("hook-arg:id" "{{ post.id }}")
|
||||
(text "{% if post.context.reactions_enabled -%} {% if post.content|length > 0 -%} {{ self::likes(id=post.id, asset_type=\"Post\", likes=post.likes, dislikes=post.dislikes) }} {%- endif %} {%- endif %} {% if post.context.repost and post.context.repost.reposting -%}")
|
||||
(text "{% if post.context.reactions_enabled -%} {% if post.content|length > 0 or post.uploads|length > 0 -%} {{ self::likes(id=post.id, asset_type=\"Post\", likes=post.likes, dislikes=post.dislikes, disable_dislikes=owner.settings.hide_dislikes) }} {%- endif %} {%- endif %} {% if post.context.repost and post.context.repost.reposting -%}")
|
||||
(a
|
||||
("href" "/post/{{ post.context.repost.reposting }}")
|
||||
("class" "button small camo")
|
||||
|
@ -329,7 +330,7 @@
|
|||
("class" "title")
|
||||
(text "{{ text \"general:label.share\" }}"))
|
||||
(button
|
||||
("onclick" "trigger('me::repost', ['{{ post.id }}', '', '{{ config.town_square }}'])")
|
||||
("onclick" "trigger('me::repost', ['{{ post.id }}', '', '{{ config.town_square }}', true])")
|
||||
(text "{{ icon \"repeat-2\" }}")
|
||||
(span
|
||||
(text "{{ text \"communities:label.repost\" }}")))
|
||||
|
@ -339,7 +340,32 @@
|
|||
(text "{{ icon \"quote\" }}")
|
||||
(span
|
||||
(text "{{ text \"communities:label.quote_post\" }}")))
|
||||
(button
|
||||
("onclick" "trigger('me::intent_twitter', [trigger('me::gen_share', [{ q: '{{ post.context.answering }}', p: '{{ post.id }}' }, 280, true])])")
|
||||
(icon (text "bird"))
|
||||
(span
|
||||
(text "Twitter")))
|
||||
(button
|
||||
("onclick" "trigger('me::intent_bluesky', [trigger('me::gen_share', [{ q: '{{ post.context.answering }}', p: '{{ post.id }}' }, 280, true])])")
|
||||
(icon (text "cloud"))
|
||||
(span
|
||||
(text "BlueSky")))
|
||||
(text "{%- endif %}")
|
||||
(text "{% if user.id != post.owner -%}")
|
||||
(b
|
||||
("class" "title")
|
||||
(text "{{ text \"general:label.safety\" }}"))
|
||||
(button
|
||||
("class" "red")
|
||||
("onclick" "trigger('me::report', ['{{ post.id }}', 'post'])")
|
||||
(text "{{ icon \"flag\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:action.report\" }}")))
|
||||
(text "{%- endif %} {% if (user.id == post.owner) or is_helper or can_manage_post %}")
|
||||
(b
|
||||
("class" "title")
|
||||
(text "{{ text \"general:action.manage\" }}"))
|
||||
; forge stuff
|
||||
(text "{% if community and community.is_forge -%} {% if post.is_open -%}")
|
||||
(button
|
||||
("class" "green")
|
||||
|
@ -355,20 +381,7 @@
|
|||
(span
|
||||
(text "{{ text \"forge:action.reopen\" }}")))
|
||||
(text "{%- endif %} {%- endif %}")
|
||||
(text "{% if user.id != post.owner -%}")
|
||||
(b
|
||||
("class" "title")
|
||||
(text "{{ text \"general:label.safety\" }}"))
|
||||
(button
|
||||
("class" "red")
|
||||
("onclick" "trigger('me::report', ['{{ post.id }}', 'post'])")
|
||||
(text "{{ icon \"flag\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:action.report\" }}")))
|
||||
(text "{%- endif %} {% if (user.id == post.owner) or is_helper or can_manage_post %}")
|
||||
(b
|
||||
("class" "title")
|
||||
(text "{{ text \"general:action.manage\" }}"))
|
||||
; owner stuff
|
||||
(text "{% if user.id == post.owner -%}")
|
||||
(a
|
||||
("href" "/post/{{ post.id }}#/edit")
|
||||
|
@ -405,7 +418,7 @@
|
|||
(text "{%- endif %}"))))
|
||||
(text "{% if community and show_community and community.id != config.town_square or question %}"))
|
||||
|
||||
(text "{%- endif %} {%- endmacro %} {% macro post_media(upload_ids) -%} {% if upload_ids|length > 0%}")
|
||||
(text "{%- endif %} {%- endmacro %} {% macro post_media(upload_ids) -%} {% if upload_ids|length > 0 -%}")
|
||||
(div
|
||||
("class" "media_gallery gap-2")
|
||||
(text "{% for upload in upload_ids %}")
|
||||
|
@ -612,7 +625,7 @@
|
|||
|
||||
(text "{%- endif %} {%- endmacro %} {% macro question(question, owner, show_community=true, secondary=false, profile=false) -%}")
|
||||
(div
|
||||
("class" "card {% if secondary -%}secondary{%- endif %} flex gap-2")
|
||||
("class" "card question {% if secondary -%}secondary{%- endif %} flex gap-2")
|
||||
(text "{% if owner.id == 0 -%}")
|
||||
(span
|
||||
(text "{% if profile and profile.settings.anonymous_avatar_url -%}")
|
||||
|
@ -676,7 +689,10 @@
|
|||
(span
|
||||
("class" "no_p_margin")
|
||||
("style" "font-weight: 500")
|
||||
("id" "question_content:{{ question.id }}")
|
||||
(text "{{ question.content|markdown|safe }}"))
|
||||
; question drawings
|
||||
(text "{{ self::post_media(upload_ids=question.drawings) }}")
|
||||
; anonymous user ip thing
|
||||
; this is only shown if the post author is anonymous AND we are a helper
|
||||
(text "{% if is_helper and owner.id == 0 %}")
|
||||
|
@ -693,7 +709,7 @@
|
|||
(div
|
||||
("class" "flex gap-2 items-center justify-between"))))
|
||||
|
||||
(text "{%- endmacro %} {% macro create_question_form(receiver=\"0\", community=\"\", header=\"\", is_global=false) -%}")
|
||||
(text "{%- endmacro %} {% macro create_question_form(receiver=\"0\", community=\"\", header=\"\", is_global=false, drawing_enabled=false) -%}")
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
|
@ -707,6 +723,12 @@
|
|||
("onsubmit" "create_question_from_form(event)")
|
||||
(div
|
||||
("class" "flex flex-col gap-1")
|
||||
; carp canvas
|
||||
(text "{% if drawing_enabled -%}")
|
||||
(div ("ui_ident" "carp_canvas_field"))
|
||||
(text "{%- endif %}")
|
||||
|
||||
; form
|
||||
(label
|
||||
("for" "content")
|
||||
(text "{{ text \"communities:label.content\" }}"))
|
||||
|
@ -718,25 +740,83 @@
|
|||
("required" "")
|
||||
("minlength" "2")
|
||||
("maxlength" "4096")))
|
||||
(div
|
||||
("class" "flex gap-2")
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ text \"communities:action.create\" }}"))))
|
||||
(text "{{ text \"communities:action.create\" }}"))
|
||||
|
||||
(text "{% if drawing_enabled -%}")
|
||||
(button
|
||||
("class" "lowered")
|
||||
("ui_ident" "add_drawing")
|
||||
("onclick" "attach_drawing()")
|
||||
("type" "button")
|
||||
(text "{{ text \"communities:action.draw\" }}"))
|
||||
|
||||
(button
|
||||
("class" "lowered red hidden")
|
||||
("ui_ident" "remove_drawing")
|
||||
("onclick" "remove_drawing()")
|
||||
("type" "button")
|
||||
(text "{{ text \"communities:action.remove_drawing\" }}"))
|
||||
|
||||
(script
|
||||
(text "async function create_question_from_form(e) {
|
||||
(text "globalThis.attach_drawing = async () => {
|
||||
globalThis.gerald = await trigger(\"carp::new\", [document.querySelector(\"[ui_ident=carp_canvas_field]\")]);
|
||||
globalThis.gerald.create_canvas();
|
||||
|
||||
document.querySelector(\"[ui_ident=add_drawing]\").classList.add(\"hidden\");
|
||||
document.querySelector(\"[ui_ident=remove_drawing]\").classList.remove(\"hidden\");
|
||||
}
|
||||
|
||||
globalThis.remove_drawing = async () => {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelector(\"[ui_ident=carp_canvas_field]\").innerHTML = \"\";
|
||||
globalThis.gerald = null;
|
||||
|
||||
document.querySelector(\"[ui_ident=add_drawing]\").classList.remove(\"hidden\");
|
||||
document.querySelector(\"[ui_ident=remove_drawing]\").classList.add(\"hidden\");
|
||||
}"))
|
||||
(text "{%- endif %}"))))
|
||||
|
||||
(script
|
||||
(text "globalThis.gerald = null;
|
||||
async function create_question_from_form(e) {
|
||||
e.preventDefault();
|
||||
await trigger(\"atto::debounce\", [\"questions::create\"]);
|
||||
fetch(\"/api/v1/questions\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
|
||||
// create body
|
||||
const body = new FormData();
|
||||
|
||||
if (globalThis.gerald) {
|
||||
body.append(\"drawing.carpgraph\", new Blob([new Uint8Array(globalThis.gerald.as_carp2())], {
|
||||
type: \"application/octet-stream\"
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
body.append(
|
||||
\"body\",
|
||||
JSON.stringify({
|
||||
content: e.target.content.value,
|
||||
receiver: \"{{ receiver }}\",
|
||||
community: \"{{ community }}\",
|
||||
is_global: \"{{ is_global }}\" == \"true\",
|
||||
}),
|
||||
);
|
||||
|
||||
// ...
|
||||
fetch(\"/api/v1/questions\", {
|
||||
method: \"POST\",
|
||||
body,
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
|
@ -747,6 +827,10 @@
|
|||
|
||||
if (res.ok) {
|
||||
e.target.reset();
|
||||
|
||||
if (globalThis.gerald) {
|
||||
globalThis.gerald.clear();
|
||||
}
|
||||
}
|
||||
});
|
||||
}"))
|
||||
|
@ -956,9 +1040,29 @@
|
|||
(text "{%- endif %}")
|
||||
(div
|
||||
("class" "flex w-full gap-2 justify-between")
|
||||
(div
|
||||
("class" "flex flex-col gap-2")
|
||||
(span
|
||||
("class" "no_p_margin")
|
||||
(text "{{ message.content|markdown|safe }}"))
|
||||
|
||||
(div
|
||||
("class" "flex w-full gap-1 flex-wrap")
|
||||
("onclick" "window.EMOJI_PICKER_REACTION_MESSAGE_ID = '{{ message.id }}'")
|
||||
("hook" "check_message_reactions")
|
||||
("hook-arg:id" "{{ message.id }}")
|
||||
|
||||
(text "{% for emoji,num in message.reactions -%}")
|
||||
(button
|
||||
("class" "small lowered")
|
||||
("ui_ident" "emoji_{{ emoji }}")
|
||||
("onclick" "trigger('me::message_react', [event.target.parentElement.parentElement, '{{ message.id }}', '{{ emoji }}'])")
|
||||
(span (text "{{ emoji|emojis|safe }} {{ num }}")))
|
||||
(text "{%- endfor %}")
|
||||
|
||||
(div
|
||||
("class" "hidden")
|
||||
(text "{{ self::emoji_picker(element_id=\"react_emoji_picker_field\", render_dialog=false, render_button=true, small=true) }}"))))
|
||||
(text "{% if grouped -%}")
|
||||
(div
|
||||
("class" "hidden")
|
||||
|
@ -976,6 +1080,16 @@
|
|||
(text "{{ icon \"circle-user-round\" }}")
|
||||
(span
|
||||
(text "{{ text \"auth:link.my_profile\" }}")))
|
||||
(a
|
||||
("href" "/journals/0/0")
|
||||
(icon (text "notebook"))
|
||||
(str (text "general:link.journals")))
|
||||
(text "{% if not user.settings.disable_achievements -%}")
|
||||
(a
|
||||
("href" "/achievements")
|
||||
(icon (text "award"))
|
||||
(str (text "general:link.achievements")))
|
||||
(text "{%- endif %}")
|
||||
(a
|
||||
("href" "/settings")
|
||||
(text "{{ icon \"settings\" }}")
|
||||
|
@ -1019,6 +1133,18 @@
|
|||
("data-turbo" "false")
|
||||
(icon (text "rabbit"))
|
||||
(str (text "general:link.reference")))
|
||||
|
||||
(a
|
||||
("href" "{{ config.policies.terms_of_service }}")
|
||||
("class" "button")
|
||||
(icon (text "heart-handshake"))
|
||||
(text "Terms of service"))
|
||||
|
||||
(a
|
||||
("href" "{{ config.policies.privacy }}")
|
||||
("class" "button")
|
||||
(icon (text "cookie"))
|
||||
(text "Privacy policy"))
|
||||
(b ("class" "title") (str (text "general:label.account")))
|
||||
(button
|
||||
("onclick" "trigger('me::switch_account')")
|
||||
|
@ -1099,13 +1225,15 @@
|
|||
(text "{{ text \"chats:action.kick_member\" }}")))))
|
||||
(text "{%- endif %}"))
|
||||
|
||||
(text "{%- endmacro %} {% macro emoji_picker(element_id, render_dialog=false) -%}")
|
||||
(text "{%- endmacro %} {% macro emoji_picker(element_id, render_dialog=false, render_button=true, small=false) -%}")
|
||||
(text "{% if render_button -%}")
|
||||
(button
|
||||
("class" "button small square lowered")
|
||||
("class" "button small {% if not small -%} square {%- endif %} lowered")
|
||||
("onclick" "window.EMOJI_PICKER_TEXT_ID = '{{ element_id }}'; document.getElementById('emoji_dialog').showModal()")
|
||||
("title" "Emojis")
|
||||
("type" "button")
|
||||
(text "{{ icon \"smile-plus\" }}"))
|
||||
(text "{%- endif %}")
|
||||
|
||||
(text "{% if render_dialog -%}")
|
||||
(dialog
|
||||
|
@ -1151,19 +1279,40 @@
|
|||
}
|
||||
|
||||
if (event.detail.unicode) {
|
||||
if (window.EMOJI_PICKER_MODE === \"replace\") {
|
||||
document.getElementById(
|
||||
window.EMOJI_PICKER_TEXT_ID,
|
||||
).value += ` :${await (
|
||||
).value = `:${await (
|
||||
await fetch(\"/api/v1/lookup_emoji\", {
|
||||
method: \"POST\",
|
||||
body: event.detail.unicode,
|
||||
})
|
||||
).text()}:`;
|
||||
} else {
|
||||
document.getElementById(
|
||||
window.EMOJI_PICKER_TEXT_ID,
|
||||
).value += ` :${await (
|
||||
await fetch(\"/api/v1/lookup_emoji\", {
|
||||
method: \"POST\",
|
||||
body: event.detail.unicode,
|
||||
})
|
||||
).text()}:`;
|
||||
}
|
||||
} else {
|
||||
if (window.EMOJI_PICKER_MODE === \"replace\") {
|
||||
document.getElementById(
|
||||
window.EMOJI_PICKER_TEXT_ID,
|
||||
).value = `:${event.detail.emoji.shortcodes[0]}:`;
|
||||
} else {
|
||||
document.getElementById(
|
||||
window.EMOJI_PICKER_TEXT_ID,
|
||||
).value += ` :${event.detail.emoji.shortcodes[0]}:`;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById(
|
||||
window.EMOJI_PICKER_TEXT_ID,
|
||||
).dispatchEvent(new Event(\"change\"));
|
||||
|
||||
document.getElementById(\"emoji_dialog\").close();
|
||||
});"))
|
||||
|
@ -1271,7 +1420,7 @@
|
|||
|
||||
(text "{%- endif %} {%- endmacro %} {% macro create_post_options() -%}")
|
||||
(div
|
||||
("class" "flex gap-2")
|
||||
("class" "flex gap-2 flex-wrap")
|
||||
(text "{{ components::emoji_picker(element_id=\"content\", render_dialog=true) }} {% if not quoting -%} {% if is_supporter -%} {{ components::file_picker(files_list_id=\"files_list\") }} {%- endif %} {%- endif %}")
|
||||
|
||||
(button
|
||||
|
@ -1286,7 +1435,20 @@
|
|||
("title" "More options")
|
||||
("onclick" "document.getElementById('post_options_dialog').showModal()")
|
||||
("type" "button")
|
||||
(text "{{ icon \"ellipsis\" }}")))
|
||||
(text "{{ icon \"ellipsis\" }}"))
|
||||
|
||||
(label
|
||||
("class" "flex items-center gap-1 button lowered")
|
||||
("title" "Mark as NSFW/hide from public timelines")
|
||||
("for" "is_nsfw")
|
||||
(input
|
||||
("type" "checkbox")
|
||||
("name" "is_nsfw")
|
||||
("id" "is_nsfw")
|
||||
("checked" "{{ user.settings.auto_unlist }}")
|
||||
("onchange" "POST_INITIAL_SETTINGS['is_nsfw'] = event.target.checked"))
|
||||
|
||||
(span (icon (text "eye-closed")))))
|
||||
|
||||
(dialog
|
||||
("id" "post_options_dialog")
|
||||
|
@ -1346,11 +1508,11 @@
|
|||
window.POST_INITIAL_SETTINGS.reactions_enabled.toString(),
|
||||
\"checkbox\",
|
||||
],
|
||||
[
|
||||
[\"is_nsfw\", \"Hide from public timelines\"],
|
||||
window.POST_INITIAL_SETTINGS.is_nsfw.toString(),
|
||||
\"checkbox\",
|
||||
],
|
||||
// [
|
||||
// [\"is_nsfw\", \"Hide from public timelines\"],
|
||||
// window.POST_INITIAL_SETTINGS.is_nsfw.toString(),
|
||||
// \"checkbox\",
|
||||
// ],
|
||||
[
|
||||
[\"content_warning\", \"Content warning\"],
|
||||
window.POST_INITIAL_SETTINGS.content_warning,
|
||||
|
@ -1839,3 +2001,261 @@
|
|||
(text "{{ stack.created }}"))
|
||||
(text "; {{ stack.privacy }}; {{ stack.users|length }} users")))
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
(text "{% macro journal_listing(journal, notes, selected_note, selected_journal, view_mode=false, owner=false) -%}")
|
||||
(text "{% if selected_journal != journal.id -%}")
|
||||
; not selected
|
||||
(div
|
||||
("class" "flex flex-row gap-1")
|
||||
(a
|
||||
("href" "/journals/{{ journal.id }}/0?view={{ view_mode }}")
|
||||
("class" "button justify-start lowered w-full")
|
||||
(icon (text "notebook"))
|
||||
(text "{{ journal.title }}"))
|
||||
|
||||
(div
|
||||
("class" "dropdown")
|
||||
(button
|
||||
("class" "big_icon lowered")
|
||||
("onclick" "trigger('atto::hooks::dropdown', [event])")
|
||||
("exclude" "dropdown")
|
||||
("style" "width: 32px")
|
||||
(text "{{ icon \"ellipsis\" }}"))
|
||||
(div
|
||||
("class" "inner")
|
||||
(button
|
||||
("onclick" "delete_journal('{{ journal.id }}')")
|
||||
("class" "red")
|
||||
(text "{{ icon \"trash\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:action.delete\" }}"))))))
|
||||
(text "{% else %}")
|
||||
; selected
|
||||
(div
|
||||
("class" "flex flex-row gap-1")
|
||||
(button
|
||||
("class" "justify-start lowered w-full")
|
||||
(icon (text "arrow-down"))
|
||||
(text "{{ journal.title }}"))
|
||||
|
||||
(text "{% if user and user.id == journal.owner -%}")
|
||||
(div
|
||||
("class" "dropdown")
|
||||
(button
|
||||
("class" "big_icon lowered")
|
||||
("onclick" "trigger('atto::hooks::dropdown', [event])")
|
||||
("exclude" "dropdown")
|
||||
("style" "width: 32px")
|
||||
(text "{{ icon \"ellipsis\" }}"))
|
||||
(div
|
||||
("class" "inner")
|
||||
(a
|
||||
("class" "button")
|
||||
("href" "/journals/{{ journal.id }}/0?view={{ view_mode }}")
|
||||
(icon (text "house"))
|
||||
(str (text "general:link.home")))
|
||||
(button
|
||||
("onclick" "delete_journal('{{ journal.id }}')")
|
||||
("class" "red")
|
||||
(icon (text "trash"))
|
||||
(str (text "general:action.delete")))))
|
||||
(text "{%- endif %}"))
|
||||
|
||||
(text "{% if selected_note -%}")
|
||||
; open all details elements above the selected note
|
||||
(script
|
||||
("defer" "true")
|
||||
(text "setTimeout(() => {
|
||||
let cursor = document.querySelector(\"[ui_ident=active_note]\");
|
||||
while (cursor) {
|
||||
if (cursor.nodeName === \"DETAILS\") {
|
||||
cursor.setAttribute(\"open\", \"true\");
|
||||
}
|
||||
|
||||
cursor = cursor.parentElement;
|
||||
}
|
||||
}, 150);"))
|
||||
(text "{%- endif %}")
|
||||
|
||||
(div
|
||||
("class" "flex flex-col gap-2")
|
||||
("style" "margin-left: 10px; padding-left: 5px; border-left: solid 2px var(--color-super-lowered); width: calc(100% - 10px)")
|
||||
; create note
|
||||
(text "{% if user and user.id == journal.owner -%}")
|
||||
(button
|
||||
("class" "lowered justify-start w-full")
|
||||
("onclick" "create_note()")
|
||||
(icon (text "plus"))
|
||||
(str (text "journals:action.create_note")))
|
||||
(text "{%- endif %}")
|
||||
|
||||
; note listings
|
||||
(text "{{ self::notes_list_dir_listing_inner(dir=[0, 0, \"root\"], dirs=journal.dirs, notes=notes, owner=owner, journal=journal, view_mode=view_mode) }}"))
|
||||
(text "{%- endif %}")
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
(text "{% macro notes_list_dir_listing(dir, dirs, notes, owner, journal, view_mode=false) -%}")
|
||||
(details
|
||||
(summary
|
||||
("class" "button w-full justify-start raised w-full")
|
||||
(icon (text "folder"))
|
||||
(text "{{ dir[2] }}"))
|
||||
|
||||
(div
|
||||
("class" "flex flex-col gap-2")
|
||||
("style" "margin-left: 10px; padding-left: 5px; border-left: solid 2px var(--color-super-lowered); width: calc(100% - 10px)")
|
||||
(text "{{ self::notes_list_dir_listing_inner(dir=dir, dirs=dirs, notes=notes, owner=owner, journal=journal, view_mode=view_mode) }}")))
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
(text "{% macro notes_list_dir_listing_inner(dir, dirs, notes, owner, journal, view_mode=false) -%}")
|
||||
; child dirs
|
||||
(text "{% for subdir in dirs %} {% if subdir[1] == dir[0] -%}")
|
||||
(text "{{ self::notes_list_dir_listing(dir=subdir, dirs=dirs, notes=notes, owner=owner, journal=journal) }}")
|
||||
(text "{%- endif %} {% endfor %}")
|
||||
|
||||
; child notes
|
||||
(text "{% for note in notes %} {% if note.dir == dir[0] -%} {% if not view_mode or note.title != \"journal.css\" -%}")
|
||||
(text "{{ self::notes_list_note_listing(note=note, owner=owner, journal=journal) }}")
|
||||
(text "{%- endif %} {%- endif %} {% endfor %}")
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
(text "{% macro notes_list_note_listing(owner, journal, note) -%}")
|
||||
(div
|
||||
("class" "flex flex-row gap-1")
|
||||
("ui_ident" "{% if selected_note == note.id -%} active_note {%- else -%} inactive_note {%- endif %}")
|
||||
(a
|
||||
("href" "{% if owner -%} /@{{ owner.username }}/{{ journal.title }}/{{ note.title }} {%- else -%} /journals/{{ journal.id }}/{{ note.id }} {%- endif %}")
|
||||
("class" "button justify-start w-full {% if selected_note == note.id -%} lowered {%- else -%} raised {%- endif %}")
|
||||
(icon (text "file-text"))
|
||||
(text "{{ note.title }}"))
|
||||
|
||||
(text "{% if user and user.id == journal.owner -%}")
|
||||
(div
|
||||
("class" "dropdown")
|
||||
(button
|
||||
("class" "big_icon {% if selected_note == note.id -%} lowered {%- else -%} raised {%- endif %}")
|
||||
("onclick" "trigger('atto::hooks::dropdown', [event])")
|
||||
("exclude" "dropdown")
|
||||
("style" "width: 32px")
|
||||
(text "{{ icon \"ellipsis\" }}"))
|
||||
(div
|
||||
("class" "inner")
|
||||
(button
|
||||
("onclick" "change_note_title('{{ note.id }}')")
|
||||
(icon (text "pencil"))
|
||||
(str (text "chats:action.rename")))
|
||||
(a
|
||||
("href" "/journals/{{ journal.id }}/{{ note.id }}#/tags")
|
||||
(icon (text "tag"))
|
||||
(str (text "journals:action.edit_tags")))
|
||||
(button
|
||||
("onclick" "window.NOTE_MOVER_NOTE_ID = '{{ note.id }}'; document.getElementById('note_mover_dialog').showModal()")
|
||||
(icon (text "brush-cleaning"))
|
||||
(str (text "journals:action.move")))
|
||||
(text "{% if note.is_global -%}")
|
||||
(a
|
||||
("class" "button")
|
||||
("href" "/x/{{ note.title }}")
|
||||
(icon (text "eye"))
|
||||
(str (text "journals:action.view")))
|
||||
|
||||
(button
|
||||
("class" "purple")
|
||||
("onclick" "unpublish_note('{{ note.id }}')")
|
||||
(icon (text "globe-lock"))
|
||||
(str (text "journals:action.unpublish")))
|
||||
(text "{% elif note.title != 'journal.css' %}")
|
||||
(button
|
||||
("class" "green")
|
||||
("onclick" "publish_note('{{ note.id }}')")
|
||||
(icon (text "globe"))
|
||||
(str (text "journals:action.publish")))
|
||||
(text "{%- endif %}")
|
||||
(button
|
||||
("onclick" "delete_note('{{ note.id }}')")
|
||||
("class" "red")
|
||||
(icon (text "trash"))
|
||||
(str (text "general:action.delete")))))
|
||||
(text "{%- endif %}"))
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
(text "{% macro note_tags(note) -%} {% if note and note.tags|length > 0 -%}")
|
||||
(div
|
||||
("class" "flex gap-1 flex-wrap")
|
||||
(text "{% for tag in note.tags %}")
|
||||
(a
|
||||
("href" "{% if view_mode -%} /@{{ owner.username }} {%- else -%} /@{{ user.username }} {%- endif -%} /{{ journal.title }}?tag={{ tag }}")
|
||||
("class" "notification chip")
|
||||
(span (text "{{ tag }}")))
|
||||
(text "{% endfor %}"))
|
||||
(text "{%- endif %} {%- endmacro %}")
|
||||
|
||||
(text "{% macro directories_editor(dirs) -%}")
|
||||
(button
|
||||
("onclick" "create_directory('0')")
|
||||
(icon (text "plus"))
|
||||
(str (text "journals:action.create_root_dir")))
|
||||
|
||||
(text "{% for dir in dirs %} {% if dir[1] == 0 -%}")
|
||||
(text "{{ self::directories_editor_listing(dir=dir, dirs=dirs) }}")
|
||||
(text "{%- endif %} {% endfor %}")
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
(text "{% macro directories_editor_listing(dir, dirs) -%}")
|
||||
(div
|
||||
("class" "flex flex-row gap-1")
|
||||
(button
|
||||
("class" "justify-start lowered w-full")
|
||||
(icon (text "folder-open"))
|
||||
(text "{{ dir[2] }}"))
|
||||
|
||||
(div
|
||||
("class" "dropdown")
|
||||
(button
|
||||
("class" "big_icon lowered")
|
||||
("onclick" "trigger('atto::hooks::dropdown', [event])")
|
||||
("exclude" "dropdown")
|
||||
("style" "width: 32px")
|
||||
(text "{{ icon \"ellipsis\" }}"))
|
||||
(div
|
||||
("class" "inner")
|
||||
(button
|
||||
("onclick" "create_directory('{{ dir[0] }}')")
|
||||
(icon (text "plus"))
|
||||
(str (text "journals:action.create_subdir")))
|
||||
(button
|
||||
("onclick" "delete_directory('{{ dir[0] }}')")
|
||||
("class" "red")
|
||||
(icon (text "trash"))
|
||||
(str (text "general:action.delete"))))))
|
||||
|
||||
(div
|
||||
("class" "flex flex-col gap-2")
|
||||
("style" "margin-left: 10px; padding-left: 5px; border-left: solid 2px var(--color-super-lowered); width: calc(100% - 10px)")
|
||||
; subdir listings
|
||||
(text "{% for subdir in dirs %} {% if subdir[1] == dir[0] -%}")
|
||||
(text "{{ self::directories_editor_listing(dir=subdir, dirs=dirs) }}")
|
||||
(text "{%- endif %} {% endfor %}"))
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
(text "{% macro note_mover_dirs(dirs) -%}")
|
||||
(text "{% for dir in dirs %} {% if dir[1] == 0 -%}")
|
||||
(text "{{ self::note_mover_dirs_listing(dir=dir, dirs=dirs) }}")
|
||||
(text "{%- endif %} {% endfor %}")
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
(text "{% macro note_mover_dirs_listing(dir, dirs) -%}")
|
||||
(button
|
||||
("onclick" "move_note_dir(window.NOTE_MOVER_NOTE_ID, '{{ dir[0] }}'); document.getElementById('note_mover_dialog').close()")
|
||||
("class" "justify-start lowered w-full")
|
||||
(icon (text "folder-open"))
|
||||
(text "{{ dir[2] }}"))
|
||||
|
||||
(div
|
||||
("class" "flex flex-col gap-2")
|
||||
("style" "margin-left: 10px; padding-left: 5px; border-left: solid 2px var(--color-super-lowered); width: calc(100% - 10px)")
|
||||
; subdir listings
|
||||
(text "{% for subdir in dirs %} {% if subdir[1] == dir[0] -%}")
|
||||
(text "{{ self::note_mover_dirs_listing(dir=subdir, dirs=dirs) }}")
|
||||
(text "{%- endif %} {% endfor %}"))
|
||||
(text "{%- endmacro %}")
|
||||
|
|
|
@ -129,7 +129,7 @@
|
|||
(pre ("class" "hidden red w-full") (code ("id" "scope_error_message") ("style" "white-space: pre-wrap")))
|
||||
|
||||
(details
|
||||
(summary ("class" "button lowered small") (icon (text "circle-help")) (text "Help"))
|
||||
(summary ("class" "button lowered small") (icon (text "circle-question-mark")) (text "Help"))
|
||||
(div
|
||||
("class" "card flex flex-col gap-1")
|
||||
(span ("class" "fade") (text "Scopes should be separated by a single space."))
|
||||
|
|
|
@ -92,7 +92,7 @@
|
|||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small flex items-center gap-2")
|
||||
(icon (text "circle-help"))
|
||||
(icon (text "circle-question-mark"))
|
||||
(str (text "developer:label.guides_and_help")))
|
||||
|
||||
(div
|
||||
|
|
967
crates/app/src/public/html/journals/app.lisp
Normal file
967
crates/app/src/public/html/journals/app.lisp
Normal file
|
@ -0,0 +1,967 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
|
||||
(text "{% if journal -%} {% if note -%}")
|
||||
(title (text "{{ note.title }}"))
|
||||
(text "{% else %}")
|
||||
(title (text "{{ journal.title }}"))
|
||||
(text "{%- endif %} {% else %}")
|
||||
(title (text "Journals - {{ config.name }}"))
|
||||
(text "{%- endif %}")
|
||||
|
||||
(text "{% if note and journal and owner -%}")
|
||||
(meta
|
||||
("name" "og:title")
|
||||
("content" "{{ note.title }}"))
|
||||
|
||||
(text "{% if not global_mode -%}")
|
||||
(meta
|
||||
("name" "description")
|
||||
("content" "View this note from the {{ journal.title }} journal on {{ config.name }}!"))
|
||||
|
||||
(meta
|
||||
("name" "og:description")
|
||||
("content" "View this note from the {{ journal.title }} journal on {{ config.name }}!"))
|
||||
|
||||
(meta
|
||||
("name" "twitter:description")
|
||||
("content" "View this note from the {{ journal.title }} journal on {{ config.name }}!"))
|
||||
(text "{% else %}")
|
||||
(meta
|
||||
("name" "description")
|
||||
("content" "View this note on {{ config.name }}!"))
|
||||
|
||||
(meta
|
||||
("name" "og:description")
|
||||
("content" "View this note on {{ config.name }}!"))
|
||||
|
||||
(meta
|
||||
("name" "twitter:description")
|
||||
("content" "View this note on {{ config.name }}!"))
|
||||
(text "{%- endif %}")
|
||||
|
||||
(meta
|
||||
("property" "og:type")
|
||||
("content" "website"))
|
||||
|
||||
(meta
|
||||
("name" "og:image")
|
||||
("content" "{{ config.host|safe }}/api/v1/auth/user/{{ owner.username }}/avatar?selector_type=username"))
|
||||
|
||||
(meta
|
||||
("name" "twitter:image")
|
||||
("content" "{{ config.host|safe }}/api/v1/auth/user/{{ owner.username }}/avatar?selector_type=usernamev"))
|
||||
|
||||
(meta
|
||||
("name" "twitter:card")
|
||||
("content" "summary"))
|
||||
|
||||
(meta
|
||||
("name" "twitter:title")
|
||||
("content" "{{ note.title }}"))
|
||||
(text "{%- endif %}")
|
||||
(link ("rel" "stylesheet") ("data-turbo-temporary" "true") ("href" "/css/chats.css?v=tetratto-{{ random_cache_breaker }}"))
|
||||
|
||||
(style
|
||||
(text "html, body {
|
||||
overflow: hidden auto !important;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 42px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 900px) {
|
||||
.sidebar {
|
||||
position: absolute;
|
||||
top: unset;
|
||||
}
|
||||
|
||||
body.sidebars_shown {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
}"))
|
||||
|
||||
(text "{% if view_mode and journal and is_editor -%} {% if note -%}")
|
||||
; redirect to note
|
||||
(meta ("http-equiv" "refresh") ("content" "0; url=/@{{ user.username }}/{{ journal.title }}/{{ note.title }}"))
|
||||
(text "{% else %}")
|
||||
; redirect to journal homepage
|
||||
(meta ("http-equiv" "refresh") ("content" "0; url=/@{{ user.username }}/{{ journal.title }}"))
|
||||
(text "{%- endif %} {%- endif %}")
|
||||
|
||||
(text "{% if view_mode and journal -%}")
|
||||
; add journal css
|
||||
(link ("rel" "stylesheet") ("data-turbo-temporary" "true") ("href" "/api/v1/journals/{{ journal.id }}/journal.css?v=tetratto-{{ random_cache_breaker }}"))
|
||||
(text "{%- endif %}")
|
||||
(text "{% endblock %} {% block body %} {% if not global_mode -%} {{ macros::nav(selected=\"journals\") }} {%- endif %}")
|
||||
(text "{% if not view_mode -%}")
|
||||
(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 \"journals:label.my_journals\" }}"))
|
||||
(text "{%- endif %}")))
|
||||
(text "{%- endif %}")
|
||||
(div
|
||||
("class" "flex")
|
||||
; journals/notes listing
|
||||
(text "{% if not view_mode -%}")
|
||||
; this isn't shown if we're in view mode
|
||||
(div
|
||||
("class" "sidebar flex flex-col gap-2 justify-between")
|
||||
("id" "notes_list")
|
||||
(div
|
||||
("class" "flex flex-col gap-2 w-full")
|
||||
(button
|
||||
("class" "lowered justify-start w-full")
|
||||
("onclick" "create_journal()")
|
||||
(icon (text "plus"))
|
||||
(str (text "journals:action.create_journal")))
|
||||
|
||||
(text "{% for journal in journals %}")
|
||||
(text "{{ components::journal_listing(journal=journal, notes=notes, selected_note=selected_note, selected_journal=selected_journal, view_mode=view_mode) }}")
|
||||
(text "{% endfor %}")))
|
||||
(text "{%- endif %}")
|
||||
; editor
|
||||
(div
|
||||
("class" "w-full padded_section")
|
||||
("id" "editor")
|
||||
("style" "padding: var(--pad-4)")
|
||||
(main
|
||||
("class" "flex flex-col gap-2")
|
||||
; the journal/note header is always shown
|
||||
(text "{% if journal and not global_mode -%}")
|
||||
(div
|
||||
("class" "mobile_nav w-full flex items-center justify-between gap-2")
|
||||
(div
|
||||
("class" "flex flex-col gap-2")
|
||||
(div
|
||||
("class" "flex gap-2 items-center")
|
||||
(a
|
||||
("class" "flex items-center")
|
||||
("href" "/@{{ owner.username }}")
|
||||
(text "{{ components::avatar(username=owner.username, selector_type=\"username\", size=\"18px\") }}"))
|
||||
|
||||
(text "{% if (view_mode and owner) or not view_mode -%}")
|
||||
(a
|
||||
("class" "flush")
|
||||
("href" "{% if view_mode -%} /@{{ owner.username }}/{{ journal.title }} {%- else -%} /journals/{{ journal.id }}/0 {%- endif %}")
|
||||
(b (text "{{ journal.title }}")))
|
||||
(text "{%- endif %}")
|
||||
|
||||
(text "{% if note -%}")
|
||||
(span (text "/"))
|
||||
(b (text "{{ note.title }}"))
|
||||
(text "{%- endif %}")))
|
||||
|
||||
(text "{% if user and user.id == journal.owner and (not note or note.title != \"journal.css\") -%}")
|
||||
(div
|
||||
("class" "pillmenu")
|
||||
(a
|
||||
("class" "{% if not view_mode -%}active{%- endif %}")
|
||||
("href" "/journals/{{ journal.id }}/{% if note -%} {{ note.id }} {%- else -%} 0 {%- endif %}")
|
||||
("data-turbo" "false")
|
||||
(icon (text "pencil")))
|
||||
(a
|
||||
("class" "{% if view_mode -%}active{%- endif %}")
|
||||
("href" "/@{{ user.username }}/{{ journal.title }}{% if note -%} /{{ note.title }} {%- endif %}")
|
||||
(icon (text "eye"))))
|
||||
(text "{%- endif %}"))
|
||||
(text "{%- endif %}")
|
||||
|
||||
; we're going to put some help panes in here if something is 0
|
||||
; ...people got confused on the chats page because they somehow couldn't figure out how to open the sidebar
|
||||
(text "{% if selected_journal == 0 -%}")
|
||||
; no journal selected
|
||||
(div
|
||||
("class" "card w-full flex flex-col gap-2")
|
||||
(h3 (str (text "journals:label.welcome")))
|
||||
(span (str (text "journals:label.select_a_journal")))
|
||||
(span ("class" "mobile") (str (text "journals:label.mobile_click_my_journals")))
|
||||
(button
|
||||
("onclick" "create_journal()")
|
||||
(icon (text "plus"))
|
||||
(str (text "journals:action.create_journal"))))
|
||||
(text "{% elif selected_note == 0 -%}")
|
||||
; journal selected, but no note is selected
|
||||
(text "{% if not view_mode -%}")
|
||||
; we're the journal owner and we're not in view mode
|
||||
(div
|
||||
("class" "card w-full flex flex-col gap-2")
|
||||
(h3 (text "{{ journal.title }}"))
|
||||
(span (str (text "journals:label.select_a_note")))
|
||||
(button
|
||||
("onclick" "create_note()")
|
||||
(icon (text "plus"))
|
||||
(str (text "journals:action.create_note"))))
|
||||
|
||||
; we'll also let users edit the journal's settings here i guess
|
||||
(details
|
||||
("class" "w-full")
|
||||
(summary
|
||||
("class" "button lowered w-full justify-start")
|
||||
(icon (text "settings"))
|
||||
(str (text "general:action.manage")))
|
||||
|
||||
(div
|
||||
("class" "card flex flex-col gap-2 lowered")
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small")
|
||||
(b
|
||||
(text "Privacy")))
|
||||
(div
|
||||
("class" "card")
|
||||
(select
|
||||
("onchange" "change_journal_privacy(event)")
|
||||
(option
|
||||
("value" "Private")
|
||||
("selected" "{% if journal.privacy == 'Private' -%}true{% else %}false{%- endif %}")
|
||||
(text "Private"))
|
||||
(option
|
||||
("value" "Public")
|
||||
("selected" "{% if journal.privacy == 'Public' -%}true{% else %}false{%- endif %}")
|
||||
(text "Public")))))
|
||||
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small")
|
||||
(label
|
||||
("for" "title")
|
||||
(b (str (text "communities:label.title")))))
|
||||
|
||||
(form
|
||||
("class" "card flex flex-col gap-2")
|
||||
("onsubmit" "change_journal_title(event)")
|
||||
(div
|
||||
("class" "flex flex-col gap-1")
|
||||
(input
|
||||
("type" "text")
|
||||
("name" "title")
|
||||
("id" "title")
|
||||
("placeholder" "title")
|
||||
("required" "")
|
||||
("minlength" "2")))
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ icon \"check\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:action.save\" }}")))))))
|
||||
|
||||
; users should also be able to manage the journal's sub directories here
|
||||
(details
|
||||
("class" "w-full")
|
||||
(summary
|
||||
("class" "button lowered w-full justify-start")
|
||||
(icon (text "folders"))
|
||||
(str (text "journals:label.directories")))
|
||||
|
||||
(div
|
||||
("class" "card flex flex-col gap-2 lowered")
|
||||
(text "{{ components::directories_editor(dirs=journal.dirs) }}")))
|
||||
(text "{% else %}")
|
||||
; we're in view mode; just show journal listing and notes as journal homepage
|
||||
(div
|
||||
("class" "card flex flex-col gap-2")
|
||||
(text "{% if tag|length > 0 -%}")
|
||||
(a
|
||||
("href" "?")
|
||||
("class" "notification chip w-content")
|
||||
(text "{{ tag }}"))
|
||||
(text "{%- endif %}")
|
||||
|
||||
(text "{{ components::journal_listing(journal=journal, notes=notes, selected_note=selected_note, selected_journal=selected_journal, view_mode=view_mode, owner=owner) }}"))
|
||||
(text "{%- endif %}")
|
||||
(text "{% else %}")
|
||||
; journal AND note selected
|
||||
(text "{% if not view_mode -%}")
|
||||
; not view mode; show editor
|
||||
; import codemirror
|
||||
(script ("src" "https://unpkg.com/codemirror@5.39.2/lib/codemirror.js") ("data-turbo-temporary" "true"))
|
||||
(script ("src" "https://unpkg.com/codemirror@5.39.2/addon/display/placeholder.js") ("data-turbo-temporary" "true"))
|
||||
(link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/lib/codemirror.css") ("data-turbo-temporary" "true"))
|
||||
|
||||
(text "{% if note.title == \"journal.css\" -%}")
|
||||
; css editor
|
||||
(script ("src" "https://unpkg.com/codemirror@5.39.2/addon/edit/closebrackets.js") ("data-turbo-temporary" "true"))
|
||||
(script ("src" "https://unpkg.com/codemirror@5.39.2/addon/hint/css-hint.js") ("data-turbo-temporary" "true"))
|
||||
(script ("src" "https://unpkg.com/codemirror@5.39.2/addon/hint/show-hint.js") ("data-turbo-temporary" "true"))
|
||||
(script ("src" "https://unpkg.com/codemirror@5.39.2/addon/lint/css-lint.js") ("data-turbo-temporary" "true"))
|
||||
(script ("src" "https://unpkg.com/codemirror@5.39.2/mode/css/css.js") ("data-turbo-temporary" "true"))
|
||||
(link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/addon/hint/show-hint.css") ("data-turbo-temporary" "true"))
|
||||
(link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/addon/lint/lint.css") ("data-turbo-temporary" "true"))
|
||||
|
||||
(style
|
||||
(text ".CodeMirror {
|
||||
font-family: monospace !important;
|
||||
font-size: 16px;
|
||||
border: solid 1px var(--color-super-lowered);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.CodeMirror-line {
|
||||
padding-left: 5px !important;
|
||||
}"))
|
||||
(text "{% else %}")
|
||||
; markdown editor
|
||||
(script ("src" "https://unpkg.com/codemirror@5.39.2/mode/markdown/markdown.js") ("data-turbo-temporary" "true"))
|
||||
(text "{%- endif %}")
|
||||
|
||||
; tab bar
|
||||
(text "{% if note.title != \"journal.css\" -%}")
|
||||
(div
|
||||
("class" "pillmenu")
|
||||
(a
|
||||
("href" "#/editor")
|
||||
("data-tab-button" "editor")
|
||||
("data-turbo" "false")
|
||||
("class" "active")
|
||||
(str (text "journals:label.editor")))
|
||||
|
||||
(a
|
||||
("href" "#/preview")
|
||||
("data-tab-button" "preview")
|
||||
("data-turbo" "false")
|
||||
(str (text "journals:label.preview_pane")))
|
||||
|
||||
(a
|
||||
("href" "#/tags")
|
||||
("data-tab-button" "tags")
|
||||
("data-turbo" "false")
|
||||
("class" "hidden")
|
||||
(str (text "journals:action.edit_tags"))))
|
||||
(text "{%- endif %}")
|
||||
|
||||
; tabs
|
||||
(text "{{ components::note_tags(note=note) }}")
|
||||
|
||||
(div
|
||||
("data-tab" "editor")
|
||||
("class" "flex flex-col gap-2")
|
||||
(div
|
||||
("class" "flex flex-col gap-2 card")
|
||||
("style" "animation: fadein ease-in-out 1 0.5s forwards running")
|
||||
("id" "editor_tab"))
|
||||
|
||||
(button
|
||||
("onclick" "change_note_content('{{ note.id }}')")
|
||||
(icon (text "check"))
|
||||
(str (text "general:action.save"))))
|
||||
|
||||
(div
|
||||
("data-tab" "preview")
|
||||
("class" "flex flex-col gap-2 card hidden")
|
||||
("style" "animation: fadein ease-in-out 1 0.5s forwards running")
|
||||
("id" "preview_tab"))
|
||||
|
||||
(div
|
||||
("data-tab" "tags")
|
||||
("class" "flex flex-col gap-2 card hidden")
|
||||
("style" "animation: fadein ease-in-out 1 0.5s forwards running")
|
||||
(form
|
||||
("onsubmit" "save_tags(event)")
|
||||
("class" "flex flex-col gap-1")
|
||||
(label
|
||||
("for" "tags")
|
||||
(str (text "journals:action.tags"))
|
||||
(textarea
|
||||
("type" "text")
|
||||
("name" "tags")
|
||||
("id" "tags")
|
||||
("placeholder" "tags")
|
||||
("required" "")
|
||||
("minlength" "2")
|
||||
("maxlength" "128")
|
||||
(text "{% for tag in note.tags -%} {{ tag }}, {% endfor %}"))
|
||||
(span ("class" "fade") (text "Tags should be separated by a comma.")))
|
||||
|
||||
(button
|
||||
(icon (text "check"))
|
||||
(str (text "general:action.save"))))
|
||||
|
||||
(script
|
||||
(text "globalThis.save_tags = (e) => {
|
||||
event.preventDefault();
|
||||
fetch(\"/api/v1/notes/{{ selected_note }}/tags\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tags: e.target.tags.value.split(\",\").map(t => t.trim()).filter(t => t),
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
}")))
|
||||
|
||||
; init codemirror
|
||||
(script ("id" "editor_content") ("type" "text/markdown") (text "{{ note.content|remove_script_tags|safe }}"))
|
||||
(script
|
||||
(text "setTimeout(async () => {
|
||||
document.getElementById(\"editor_tab\").innerHTML = \"\";
|
||||
globalThis.editor = CodeMirror(document.getElementById(\"editor_tab\"), {
|
||||
value: document.getElementById(\"editor_content\").innerHTML,
|
||||
mode: \"{% if note.title == 'journal.css' -%} css {%- else -%} markdown {%- endif %}\",
|
||||
lineWrapping: true,
|
||||
lineNumbers: \"{{ note.title }}\" === \"journal.css\",
|
||||
autoCloseBrackets: true,
|
||||
autofocus: true,
|
||||
viewportMargin: Number.POSITIVE_INFINITY,
|
||||
inputStyle: \"contenteditable\",
|
||||
highlightFormatting: false,
|
||||
fencedCodeBlockHighlighting: false,
|
||||
xml: false,
|
||||
smartIndent: true,
|
||||
indentUnit: 4,
|
||||
placeholder: `# {{ note.title }}`,
|
||||
extraKeys: {
|
||||
Home: \"goLineLeft\",
|
||||
End: \"goLineRight\",
|
||||
Enter: (cm) => {
|
||||
cm.replaceSelection(\"\\n\");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
editor.on(\"keydown\", (cm, e) => {
|
||||
if (e.key.length > 1) {
|
||||
// ignore all keys that aren't a letter
|
||||
return;
|
||||
}
|
||||
|
||||
CodeMirror.showHint(cm, CodeMirror.hint.css);
|
||||
});
|
||||
|
||||
document.querySelector(\"[data-tab-button=editor]\").addEventListener(\"click\", async (e) => {
|
||||
e.preventDefault();
|
||||
trigger(\"atto::hooks::tabs:switch\", [\"editor\"]);
|
||||
});
|
||||
|
||||
document.querySelector(\"[data-tab-button=preview]\").addEventListener(\"click\", async (e) => {
|
||||
e.preventDefault();
|
||||
const res = await (
|
||||
await fetch(\"/api/v1/notes/preview\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: globalThis.editor.getValue(),
|
||||
}),
|
||||
})
|
||||
).text();
|
||||
|
||||
const preview_token = window.crypto.randomUUID();
|
||||
document.getElementById(\"preview_tab\").innerHTML = `${res}<style>
|
||||
@import url(\"/api/v1/journals/{{ journal.id }}/journal.css?v=preview-${preview_token}\");
|
||||
</style>`;
|
||||
trigger(\"atto::hooks::tabs:switch\", [\"preview\"]);
|
||||
});
|
||||
}, 150);"))
|
||||
(text "{% else %}")
|
||||
; we're just viewing this note
|
||||
(div
|
||||
("class" "flex flex-col gap-2 card")
|
||||
(text "{{ note.content|markdown|safe }}"))
|
||||
|
||||
(div
|
||||
("class" "flex w-full justify-between gap-2")
|
||||
(div
|
||||
("class" "flex flex-col gap-2 fade")
|
||||
(span (text "Last updated: ") (span ("class" "date") (text "{{ note.edited }}")))
|
||||
|
||||
(text "{% if global_mode -%}")
|
||||
(span ("class" "flex gap-1") (text "Created by: ") (text "{{ components::full_username(user=owner) }}"))
|
||||
(span (text "Views: {{ redis_views }}"))
|
||||
(text "{% elif note.is_global -%}")
|
||||
; globsl note, but we aren't viewing globally...
|
||||
(a
|
||||
("href" "/x/{{ note.title }}")
|
||||
("class" "button lowered small green")
|
||||
(icon (text "globe"))
|
||||
(text "View as global"))
|
||||
(text "{%- endif %}")
|
||||
|
||||
(text "{{ components::note_tags(note=note) }}"))
|
||||
|
||||
(text "{% if user and user.id == owner.id -%}")
|
||||
(button
|
||||
("class" "small")
|
||||
("onclick" "{% if note.is_global -%}
|
||||
trigger('atto::copy_text', ['{{ config.host }}/x/{{ note.title }}'])
|
||||
{%- else -%}
|
||||
{% if journal.privacy == \"Public\" -%}
|
||||
trigger('atto::copy_text', ['{{ config.host }}/@{{ owner.username }}/{{ journal.title }}/{{ note.title }}'])
|
||||
{%- else -%}
|
||||
prompt_make_public();
|
||||
trigger('atto::copy_text', ['{{ config.host }}/@{{ owner.username }}/{{ journal.title }}/{{ note.title }}'])
|
||||
{%- endif -%} {%- endif %}")
|
||||
(icon (text "share"))
|
||||
(str (text "general:label.share")))
|
||||
|
||||
(script
|
||||
(text "globalThis.prompt_make_public = async () => {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Would you like to make this journal public? This is required for others to view this note.\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
change_journal_privacy({ target: { selectedOptions: [{ value: \"Public\" }] }, preventDefault: () => {} });
|
||||
}"))
|
||||
(text "{%- endif %}"))
|
||||
(text "{%- endif %}")
|
||||
(text "{%- endif %}")))
|
||||
(style
|
||||
(text "nav::after {
|
||||
width: 100%;
|
||||
left: 0;
|
||||
}"))
|
||||
(script
|
||||
(text "window.JOURNAL_PROPS = {
|
||||
selected_journal: \"{{ selected_journal }}\",
|
||||
selected_note: \"{{ selected_note }}\",
|
||||
};
|
||||
|
||||
// journals/notes
|
||||
globalThis.create_journal = async () => {
|
||||
const title = await trigger(\"atto::prompt\", [\"Journal title:\"]);
|
||||
|
||||
if (!title) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(\"/api/v1/journals\", {
|
||||
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,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
setTimeout(() => {
|
||||
window.location.href = `/journals/${res.payload}/0`;
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
globalThis.create_note = async () => {
|
||||
const title = await trigger(\"atto::prompt\", [\"Note title:\"]);
|
||||
|
||||
if (!title) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(\"/api/v1/notes\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
content: title === \"journal.css\" ? `/* ${title} */\\n` : `# ${title}`,
|
||||
journal: \"{{ selected_journal }}\",
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
setTimeout(() => {
|
||||
window.location.href = `/journals/{{ selected_journal }}/${res.payload}`;
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
globalThis.delete_journal = async (id) => {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/v1/journals/${id}`, {
|
||||
method: \"DELETE\",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
setTimeout(() => {
|
||||
window.location.href = \"/journals\";
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
globalThis.delete_note = async (id) => {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/v1/notes/${id}`, {
|
||||
method: \"DELETE\",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
setTimeout(() => {
|
||||
window.location.href = \"/journals/{{ selected_journal }}/0\";
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
globalThis.change_journal_title = async (e) => {
|
||||
e.preventDefault();
|
||||
fetch(\"/api/v1/journals/{{ selected_journal }}/title\", {
|
||||
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) {
|
||||
e.reset();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
globalThis.change_journal_privacy = async (e) => {
|
||||
e.preventDefault();
|
||||
const selected = e.target.selectedOptions[0];
|
||||
fetch(\"/api/v1/journals/{% if journal -%} {{ journal.id }} {%- else -%} {{ selected_journal }} {%- endif %}/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.change_note_title = async (id) => {
|
||||
const title = await trigger(\"atto::prompt\", [\"New note title:\"]);
|
||||
|
||||
if (!title) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/v1/notes/${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,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
e.reset();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
globalThis.change_note_content = async (id) => {
|
||||
fetch(`/api/v1/notes/${id}/content`, {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: globalThis.editor.getValue(),
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
globalThis.create_directory = async (parent) => {
|
||||
const name = await trigger(\"atto::prompt\", [\"Directory name:\"]);
|
||||
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(\"/api/v1/journals/{{ selected_journal }}/dirs\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
parent,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
globalThis.delete_directory = async (id) => {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this? This will delete all notes within this directory.\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(\"/api/v1/journals/{{ selected_journal }}/dirs\", {
|
||||
method: \"DELETE\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
fetch(`/api/v1/notes/{{ selected_journal }}/dir/${id}`, {
|
||||
method: \"DELETE\",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
globalThis.move_note_dir = async (id, dir) => {
|
||||
fetch(`/api/v1/notes/${id}/dir`, {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
dir,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
globalThis.publish_note = async (id) => {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
`Are you sure you would like to do this? The note will be public at '/x/name', even if the journal is private.
|
||||
|
||||
Publishing your note is specifically for making the note accessible through the global endpoint. The note will be public under your username as long as the journal is public.`,
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/v1/notes/${id}/global`, {
|
||||
method: \"POST\",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
globalThis.unpublish_note = async (id) => {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this? This global note name will be made available.\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/v1/notes/${id}/global`, {
|
||||
method: \"DELETE\",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// sidebars
|
||||
window.SIDEBARS_OPEN = false;
|
||||
if (new URLSearchParams(window.location.search).get(\"nav\") === \"true\") {
|
||||
window.SIDEBARS_OPEN = true;
|
||||
}
|
||||
|
||||
if (
|
||||
window.SIDEBARS_OPEN &&
|
||||
!document.body.classList.contains(\"sidebars_shown\")
|
||||
) {
|
||||
toggle_sidebars();
|
||||
window.SIDEBARS_OPEN = true;
|
||||
}
|
||||
|
||||
for (const anchor of document.querySelectorAll(\"[data-turbo=false]\")) {
|
||||
anchor.href += `?nav=${window.SIDEBARS_OPEN}`;
|
||||
}
|
||||
|
||||
function toggle_sidebars() {
|
||||
window.SIDEBARS_OPEN = !window.SIDEBARS_OPEN;
|
||||
|
||||
for (const anchor of document.querySelectorAll(
|
||||
\"[data-turbo=false]\",
|
||||
)) {
|
||||
anchor.href = anchor.href.replace(
|
||||
`?nav=${!window.SIDEBARS_OPEN}`,
|
||||
`?nav=${window.SIDEBARS_OPEN}`,
|
||||
);
|
||||
}
|
||||
|
||||
const notes_list = document.getElementById(\"notes_list\");
|
||||
|
||||
if (document.body.classList.contains(\"sidebars_shown\")) {
|
||||
// hide
|
||||
document.body.classList.remove(\"sidebars_shown\");
|
||||
notes_list.style.left = \"-200%\";
|
||||
} else {
|
||||
// show
|
||||
document.body.classList.add(\"sidebars_shown\");
|
||||
notes_list.style.left = \"0\";
|
||||
}
|
||||
}")))
|
||||
|
||||
(text "{% if journal -%}")
|
||||
; note mover
|
||||
(dialog
|
||||
("id" "note_mover_dialog")
|
||||
(div
|
||||
("class" "inner flex flex-col gap-2")
|
||||
(p (text "Select a directory to move this note into:"))
|
||||
(text "{{ components::note_mover_dirs(dirs=journal.dirs) }}")
|
||||
(div
|
||||
("class" "flex justify-between")
|
||||
(div)
|
||||
(div
|
||||
("class" "flex gap-2")
|
||||
(button
|
||||
("class" "bold red lowered")
|
||||
("onclick" "document.getElementById('note_mover_dialog').close()")
|
||||
("type" "button")
|
||||
(text "{{ icon \"x\" }} {{ text \"dialog:action.close\" }}"))))))
|
||||
(text "{%- endif %}")
|
||||
(text "{% endblock %}")
|
|
@ -48,7 +48,7 @@
|
|||
(a
|
||||
("href" "/requests")
|
||||
("class" "button {% if selected == 'requests' -%}active{%- endif %}")
|
||||
("title" "Chats")
|
||||
("title" "Requests")
|
||||
(icon (text "inbox"))
|
||||
(span
|
||||
("class" "notification tr {% if user.request_count <= 0 -%}hidden{%- endif %}")
|
||||
|
@ -58,7 +58,7 @@
|
|||
(a
|
||||
("href" "/notifs")
|
||||
("class" "button {% if selected == 'notifications' -%}active{%- endif %}")
|
||||
("title" "Chats")
|
||||
("title" "Notifications")
|
||||
(icon (text "bell"))
|
||||
(span
|
||||
("class" "notification tr {% if user.notification_count <= 0 -%}hidden{%- endif %}")
|
||||
|
@ -112,7 +112,19 @@
|
|||
("class" "button")
|
||||
("data-turbo" "false")
|
||||
(icon (text "rabbit"))
|
||||
(str (text "general:link.reference"))))))
|
||||
(str (text "general:link.reference")))
|
||||
|
||||
(a
|
||||
("href" "{{ config.policies.terms_of_service }}")
|
||||
("class" "button")
|
||||
(icon (text "heart-handshake"))
|
||||
(text "Terms of service"))
|
||||
|
||||
(a
|
||||
("href" "{{ config.policies.privacy }}")
|
||||
("class" "button")
|
||||
(icon (text "cookie"))
|
||||
(text "Privacy policy")))))
|
||||
(text "{%- endif %}")))
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
|
@ -124,7 +136,7 @@
|
|||
("class" "dropdown")
|
||||
("style" "width: max-content")
|
||||
(button
|
||||
("class" "camo raised small")
|
||||
("class" "raised small")
|
||||
("onclick" "trigger('atto::hooks::dropdown', [event])")
|
||||
("exclude" "dropdown")
|
||||
(icon (text "sliders-horizontal"))
|
||||
|
|
45
crates/app/src/public/html/misc/achievements.lisp
Normal file
45
crates/app/src/public/html/misc/achievements.lisp
Normal file
|
@ -0,0 +1,45 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "Achievements - {{ config.name }}"))
|
||||
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"achievements\") }}")
|
||||
(main
|
||||
("class" "flex flex-col gap-2")
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small flex items-center gap-2")
|
||||
(icon (text "coffee"))
|
||||
(span (text "Welcome to {{ config.name }}!")))
|
||||
(div
|
||||
("class" "card no_p_margin flex flex-col gap-2")
|
||||
(p (text "To help you move in, you'll be rewarded with small achievements for completing simple tasks on {{ config.name }}!"))
|
||||
(p (text "You'll find out what each achievement is when you get it, so look around!"))
|
||||
(hr)
|
||||
(span (b (text "Your progress: ")) (text "{{ percentage|round(method=\"floor\", precision=2) }}%"))
|
||||
(div ("class" "poll_bar") ("style" "width: {{ percentage }}%"))))
|
||||
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small flex items-center justify-between gap-2")
|
||||
(span
|
||||
("class" "flex items-center gap-2")
|
||||
(icon (text "award"))
|
||||
(span (str (text "general:link.achievements")))))
|
||||
(div
|
||||
("class" "card lowered flex flex-col gap-4")
|
||||
(text "{% for achievement in achievements %}")
|
||||
(div
|
||||
("class" "w-full card-nest")
|
||||
(div
|
||||
("class" "card small flex items-center gap-2 {% if achievement[2] == 'Uncommon' -%} green {%- elif achievement[2] == 'Rare' -%} purple {%- endif %}")
|
||||
(icon (text "award"))
|
||||
(text "{{ achievement[0] }}"))
|
||||
(div
|
||||
("class" "card flex flex-col gap-2")
|
||||
(span ("class" "no_p_margin") (text "{{ achievement[1]|markdown|safe }}"))
|
||||
(hr)
|
||||
(span ("class" "fade") (text "Unlocked: ") (span ("class" "date") (text "{{ achievement[3].unlocked }}")))))
|
||||
(text "{% endfor %}"))))
|
||||
(text "{% endblock %}")
|
|
@ -112,12 +112,11 @@
|
|||
(div
|
||||
("id" "files_list")
|
||||
("class" "flex gap-2 flex-wrap"))
|
||||
(div
|
||||
("class" "flex w-full justify-between flex-collapse gap-2")
|
||||
(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 lowered")
|
||||
|
@ -127,7 +126,11 @@
|
|||
("type" "button")
|
||||
("class" "red lowered")
|
||||
("onclick" "trigger('me::ip_block_question', ['{{ question[0].id }}'])")
|
||||
(text "{{ text \"auth:action.ip_block\" }}")))))
|
||||
(text "{{ text \"auth:action.ip_block\" }}")))
|
||||
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ text \"requests:label.answer\" }}")))))
|
||||
(text "{% endfor %}")))
|
||||
|
||||
(text "{{ components::pagination(page=page, items=requests|length, key=\"&id=\", value=profile.id) }}"))
|
||||
|
|
|
@ -68,8 +68,8 @@
|
|||
(text "Unban"))
|
||||
(text "{%- endif %}")))
|
||||
(script
|
||||
(text "setTimeout(() => {
|
||||
const ui = ns(\"ui\");
|
||||
(text "setTimeout(async () => {
|
||||
const ui = await ns(\"ui\");
|
||||
const element = document.getElementById(\"mod_options\");
|
||||
|
||||
async function profile_request(do_confirm, path, body) {
|
||||
|
@ -202,6 +202,20 @@
|
|||
(text "{% for user in associations -%}")
|
||||
(text "{{ components::user_plate(user=user, show_menu=false) }}")
|
||||
(text "{%- endfor %}")))
|
||||
(text "{% if invite -%}")
|
||||
(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 \"ticket\" }}")
|
||||
(span
|
||||
(text "{{ text \"mod_panel:label.invited_by\" }}"))))
|
||||
(div
|
||||
("class" "card lowered flex flex-wrap gap-2")
|
||||
(text "{{ components::user_plate(user=invite[0], show_menu=false) }}")))
|
||||
(text "{%- endif %}")
|
||||
(div
|
||||
("class" "card-nest w-full")
|
||||
(div
|
||||
|
@ -221,8 +235,8 @@
|
|||
("class" "card lowered flex flex-col gap-2")
|
||||
("id" "permission_builder")))
|
||||
(script
|
||||
(text "setTimeout(() => {
|
||||
const get_permissions_html = trigger(
|
||||
(text "setTimeout(async () => {
|
||||
const get_permissions_html = await trigger(
|
||||
\"ui::generate_permissions_ui\",
|
||||
[
|
||||
{
|
||||
|
@ -256,6 +270,8 @@
|
|||
MANAGE_STACKS: 1 << 26,
|
||||
STAFF_BADGE: 1 << 27,
|
||||
MANAGE_APPS: 1 << 28,
|
||||
MANAGE_JOURNALS: 1 << 29,
|
||||
MANAGE_NOTES: 1 << 30,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
|
|
@ -29,6 +29,15 @@
|
|||
(b
|
||||
(text "Socket tasks: "))
|
||||
(span
|
||||
(text "{{ (active_users_chats + active_users) * 3 }}")))))))
|
||||
(text "{{ (active_users_chats + active_users) * 3 }}"))))
|
||||
|
||||
(hr)
|
||||
(ul
|
||||
(li (b (text "Users: ")) (span (text "{{ table_users }}")))
|
||||
(li (b (text "IP bans: ")) (span (text "{{ table_ipbans }}")))
|
||||
(li (b (text "Invite codes: ")) (span (text "{{ table_invite_codes }}")))
|
||||
(li (b (text "Posts: ")) (span (text "{{ table_posts }}")))
|
||||
(li (b (text "Uploads: ")) (span (text "{{ table_uploads }}")))
|
||||
(li (b (text "Communities: ")) (span (text "{{ table_communities }}")))))))
|
||||
|
||||
(text "{% endblock %}")
|
||||
|
|
|
@ -2,6 +2,41 @@
|
|||
(title
|
||||
(text "Post - {{ config.name }}"))
|
||||
|
||||
(meta
|
||||
("name" "og:title")
|
||||
("content" "{% if owner.settings.display_name -%} {{ owner.settings.display_name }} {%- else -%} {{ owner.username }} {%- endif %}'s post"))
|
||||
|
||||
(meta
|
||||
("name" "description")
|
||||
("content" "View this post from @{{ owner.username }} on {{ config.name }}!"))
|
||||
|
||||
(meta
|
||||
("name" "og:description")
|
||||
("content" "View this post from @{{ owner.username }} on {{ config.name }}!"))
|
||||
|
||||
(meta
|
||||
("property" "og:type")
|
||||
("content" "website"))
|
||||
|
||||
(meta
|
||||
("name" "og:image")
|
||||
("content" "{{ config.host|safe }}/api/v1/auth/user/{{ owner.username }}/avatar?selector_type=username"))
|
||||
|
||||
(meta
|
||||
("name" "twitter:image")
|
||||
("content" "{{ config.host|safe }}/api/v1/auth/user/{{ owner.username }}/avatar?selector_type=usernamev"))
|
||||
|
||||
(meta
|
||||
("name" "twitter:card")
|
||||
("content" "summary"))
|
||||
|
||||
(meta
|
||||
("name" "twitter:title")
|
||||
("content" "{% if owner.settings.display_name -%} {{ owner.settings.display_name }} {%- else -%} {{ owner.username }} {%- endif %}'s post"))
|
||||
|
||||
(meta
|
||||
("name" "twitter:description")
|
||||
("content" "View this post from @{{ owner.username }} on {{ config.name }}!"))
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
|
||||
(main
|
||||
("class" "flex flex-col gap-2")
|
||||
|
@ -110,8 +145,8 @@
|
|||
(span
|
||||
(text "{{ text \"general:action.save\" }}")))
|
||||
(script
|
||||
(text "setTimeout(() => {
|
||||
const ui = ns(\"ui\");
|
||||
(text "setTimeout(async () => {
|
||||
const ui = await ns(\"ui\");
|
||||
const element = document.getElementById(\"post_context\");
|
||||
const settings = JSON.parse(\"{{ post_context_serde|safe }}\");
|
||||
|
||||
|
|
|
@ -132,9 +132,11 @@
|
|||
(text "{{ profile.settings.biography|markdown|safe }}"))
|
||||
(div
|
||||
("class" "card flex flex-col gap-2")
|
||||
(text "{% if user -%}")
|
||||
(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 %}"))
|
||||
(text "{%- endif %}")
|
||||
(div
|
||||
("class" "w-full flex justify-between items-center")
|
||||
(span
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
(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 "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}"))
|
||||
|
||||
(text "{%- endif %} {{ macros::profile_nav(selected=\"media\") }}")
|
||||
(div
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
(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 "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}"))
|
||||
|
||||
(text "{%- endif %} {{ macros::profile_nav(selected=\"outbox\") }}")
|
||||
(div
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
(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 "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}"))
|
||||
|
||||
(text "{%- endif %} {% if not tag and pinned|length != 0 -%}")
|
||||
(div
|
||||
|
@ -40,7 +40,16 @@
|
|||
(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, poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %} {{ components::pagination(page=page, items=posts|length, key=\"&tag=\", value=tag) }}")))
|
||||
("class" "card w-full flex flex-col gap-2")
|
||||
("ui_ident" "io_data_load")
|
||||
(div ("ui_ident" "io_data_marker"))))
|
||||
|
||||
(text "{% set paged = user and user.settings.paged_timelines %}")
|
||||
(script
|
||||
(text "setTimeout(async () => {
|
||||
await trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?user_id={{ profile.id }}&tag={{ tag }}&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
|
||||
(await ns(\"ui\")).IO_DATA_DISABLE_RELOAD = true;
|
||||
console.log(\"created profile timeline\");
|
||||
}, 1000);"))
|
||||
|
||||
(text "{% endblock %}")
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
(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 "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}"))
|
||||
|
||||
(text "{%- endif %} {{ macros::profile_nav(selected=\"replies\") }}")
|
||||
(div
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
("class" "dropdown")
|
||||
("style" "width: max-content")
|
||||
(button
|
||||
("class" "camo raised small")
|
||||
("class" "raised small")
|
||||
("onclick" "trigger('atto::hooks::dropdown', [event])")
|
||||
("exclude" "dropdown")
|
||||
(icon (text "sliders-horizontal"))
|
||||
|
@ -61,21 +61,35 @@
|
|||
("href" "#/account/blocks")
|
||||
(text "{{ icon \"shield\" }}")
|
||||
(span
|
||||
(text "{{ text \"settings:tab.blocks\" }}")))
|
||||
(text "{{ text \"settings:tab.blocks\" }}"))))
|
||||
|
||||
(text "{% if config.stripe -%}")
|
||||
; stripe menu
|
||||
(div
|
||||
("class" "pillmenu")
|
||||
("ui_ident" "account_settings_tabs")
|
||||
(a
|
||||
("data-tab-button" "account/uploads")
|
||||
("href" "?page=0#/account/uploads")
|
||||
(text "{{ icon \"image-up\" }}")
|
||||
(span
|
||||
(text "{{ text \"settings:tab.uploads\" }}")))
|
||||
(text "{% if config.stripe -%}")
|
||||
(text "{% if config.security.enable_invite_codes -%}")
|
||||
(a
|
||||
("data-tab-button" "account/invites")
|
||||
("href" "?page=0#/account/invites")
|
||||
(text "{{ icon \"ticket\" }}")
|
||||
(span
|
||||
(text "{{ text \"settings:tab.invites\" }}")))
|
||||
(text "{%- endif %}")
|
||||
(a
|
||||
("data-tab-button" "account/billing")
|
||||
("href" "#/account/billing")
|
||||
(text "{{ icon \"credit-card\" }}")
|
||||
(span
|
||||
(text "{{ text \"settings:tab.billing\" }}")))
|
||||
(text "{%- endif %}"))
|
||||
(text "{{ text \"settings:tab.billing\" }}"))))
|
||||
(text "{%- endif %}")
|
||||
|
||||
(div
|
||||
("class" "card-nest")
|
||||
("ui_ident" "home_timeline")
|
||||
|
@ -419,12 +433,19 @@
|
|||
(div
|
||||
("class" "flex gap-2")
|
||||
(text "{{ components::avatar(username=user.username) }} {{ components::full_username(user=user) }}"))
|
||||
(div
|
||||
("class" "flex gap-2")
|
||||
(a
|
||||
("href" "/stacks/add_user/{{ user.id }}")
|
||||
("target" "_blank")
|
||||
("class" "button lowered small")
|
||||
(icon (text "plus"))
|
||||
(span (str (text "settings:label.add_to_stack"))))
|
||||
(a
|
||||
("href" "/@{{ user.username }}")
|
||||
("class" "button lowered small")
|
||||
(text "{{ icon \"external-link\" }}")
|
||||
(span
|
||||
(text "{{ text \"requests:action.view_profile\" }}"))))
|
||||
(icon (text "external-link"))
|
||||
(span (str (text "requests:action.view_profile"))))))
|
||||
(text "{% endfor %}")))))
|
||||
(div
|
||||
("class" "w-full flex flex-col gap-2 hidden")
|
||||
|
@ -495,6 +516,88 @@
|
|||
]);
|
||||
});
|
||||
};"))))))
|
||||
|
||||
(text "{% if config.security.enable_invite_codes -%}")
|
||||
(div
|
||||
("class" "w-full flex flex-col gap-2 hidden")
|
||||
("data-tab" "account/invites")
|
||||
(div
|
||||
("class" "card lowered flex flex-col gap-2")
|
||||
(a
|
||||
("href" "#/account")
|
||||
("class" "button secondary")
|
||||
(text "{{ icon \"arrow-left\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:action.back\" }}")))
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card flex items-center gap-2 small")
|
||||
(text "{{ icon \"ticket\" }}")
|
||||
(span
|
||||
(text "{{ text \"settings:tab.invites\" }}")))
|
||||
(div
|
||||
("class" "card flex flex-col gap-2 secondary")
|
||||
(pre ("id" "invite_codes_output") ("class" "hidden") (code))
|
||||
(pre ("id" "invite_codes_error_output") ("class" "hidden") (code ("class" "red")))
|
||||
|
||||
(button
|
||||
("onclick" "generate_invite_codes()")
|
||||
(icon (text "plus"))
|
||||
(str (text "settings:label.generate_invites")))
|
||||
|
||||
(text "{{ components::supporter_ad(body=\"Become a supporter to generate up to 48 invite codes! You can currently have 2 maximum.\") }} {% for code in invites %}")
|
||||
(div
|
||||
("class" "card flex flex-col gap-2")
|
||||
(text "{% if code[1].is_used -%}")
|
||||
; used
|
||||
(b ("class" "{% if code[1].is_used -%} fade {%- endif %}") (s (text "{{ code[1].code }}")))
|
||||
(text "{{ components::full_username(user=code[0]) }}")
|
||||
(text "{% else %}")
|
||||
; unused
|
||||
(b (text "{{ code[1].code }}"))
|
||||
(text "{%- endif %}"))
|
||||
(text "{% endfor %}")
|
||||
(text "{{ components::pagination(page=page, items=invites|length, key=\"#/account/invites\") }}")
|
||||
(script
|
||||
(text "globalThis.generate_invite_codes = async () => {
|
||||
await trigger(\"atto::debounce\", [\"invites::create\"]);
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this? This action is permanent.\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const count = Number.parseInt(await trigger(\"atto::prompt\", [\"Count (1-48):\"]));
|
||||
|
||||
if (!count) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById(\"invite_codes_output\").classList.remove(\"hidden\");
|
||||
document.getElementById(\"invite_codes_error_output\").classList.remove(\"hidden\");
|
||||
document.getElementById(\"invite_codes_output\").children[0].innerText = \"Working... expect to wait 50ms per invite code\";
|
||||
|
||||
fetch(`/api/v1/invites/${count}`, {
|
||||
method: \"POST\",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
document.getElementById(\"invite_codes_output\").children[0].innerText = res.payload[0];
|
||||
document.getElementById(\"invite_codes_error_output\").children[0].innerText = res.payload[1];
|
||||
}
|
||||
});
|
||||
};"))))))
|
||||
(text "{%- endif %}")
|
||||
|
||||
(div
|
||||
("class" "w-full flex flex-col gap-2 hidden")
|
||||
("data-tab" "account/billing")
|
||||
|
@ -564,14 +667,14 @@
|
|||
(li
|
||||
(text "Use custom CSS on your profile"))
|
||||
(li
|
||||
(text "Ability to use community emojis outside of
|
||||
(text "Use community emojis outside of
|
||||
their community"))
|
||||
(li
|
||||
(text "Ability to upload and use gif emojis"))
|
||||
(text "Upload and use gif emojis"))
|
||||
(li
|
||||
(text "Create infinite stack timelines"))
|
||||
(li
|
||||
(text "Ability to upload images to posts"))
|
||||
(text "Upload images to posts"))
|
||||
(li
|
||||
(text "Save infinite post drafts"))
|
||||
(li
|
||||
|
@ -579,11 +682,24 @@
|
|||
(li
|
||||
(text "Ability to create forges"))
|
||||
(li
|
||||
(text "Ability to create more than 1 app"))
|
||||
(text "Create more than 1 app"))
|
||||
(li
|
||||
(text "Create up to 10 stack blocks"))
|
||||
(li
|
||||
(text "Add unlimited users to stacks")))
|
||||
(text "Add unlimited users to stacks"))
|
||||
(li
|
||||
(text "Increased proxied image size"))
|
||||
(li
|
||||
(text "Create infinite journals"))
|
||||
(li
|
||||
(text "Create infinite notes in each journal"))
|
||||
(li
|
||||
(text "Publish up to 50 notes"))
|
||||
|
||||
(text "{% if config.security.enable_invite_codes -%}")
|
||||
(li
|
||||
(text "Create up to 48 invite codes"))
|
||||
(text "{%- endif %}"))
|
||||
(a
|
||||
("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}")
|
||||
("class" "button")
|
||||
|
@ -921,8 +1037,8 @@
|
|||
("id" "settings_json")
|
||||
(text "{{ profile.settings|json_encode()|remove_script_tags|safe }}"))
|
||||
(script
|
||||
(text "setTimeout(() => {
|
||||
const ui = ns(\"ui\");
|
||||
(text "setTimeout(async () => {
|
||||
const ui = await ns(\"ui\");
|
||||
const settings = JSON.parse(
|
||||
document.getElementById(\"settings_json\").innerHTML,
|
||||
);
|
||||
|
@ -1291,6 +1407,22 @@
|
|||
embed_html:
|
||||
'<span class=\"fade\">Muted phrases should all be on new lines.</span>',
|
||||
}],
|
||||
[[], \"Accessibility\", \"title\"],
|
||||
[
|
||||
[\"large_text\", \"Increase UI text size\"],
|
||||
\"{{ profile.settings.large_text }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
[
|
||||
[\"paged_timelines\", \"Make timelines paged instead of infinitely scrolled\"],
|
||||
\"{{ profile.settings.paged_timelines }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
[
|
||||
[\"auto_clear_notifs\", \"Automatically clear all notifications when you open the notifications page\"],
|
||||
\"{{ profile.settings.auto_clear_notifs }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
],
|
||||
settings,
|
||||
{
|
||||
|
@ -1353,6 +1485,16 @@
|
|||
\"{{ profile.settings.show_nsfw }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
[
|
||||
[\"auto_unlist\", \"Automatically mark my posts as NSFW\"],
|
||||
\"{{ profile.settings.auto_unlist }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
[
|
||||
[\"all_timeline_hide_answers\", 'Hide posts answering questions from the \"All\" timeline'],
|
||||
\"{{ profile.settings.all_timeline_hide_answers }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
[[], \"Questions\", \"title\"],
|
||||
[
|
||||
[
|
||||
|
@ -1370,6 +1512,14 @@
|
|||
\"{{ profile.settings.allow_anonymous_questions }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
[
|
||||
[
|
||||
\"enable_drawings\",
|
||||
\"Allow users to create drawings and submit them with questions\",
|
||||
],
|
||||
\"{{ profile.settings.enable_drawings }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
[
|
||||
[\"motivational_header\", \"Motivational header\"],
|
||||
settings.motivational_header,
|
||||
|
@ -1394,7 +1544,7 @@
|
|||
],
|
||||
[
|
||||
[],
|
||||
\"Hides dislikes on all posts. Users can still dislike your posts, you just won't be able to see it.\",
|
||||
\"Hides dislikes on all posts. Users will also no longer be able to dislike your posts.\",
|
||||
\"text\",
|
||||
],
|
||||
[[], \"Fun\", \"title\"],
|
||||
|
@ -1403,6 +1553,11 @@
|
|||
\"{{ profile.settings.disable_gpa_fun }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
[
|
||||
[\"disable_achievements\", \"Disable achievements\"],
|
||||
\"{{ profile.settings.disable_achievements }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
],
|
||||
settings,
|
||||
);
|
||||
|
|
|
@ -7,10 +7,9 @@
|
|||
(meta ("charset" "UTF-8"))
|
||||
(meta ("name" "viewport") ("content" "width=device-width, initial-scale=1.0"))
|
||||
(meta ("http-equiv" "X-UA-Compatible") ("content" "ie=edge"))
|
||||
(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"))
|
||||
(link ("rel" "stylesheet") ("href" "/css/style.css?v=tetratto-{{ random_cache_breaker }}"))
|
||||
|
||||
(text "{% if user -%}
|
||||
<script>
|
||||
|
@ -22,11 +21,10 @@
|
|||
{%- endif %}")
|
||||
|
||||
(text "<script>
|
||||
globalThis.ns_verbose = false;
|
||||
globalThis.ns_config = {
|
||||
root: \"/js/\",
|
||||
verbose: globalThis.ns_verbose,
|
||||
version: \"cache-breaker-{{ random_cache_breaker }}\",
|
||||
verbose: false,
|
||||
version: \"tetratto-{{ random_cache_breaker }}\",
|
||||
};
|
||||
|
||||
globalThis._app_base = {
|
||||
|
@ -36,10 +34,11 @@
|
|||
};
|
||||
|
||||
globalThis.no_policy = false;
|
||||
globalThis.BUILD_CODE = \"{{ random_cache_breaker }}\";
|
||||
</script>")
|
||||
|
||||
(script ("src" "/js/loader.js" ))
|
||||
(script ("src" "/js/atto.js" ))
|
||||
(script ("src" "/js/loader.js?v=tetratto-{{ random_cache_breaker }}" ))
|
||||
(script ("src" "/js/atto.js?v=tetratto-{{ random_cache_breaker }}" ))
|
||||
|
||||
(meta ("name" "theme-color") ("content" "{{ config.color }}"))
|
||||
(meta ("name" "description") ("content" "{{ config.description }}"))
|
||||
|
|
49
crates/app/src/public/html/stacks/add_user.lisp
Normal file
49
crates/app/src/public/html/stacks/add_user.lisp
Normal file
|
@ -0,0 +1,49 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "Add user to stack - {{ config.name }}"))
|
||||
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"\") }}")
|
||||
(main
|
||||
("class" "flex flex-col gap-2")
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small flex items-center gap-2")
|
||||
(text "{{ components::avatar(username=add_user.username, size=\"24px\") }}")
|
||||
(text "{{ components::full_username(user=add_user) }}"))
|
||||
(div
|
||||
("class" "card flex flex-col gap-2")
|
||||
(span (text "Select a stack to add this user to:"))
|
||||
(text "{% for stack in stacks %}")
|
||||
(button
|
||||
("class" "justify-start lowered w-full")
|
||||
("onclick" "choose_stack('{{ stack.id }}')")
|
||||
(icon (text "layers"))
|
||||
(text "{{ stack.name }}"))
|
||||
(text "{% endfor %}"))))
|
||||
|
||||
(script
|
||||
(text "function choose_stack(id) {
|
||||
fetch(`/api/v1/stacks/${id}/users`, {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: \"{{ add_user.username }}\",
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
window.close();
|
||||
}
|
||||
});
|
||||
}"))
|
||||
|
||||
(text "{% endblock %}")
|
|
@ -40,9 +40,9 @@
|
|||
(text "{%- endif %}")))
|
||||
(div
|
||||
("class" "card w-full flex flex-col gap-2")
|
||||
(text "{% if list|length == 0 -%}")
|
||||
(text "{% if stack.users|length == 0 -%}")
|
||||
(p
|
||||
(text "No items yet! Maybe ")
|
||||
(text "No users included yet! Maybe ")
|
||||
(a
|
||||
("href" "/stacks/{{ stack.id }}/manage#/users")
|
||||
(text "add a user to this stack"))
|
||||
|
@ -63,21 +63,32 @@
|
|||
(div
|
||||
("class" "flex gap-2 flex-wrap w-full")
|
||||
(text "{% for user in list %} {{ components::user_plate(user=user, secondary=true) }} {% endfor %}"))
|
||||
(text "{{ components::pagination(page=page, items=list|length) }}")
|
||||
(text "{% else %}")
|
||||
; user icons for circle stack
|
||||
(text "{% if stack.mode == 'Circle' -%} {% for user in stack.users %}")
|
||||
(text "{{ components::avatar(username=user, selector_type=\"id\", size=\"24px\") }}")
|
||||
(text "{% endfor %} {%- endif %}")
|
||||
(text "{% if stack.mode == 'Circle' -%}")
|
||||
(div
|
||||
("class" "flex w-full gap-2 flex-wrap")
|
||||
(text "{% for user in stack.users %}")
|
||||
(a
|
||||
("href" "/api/v1/auth/user/find/{{ user }}")
|
||||
("class" "flush")
|
||||
(text "{{ components::avatar(username=user, selector_type=\"id\", size=\"24px\") }}"))
|
||||
(text "{% endfor %}"))
|
||||
(text "{%- endif %}")
|
||||
|
||||
; posts for all stacks except blocklist
|
||||
(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], poll=post[5]) }}
|
||||
{%- endif %} {%- endif %} {% endfor %}")
|
||||
(text "{%- endif %} {{ components::pagination(page=page, items=list|length) }}"))))
|
||||
(div
|
||||
("class" "w-full flex flex-col gap-2")
|
||||
("ui_ident" "io_data_load")
|
||||
(div ("ui_ident" "io_data_marker")))
|
||||
|
||||
(text "{% set paged = user and user.settings.paged_timelines %}")
|
||||
(script
|
||||
(text "setTimeout(() => {
|
||||
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?stack_id={{ stack.id }}&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
|
||||
});"))
|
||||
(text "{%- endif %}"))))
|
||||
|
||||
(script
|
||||
(text "async function block_all(block = true) {
|
||||
|
|
|
@ -104,7 +104,7 @@
|
|||
(div
|
||||
("class" "flex flex-col gap-1")
|
||||
(label
|
||||
("for" "new_title")
|
||||
("for" "name")
|
||||
(text "{{ text \"communities:label.name\" }}"))
|
||||
(input
|
||||
("type" "text")
|
||||
|
@ -173,7 +173,7 @@
|
|||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/v1/stacks/{{ stack.id }}/users`, {
|
||||
fetch(\"/api/v1/stacks/{{ stack.id }}/users\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
|
|
|
@ -30,6 +30,13 @@
|
|||
(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], poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %} {{ components::pagination(page=page, items=list|length) }}")))
|
||||
("ui_ident" "io_data_load")
|
||||
(div ("ui_ident" "io_data_marker"))))
|
||||
|
||||
(text "{% set paged = user and user.settings.paged_timelines %}")
|
||||
(script
|
||||
(text "setTimeout(() => {
|
||||
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=AllPosts&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
|
||||
});"))
|
||||
|
||||
(text "{% endblock %}")
|
||||
|
|
|
@ -8,6 +8,13 @@
|
|||
(text "{{ macros::timelines_nav(selected=\"following\", 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], poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %} {{ components::pagination(page=page, items=list|length) }}")))
|
||||
("ui_ident" "io_data_load")
|
||||
(div ("ui_ident" "io_data_marker"))))
|
||||
|
||||
(text "{% set paged = user and user.settings.paged_timelines %}")
|
||||
(script
|
||||
(text "setTimeout(() => {
|
||||
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=FollowingPosts&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
|
||||
});"))
|
||||
|
||||
(text "{% endblock %}")
|
||||
|
|
|
@ -27,7 +27,14 @@
|
|||
(text "{% else %}")
|
||||
(div
|
||||
("class" "card w-full flex flex-col gap-2")
|
||||
(text "{% for post in list %} {% 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], poll=post[5]) }} {%- endif %} {% endfor %} {{ components::pagination(page=page, items=list|length) }}"))
|
||||
("ui_ident" "io_data_load")
|
||||
(div ("ui_ident" "io_data_marker")))
|
||||
(text "{%- endif %}"))
|
||||
|
||||
(text "{% set paged = user and user.settings.paged_timelines %}")
|
||||
(script
|
||||
(text "setTimeout(() => {
|
||||
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=MyCommunities&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
|
||||
});"))
|
||||
|
||||
(text "{% endblock %}")
|
||||
|
|
|
@ -8,6 +8,13 @@
|
|||
(text "{{ macros::timelines_nav(selected=\"popular\", posts=\"/popular\", questions=\"/popular/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], poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %} {{ components::pagination(page=page, items=list|length) }}")))
|
||||
("ui_ident" "io_data_load")
|
||||
(div ("ui_ident" "io_data_marker"))))
|
||||
|
||||
(text "{% set paged = user and user.settings.paged_timelines %}")
|
||||
(script
|
||||
(text "setTimeout(() => {
|
||||
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=PopularPosts&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
|
||||
});"))
|
||||
|
||||
(text "{% endblock %}")
|
||||
|
|
|
@ -53,7 +53,7 @@
|
|||
("title" "Search help")
|
||||
("href" "{{ config.manuals.search_help }}")
|
||||
("target" "_blank")
|
||||
(text "{{ icon \"circle-help\" }}"))
|
||||
(text "{{ icon \"circle-question-mark\" }}"))
|
||||
(text "{%- endif %}"))))
|
||||
(text "{%- endif %}")
|
||||
(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], poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %} {% if profile -%} {{ components::pagination(page=page, items=list|length, key=\"&profile=\" ~ profile.id, value=\"&query=\" ~ query) }} {% else %} {{ components::pagination(page=page, items=list|length, key=\"&query=\" ~ query) }} {%- endif %}"))))
|
||||
|
|
36
crates/app/src/public/html/timelines/swiss_army.lisp
Normal file
36
crates/app/src/public/html/timelines/swiss_army.lisp
Normal file
|
@ -0,0 +1,36 @@
|
|||
(text "{%- import \"components.html\" as components -%} {%- import \"macros.html\" as macros -%}")
|
||||
(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], poll=post[5]) }}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{% endfor %}")
|
||||
(datalist
|
||||
("ui_ident" "list_posts_{{ page }}")
|
||||
(text "{% for post in list -%}")
|
||||
(option ("value" "{{ post[0].id }}"))
|
||||
(text "{%- endfor %}"))
|
||||
(text "{% if list|length == 0 -%}")
|
||||
(div
|
||||
("class" "card lowered green flex justify-between items-center gap-2")
|
||||
(div
|
||||
("class" "flex items-center gap-2")
|
||||
(text "{{ icon \"shell\" }}")
|
||||
(span
|
||||
(str (text "general:label.timeline_end"))
|
||||
(text "<!-- observer_disconnect_{{ random_cache_breaker }} -->")))
|
||||
(text "{% if page > 0 -%}")
|
||||
(a
|
||||
("class" "button")
|
||||
("href" "?page=0")
|
||||
(icon (text "arrow-up"))
|
||||
(str (text "chats:label.go_back")))
|
||||
(text "{%- endif %}"))
|
||||
(text "{%- endif %}")
|
||||
|
||||
(text "{% if paginated -%}")
|
||||
(text "{{ components::pagination(page=page, items=list|length) }}")
|
||||
(text "{%- endif %}")
|
|
@ -25,10 +25,10 @@ function media_theme_pref() {
|
|||
}
|
||||
}
|
||||
|
||||
function set_theme(theme) {
|
||||
window.set_theme = (theme) => {
|
||||
window.localStorage.setItem("tetratto:theme", theme);
|
||||
document.documentElement.className = theme;
|
||||
}
|
||||
};
|
||||
|
||||
media_theme_pref();
|
||||
|
||||
|
@ -39,6 +39,7 @@ media_theme_pref();
|
|||
// init
|
||||
use("me", () => {});
|
||||
use("streams", () => {});
|
||||
use("carp", () => {});
|
||||
|
||||
// env
|
||||
self.DEBOUNCE = [];
|
||||
|
@ -91,7 +92,7 @@ media_theme_pref();
|
|||
|
||||
self.define("rel_date", (_, date) => {
|
||||
// stolen and slightly modified because js dates suck
|
||||
const diff = Math.abs((new Date().getTime() - date.getTime()) / 1000);
|
||||
const diff = Math.abs((Date.now() - date.getTime()) / 1000);
|
||||
const day_diff = Math.floor(diff / 86400);
|
||||
|
||||
if (Number.isNaN(day_diff) || day_diff < 0 || day_diff >= 31) {
|
||||
|
@ -116,7 +117,7 @@ media_theme_pref();
|
|||
);
|
||||
});
|
||||
|
||||
self.define("clean_date_codes", ({ $ }) => {
|
||||
self.define("clean_date_codes", async ({ $ }) => {
|
||||
for (const element of Array.from(document.querySelectorAll(".date"))) {
|
||||
if (element.getAttribute("data-unix")) {
|
||||
// this allows us to run the function twice on the same page
|
||||
|
@ -133,7 +134,7 @@ media_theme_pref();
|
|||
|
||||
element.setAttribute("title", then.toLocaleString());
|
||||
|
||||
let pretty = $.rel_date(then) || "";
|
||||
let pretty = (await $.rel_date(then)) || "";
|
||||
|
||||
if (
|
||||
(screen.width < 900 && pretty !== undefined) |
|
||||
|
@ -287,7 +288,7 @@ media_theme_pref();
|
|||
const goals = [150, 250, 500, 1000];
|
||||
|
||||
track_element.setAttribute("data-scroll", "0");
|
||||
scroll_element.addEventListener("scroll", (e) => {
|
||||
scroll_element.addEventListener("scroll", (_) => {
|
||||
track_element.setAttribute("data-scroll", scroll_element.scrollTop);
|
||||
|
||||
for (const goal of goals) {
|
||||
|
@ -396,7 +397,7 @@ media_theme_pref();
|
|||
counter.innerText = `${target.value.length}/${target.getAttribute("maxlength")}`;
|
||||
});
|
||||
|
||||
self.define("hooks::character_counter.init", (_, event) => {
|
||||
self.define("hooks::character_counter.init", (_) => {
|
||||
for (const element of Array.from(
|
||||
document.querySelectorAll("[hook=counter]") || [],
|
||||
)) {
|
||||
|
@ -413,7 +414,7 @@ media_theme_pref();
|
|||
element.innerHTML = full_text;
|
||||
});
|
||||
|
||||
self.define("hooks::long_text.init", (_, event) => {
|
||||
self.define("hooks::long_text.init", (_) => {
|
||||
for (const element of Array.from(
|
||||
document.querySelectorAll("[hook=long]") || [],
|
||||
)) {
|
||||
|
@ -453,18 +454,18 @@ media_theme_pref();
|
|||
}
|
||||
});
|
||||
|
||||
self.define("hooks::spotify_time_text", (_) => {
|
||||
self.define("hooks::spotify_time_text", async (_) => {
|
||||
for (const element of Array.from(
|
||||
document.querySelectorAll("[hook=spotify_time_text]") || [],
|
||||
)) {
|
||||
function render() {
|
||||
async function render() {
|
||||
const updated = element.getAttribute("hook-arg:updated");
|
||||
const progress = element.getAttribute("hook-arg:progress");
|
||||
const duration = element.getAttribute("hook-arg:duration");
|
||||
const display =
|
||||
element.getAttribute("hook-arg:display") || "full";
|
||||
|
||||
element.innerHTML = trigger("spotify::timestamp", [
|
||||
element.innerHTML = await trigger("spotify::timestamp", [
|
||||
updated,
|
||||
progress,
|
||||
duration,
|
||||
|
@ -472,7 +473,7 @@ media_theme_pref();
|
|||
]);
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
setInterval(async () => {
|
||||
element.setAttribute(
|
||||
"hook-arg:updated",
|
||||
Number.parseInt(element.getAttribute("hook-arg:updated")) +
|
||||
|
@ -485,21 +486,21 @@ media_theme_pref();
|
|||
1000,
|
||||
);
|
||||
|
||||
render();
|
||||
await render();
|
||||
}, 1000);
|
||||
|
||||
render();
|
||||
await render();
|
||||
}
|
||||
});
|
||||
|
||||
self.define("last_seen_just_now", (_, last_seen) => {
|
||||
const now = new Date().getTime();
|
||||
const now = Date.now();
|
||||
const maximum_time_to_be_considered_online = 60000 * 2; // 2 minutes
|
||||
return now - last_seen <= maximum_time_to_be_considered_online;
|
||||
});
|
||||
|
||||
self.define("last_seen_recently", (_, last_seen) => {
|
||||
const now = new Date().getTime();
|
||||
const now = Date.now();
|
||||
const maximum_time_to_be_considered_idle = 60000 * 5; // 5 minutes
|
||||
return now - last_seen <= maximum_time_to_be_considered_idle;
|
||||
});
|
||||
|
@ -585,8 +586,8 @@ media_theme_pref();
|
|||
|
||||
self.define(
|
||||
"hooks::attach_to_partial",
|
||||
({ $ }, partial, full, attach, wrapper, page, run_on_load) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
({ $ }, partial, full, attach, wrapper, page) => {
|
||||
return new Promise((resolve, _) => {
|
||||
async function load_partial() {
|
||||
const url = `${partial}${partial.includes("?") ? "&" : "?"}page=${page}`;
|
||||
history.replaceState(
|
||||
|
@ -618,7 +619,6 @@ media_theme_pref();
|
|||
.catch(() => {
|
||||
// done scrolling, no more pages (http error)
|
||||
wrapper.removeEventListener("scroll", event);
|
||||
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
|
@ -635,7 +635,6 @@ media_theme_pref();
|
|||
return;
|
||||
}
|
||||
|
||||
// biome-ignore lint/style/noParameterAssign: no it isn't
|
||||
page += 1;
|
||||
await load_partial();
|
||||
})
|
||||
|
@ -651,7 +650,7 @@ media_theme_pref();
|
|||
);
|
||||
|
||||
self.define("hooks::check_reactions", async ({ $ }) => {
|
||||
const observer = $.offload_work_to_client_when_in_view(
|
||||
const observer = await $.offload_work_to_client_when_in_view(
|
||||
async (element) => {
|
||||
const like = element.querySelector(
|
||||
'[hook_element="reaction.like"]',
|
||||
|
@ -687,7 +686,39 @@ media_theme_pref();
|
|||
$.OBSERVERS.push(observer);
|
||||
});
|
||||
|
||||
self.define("hooks::check_message_reactions", async ({ $ }) => {
|
||||
const observer = $.offload_work_to_client_when_in_view(
|
||||
async (element) => {
|
||||
const reactions = await (
|
||||
await fetch(
|
||||
`/api/v1/message_reactions/${element.getAttribute("hook-arg:id")}`,
|
||||
)
|
||||
).json();
|
||||
|
||||
if (reactions.ok) {
|
||||
for (const reaction of reactions.payload) {
|
||||
element
|
||||
.querySelector(
|
||||
`[ui_ident=emoji_${reaction.emoji.replaceAll(":", "\\:")}]`,
|
||||
)
|
||||
.classList.remove("lowered");
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
for (const element of Array.from(
|
||||
document.querySelectorAll("[hook=check_message_reactions]") || [],
|
||||
)) {
|
||||
observer.observe(element);
|
||||
}
|
||||
|
||||
$.OBSERVERS.push(observer);
|
||||
});
|
||||
|
||||
self.define("hooks::tabs:switch", (_, tab) => {
|
||||
tab = tab.split("?")[0];
|
||||
|
||||
// tab
|
||||
for (const element of Array.from(
|
||||
document.querySelectorAll("[data-tab]"),
|
||||
|
@ -805,7 +836,6 @@ media_theme_pref();
|
|||
}, time_until_remove * 1000);
|
||||
|
||||
const count_interval = setInterval(() => {
|
||||
// biome-ignore lint/style/noParameterAssign: no it isn't
|
||||
time_until_remove -= 1;
|
||||
timer.innerText = time_until_remove;
|
||||
}, 1000);
|
||||
|
@ -848,7 +878,7 @@ media_theme_pref();
|
|||
})();
|
||||
|
||||
// ui ns
|
||||
(() => {
|
||||
(async () => {
|
||||
const self = reg_ns("ui");
|
||||
window.SETTING_SET_FUNCTIONS = [];
|
||||
|
||||
|
@ -886,7 +916,7 @@ media_theme_pref();
|
|||
}
|
||||
|
||||
if (option.input_element_type === "checkbox") {
|
||||
into_element.innerHTML += `<div class="card flex gap-2">
|
||||
into_element.innerHTML += `<div class="card flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
onchange="window.set_setting_field${id_key}('${option.key}', event.target.checked)"
|
||||
|
@ -1019,7 +1049,7 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
|
|||
window.update_field_with_color = (key, value) => {
|
||||
console.log("sync_color_text", key);
|
||||
document.getElementById(key).value = value;
|
||||
set_setting_field(key, value);
|
||||
window.SETTING_SET_FUNCTIONS[0](key, value);
|
||||
preview_color(key, value);
|
||||
};
|
||||
|
||||
|
@ -1119,6 +1149,199 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
|
|||
document.getElementById("lightbox").classList.add("hidden");
|
||||
}, 250);
|
||||
});
|
||||
|
||||
// intersection observer infinite scrolling
|
||||
self.IO_DATA_OBSERVER = null;
|
||||
|
||||
self.define(
|
||||
"io_data_load",
|
||||
async (_, tmpl, page, paginated_mode = false) => {
|
||||
// remove old
|
||||
const obs = self.IO_DATA_OBSERVER;
|
||||
if (obs) {
|
||||
console.log("get lost old observer");
|
||||
obs.disconnect();
|
||||
self.IO_DATA_OBSERVER = null;
|
||||
}
|
||||
|
||||
self.IO_DATA_OBSERVER = new IntersectionObserver(
|
||||
async (entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await self.io_load_data();
|
||||
break;
|
||||
}
|
||||
},
|
||||
{
|
||||
root: document.body,
|
||||
rootMargin: "0px",
|
||||
threshold: 1,
|
||||
},
|
||||
);
|
||||
|
||||
// ...
|
||||
self.IO_DATA_MARKER = document.querySelector(
|
||||
"[ui_ident=io_data_marker]",
|
||||
);
|
||||
|
||||
self.IO_DATA_ELEMENT = document.querySelector(
|
||||
"[ui_ident=io_data_load]",
|
||||
);
|
||||
|
||||
self.IO_HTML_TMPL = document.getElementById("loading_skeleton");
|
||||
|
||||
if (!self.IO_DATA_ELEMENT || !self.IO_DATA_MARKER) {
|
||||
console.warn(
|
||||
"ui::io_data_load called, but required elements don't exist",
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
self.IO_DATA_TMPL = tmpl;
|
||||
self.IO_DATA_PAGE = page;
|
||||
self.IO_DATA_SEEN_IDS = [];
|
||||
self.IO_DATA_WAITING = false;
|
||||
self.IO_HAS_LOADED_AT_LEAST_ONCE = false;
|
||||
self.IO_DATA_DISCONNECTED = false;
|
||||
self.IO_DATA_DISABLE_RELOAD = false;
|
||||
|
||||
if (!paginated_mode) {
|
||||
self.IO_DATA_OBSERVER.observe(self.IO_DATA_MARKER);
|
||||
} else {
|
||||
// immediately load first page
|
||||
self.IO_DATA_TMPL = self.IO_DATA_TMPL.replace("&page=", "");
|
||||
self.IO_DATA_TMPL += `&paginated=true&page=`;
|
||||
self.io_load_data();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (self.IO_DATA_DISABLE_RELOAD) {
|
||||
console.log("missing data reload disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!self.IO_HAS_LOADED_AT_LEAST_ONCE) {
|
||||
// reload
|
||||
self.IO_DATA_OBSERVER.disconnect();
|
||||
console.log("timeline load fail :(");
|
||||
window.location.reload();
|
||||
}
|
||||
}, 1500);
|
||||
|
||||
self.IO_PAGINATED = paginated_mode;
|
||||
},
|
||||
);
|
||||
|
||||
self.define("io_load_data", async () => {
|
||||
if (self.IO_DATA_WAITING) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.IO_HAS_LOADED_AT_LEAST_ONCE = true;
|
||||
self.IO_DATA_WAITING = true;
|
||||
self.IO_DATA_PAGE += 1;
|
||||
console.log("load page", self.IO_DATA_PAGE);
|
||||
|
||||
// show loading component
|
||||
const loading = self.IO_HTML_TMPL.content.cloneNode(true);
|
||||
self.IO_DATA_ELEMENT.appendChild(loading);
|
||||
|
||||
// ...
|
||||
const text = await (
|
||||
await fetch(`${self.IO_DATA_TMPL}${self.IO_DATA_PAGE}`)
|
||||
).text();
|
||||
|
||||
self.IO_DATA_WAITING = false;
|
||||
|
||||
const loading_skel = self.IO_DATA_ELEMENT.querySelector(
|
||||
"[ui_ident=loading_skel]",
|
||||
);
|
||||
|
||||
if (loading_skel) {
|
||||
loading_skel.remove();
|
||||
}
|
||||
|
||||
if (self.IO_DATA_DISCONNECTED) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
text.includes(`!<!-- observer_disconnect_${window.BUILD_CODE} -->`)
|
||||
) {
|
||||
console.log("io_data_end; disconnect");
|
||||
self.IO_DATA_OBSERVER.disconnect();
|
||||
self.IO_DATA_ELEMENT.innerHTML += text;
|
||||
self.IO_DATA_DISCONNECTED = true;
|
||||
return;
|
||||
}
|
||||
|
||||
self.IO_DATA_ELEMENT.innerHTML += text;
|
||||
|
||||
setTimeout(() => {
|
||||
// move marker to bottom of dom hierarchy
|
||||
self.IO_DATA_ELEMENT.children[
|
||||
self.IO_DATA_ELEMENT.children.length - 1
|
||||
].after(self.IO_DATA_MARKER);
|
||||
|
||||
// remove posts we've already seen
|
||||
function remove_elements(id, outer = false) {
|
||||
let idx = 0;
|
||||
for (const element of Array.from(
|
||||
document.querySelectorAll(
|
||||
`.post${outer ? "_outer" : ""}\\:${id}`,
|
||||
),
|
||||
)) {
|
||||
if (element.getAttribute("is_repost") === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (idx === 0) {
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// everything that isn't the first element should be removed
|
||||
element.remove();
|
||||
console.log("removed duplicate post");
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of self.IO_DATA_SEEN_IDS) {
|
||||
remove_elements(id, false);
|
||||
remove_elements(id, true); // scoop up questions
|
||||
}
|
||||
|
||||
// push ids
|
||||
for (const opt of Array.from(
|
||||
document.querySelectorAll(
|
||||
`[ui_ident=list_posts_${self.IO_DATA_PAGE}] option`,
|
||||
),
|
||||
)) {
|
||||
const v = opt.getAttribute("value");
|
||||
|
||||
if (!self.IO_DATA_SEEN_IDS[v]) {
|
||||
self.IO_DATA_SEEN_IDS.push(v);
|
||||
}
|
||||
}
|
||||
}, 150);
|
||||
|
||||
// run hooks
|
||||
const atto = await ns("atto");
|
||||
|
||||
atto.clean_date_codes();
|
||||
atto.clean_poll_date_codes();
|
||||
atto.link_filter();
|
||||
|
||||
atto["hooks::long_text.init"]();
|
||||
atto["hooks::alt"]();
|
||||
atto["hooks::online_indicator"]();
|
||||
atto["hooks::verify_emoji"]();
|
||||
atto["hooks::check_reactions"]();
|
||||
});
|
||||
})();
|
||||
|
||||
(() => {
|
||||
|
@ -1130,7 +1353,7 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
|
|||
|
||||
self.define(
|
||||
"open",
|
||||
async ({ $ }, warning_id, warning_hash, warning_page = "") => {
|
||||
async (_, warning_id, warning_hash, warning_page = "") => {
|
||||
// check localStorage for this warning_id
|
||||
if (accepted_warnings[warning_id] !== undefined) {
|
||||
// check hash
|
||||
|
@ -1151,7 +1374,7 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
|
|||
},
|
||||
);
|
||||
|
||||
self.define("accept", ({ _ }, warning_id, warning_hash) => {
|
||||
self.define("accept", (_, warning_id, warning_hash) => {
|
||||
accepted_warnings[warning_id] = warning_hash;
|
||||
|
||||
window.localStorage.setItem(
|
||||
|
|
624
crates/app/src/public/js/carp.js
Normal file
624
crates/app/src/public/js/carp.js
Normal file
|
@ -0,0 +1,624 @@
|
|||
(() => {
|
||||
const self = reg_ns("carp");
|
||||
|
||||
const END_OF_HEADER = 0x1a;
|
||||
const COLOR = 0x1b;
|
||||
const SIZE = 0x2b;
|
||||
const LINE = 0x3b;
|
||||
const POINT = 0x4b;
|
||||
const EOF = 0x1f;
|
||||
|
||||
function enc(s, as = "guess") {
|
||||
if ((as === "guess" && typeof s === "number") || as === "u32") {
|
||||
// encode u32
|
||||
const view = new DataView(new ArrayBuffer(16));
|
||||
view.setUint32(0, s);
|
||||
return new Uint8Array(view.buffer).slice(0, 4);
|
||||
}
|
||||
|
||||
if (as === "u16") {
|
||||
// encode u16
|
||||
const view = new DataView(new ArrayBuffer(16));
|
||||
view.setUint16(0, s);
|
||||
return new Uint8Array(view.buffer).slice(0, 2);
|
||||
}
|
||||
|
||||
// encode string
|
||||
const encoder = new TextEncoder();
|
||||
return encoder.encode(s);
|
||||
}
|
||||
|
||||
function dec(as, from) {
|
||||
if (as === "u32") {
|
||||
// decode u32
|
||||
const view = new DataView(new Uint8Array(from).buffer);
|
||||
return view.getUint32(0);
|
||||
}
|
||||
|
||||
if (as === "u16") {
|
||||
// decode u16
|
||||
const view = new DataView(new Uint8Array(from).buffer);
|
||||
return view.getUint16(0);
|
||||
}
|
||||
|
||||
// decode string
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(from);
|
||||
}
|
||||
|
||||
function lpad(size, input) {
|
||||
if (input.length === size) {
|
||||
return input;
|
||||
}
|
||||
|
||||
for (let i = 0; i < size - (input.length - 1); i++) {
|
||||
input = [0, ...input];
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
self.enc = enc;
|
||||
self.dec = dec;
|
||||
self.lpad = lpad;
|
||||
|
||||
self.CARPS = {};
|
||||
self.define("new", function ({ $ }, bind_to, read_only = false) {
|
||||
const canvas = new CarpCanvas(bind_to, read_only);
|
||||
$.CARPS[bind_to.getAttribute("ui_ident")] = canvas;
|
||||
return canvas;
|
||||
});
|
||||
|
||||
class CarpCanvas {
|
||||
#element; // HTMLElement
|
||||
#ctx; // CanvasRenderingContext2D
|
||||
#pos = { x: 0, y: 0 }; // Vec2
|
||||
|
||||
STROKE_SIZE = 2;
|
||||
#stroke_size_old = 2;
|
||||
COLOR = "#000000";
|
||||
#color_old = "#000000";
|
||||
|
||||
COMMANDS = [];
|
||||
HISTORY = [];
|
||||
HISTORY_IDX = 0;
|
||||
#cmd_store = [];
|
||||
#undo_clear_future = false; // if we should clear to HISTORY_IDX on next draw
|
||||
|
||||
onedit;
|
||||
read_only;
|
||||
|
||||
/// Create a new [`CarpCanvas`]
|
||||
constructor(element, read_only) {
|
||||
this.#element = element;
|
||||
this.read_only = read_only;
|
||||
}
|
||||
|
||||
/// Push #line_store to LINES
|
||||
push_state() {
|
||||
this.COMMANDS = [...this.COMMANDS, ...this.#cmd_store];
|
||||
this.#cmd_store = [];
|
||||
|
||||
this.HISTORY.push(this.COMMANDS);
|
||||
this.HISTORY_IDX += 1;
|
||||
|
||||
if (this.#undo_clear_future) {
|
||||
this.HISTORY = this.HISTORY.slice(0, this.HISTORY_IDX);
|
||||
this.#undo_clear_future = false;
|
||||
}
|
||||
|
||||
if (this.onedit) {
|
||||
this.onedit(this.as_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Read current position in history and draw it.
|
||||
draw_from_history() {
|
||||
this.COMMANDS = this.HISTORY[this.HISTORY_IDX];
|
||||
const bytes = this.as_carp2();
|
||||
this.from_bytes(bytes); // draw
|
||||
}
|
||||
|
||||
/// Undo changes.
|
||||
undo() {
|
||||
if (this.HISTORY_IDX === 0) {
|
||||
// cannot undo
|
||||
return;
|
||||
}
|
||||
|
||||
this.HISTORY_IDX -= 1;
|
||||
this.draw_from_history();
|
||||
this.#undo_clear_future = false;
|
||||
}
|
||||
|
||||
/// Redo changes.
|
||||
redo() {
|
||||
if (this.HISTORY_IDX === this.HISTORY.length - 1) {
|
||||
// cannot redo
|
||||
return;
|
||||
}
|
||||
|
||||
this.HISTORY_IDX += 1;
|
||||
this.draw_from_history();
|
||||
}
|
||||
|
||||
/// Create canvas and init context
|
||||
async create_canvas() {
|
||||
const canvas = document.createElement("canvas");
|
||||
|
||||
canvas.width = "300";
|
||||
canvas.height = "200";
|
||||
|
||||
this.#ctx = canvas.getContext("2d");
|
||||
|
||||
if (!this.read_only) {
|
||||
// desktop
|
||||
canvas.addEventListener(
|
||||
"mousemove",
|
||||
(e) => {
|
||||
this.draw_event(e);
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
canvas.addEventListener(
|
||||
"mouseup",
|
||||
(e) => {
|
||||
this.push_state();
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
canvas.addEventListener(
|
||||
"mousedown",
|
||||
(e) => {
|
||||
this.#cmd_store.push({
|
||||
type: "Line",
|
||||
data: [],
|
||||
});
|
||||
|
||||
this.move_event(e);
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
canvas.addEventListener(
|
||||
"mouseenter",
|
||||
(e) => {
|
||||
this.move_event(e);
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
// mobile
|
||||
canvas.addEventListener(
|
||||
"touchmove",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
e.clientX = e.changedTouches[0].clientX;
|
||||
e.clientY = e.changedTouches[0].clientY;
|
||||
|
||||
this.draw_event(e, true);
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
canvas.addEventListener(
|
||||
"touchstart",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
e.clientX = e.changedTouches[0].clientX;
|
||||
e.clientY = e.changedTouches[0].clientY;
|
||||
|
||||
this.#cmd_store.push({
|
||||
type: "Line",
|
||||
data: [],
|
||||
});
|
||||
|
||||
this.move_event(e);
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
canvas.addEventListener(
|
||||
"touchend",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
e.clientX = e.changedTouches[0].clientX;
|
||||
e.clientY = e.changedTouches[0].clientY;
|
||||
|
||||
this.push_state();
|
||||
this.move_event(e);
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
// add controls
|
||||
const controls_tmpl = document
|
||||
.getElementById("carp_canvas")
|
||||
.content.cloneNode(true);
|
||||
this.#element.appendChild(controls_tmpl);
|
||||
|
||||
const canvas_loc = this.#element.querySelector(
|
||||
"[ui_ident=canvas_loc]",
|
||||
);
|
||||
canvas_loc.appendChild(canvas);
|
||||
|
||||
const color_picker = this.#element.querySelector(
|
||||
"[ui_ident=color_picker]",
|
||||
);
|
||||
color_picker.addEventListener("change", (e) => {
|
||||
this.set_old_color(this.COLOR);
|
||||
this.COLOR = e.target.value;
|
||||
});
|
||||
|
||||
const stroke_range = this.#element.querySelector(
|
||||
"[ui_ident=stroke_range]",
|
||||
);
|
||||
stroke_range.addEventListener("change", (e) => {
|
||||
this.set_old_stroke_size(this.STROKE_SIZE);
|
||||
this.STROKE_SIZE = e.target.value;
|
||||
});
|
||||
|
||||
const undo = this.#element.querySelector("[ui_ident=undo]");
|
||||
undo.addEventListener("click", () => {
|
||||
this.undo();
|
||||
});
|
||||
|
||||
const redo = this.#element.querySelector("[ui_ident=redo]");
|
||||
redo.addEventListener("click", () => {
|
||||
this.redo();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Resize the canvas
|
||||
resize(size) {
|
||||
this.#ctx.canvas.width = size.x;
|
||||
this.#ctx.canvas.height = size.y;
|
||||
}
|
||||
|
||||
/// Clear the canvas
|
||||
clear() {
|
||||
const canvas = this.#ctx.canvas;
|
||||
this.#ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
/// Set the old color
|
||||
set_old_color(value) {
|
||||
this.#color_old = value;
|
||||
}
|
||||
|
||||
/// Set the old stroke_size
|
||||
set_old_stroke_size(value) {
|
||||
this.#stroke_size_old = value;
|
||||
}
|
||||
|
||||
/// Update position (from event)
|
||||
move_event(e) {
|
||||
const rect = this.#ctx.canvas.getBoundingClientRect();
|
||||
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
this.move({ x, y });
|
||||
}
|
||||
|
||||
/// Update position
|
||||
move(pos) {
|
||||
this.#pos.x = pos.x;
|
||||
this.#pos.y = pos.y;
|
||||
}
|
||||
|
||||
/// Draw on the canvas (from event)
|
||||
draw_event(e, mobile = false) {
|
||||
if (e.buttons !== 1 && mobile === false) return;
|
||||
const rect = this.#ctx.canvas.getBoundingClientRect();
|
||||
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
this.draw({ x, y });
|
||||
}
|
||||
|
||||
/// Draw on the canvas
|
||||
draw(pos, skip_line_store = false) {
|
||||
this.#ctx.beginPath();
|
||||
|
||||
this.#ctx.lineWidth = this.STROKE_SIZE;
|
||||
this.#ctx.strokeStyle = this.COLOR;
|
||||
this.#ctx.lineCap = "round";
|
||||
|
||||
this.#ctx.moveTo(this.#pos.x, this.#pos.y);
|
||||
this.move(pos);
|
||||
this.#ctx.lineTo(this.#pos.x, this.#pos.y);
|
||||
|
||||
if (!skip_line_store) {
|
||||
// yes flooring the values will make the image SLIGHTLY different,
|
||||
// but it also saves THOUSANDS of characters
|
||||
const point = [
|
||||
Math.floor(this.#pos.x),
|
||||
Math.floor(this.#pos.y),
|
||||
];
|
||||
|
||||
if (this.#color_old !== this.COLOR) {
|
||||
this.#cmd_store.push({
|
||||
type: "Color",
|
||||
data: enc(this.COLOR.replace("#", "")),
|
||||
});
|
||||
}
|
||||
|
||||
if (this.#stroke_size_old !== this.STROKE_SIZE) {
|
||||
this.#cmd_store.push({
|
||||
type: "Size",
|
||||
data: lpad(2, enc(this.STROKE_SIZE, "u16")), // u16
|
||||
});
|
||||
}
|
||||
|
||||
this.#cmd_store.push({
|
||||
type: "Point",
|
||||
data: [
|
||||
// u32
|
||||
...lpad(4, enc(point[0])),
|
||||
...lpad(4, enc(point[1])),
|
||||
],
|
||||
});
|
||||
|
||||
if (this.#color_old !== this.COLOR) {
|
||||
// we've already seen it once, time to update it
|
||||
this.set_old_color(this.COLOR);
|
||||
}
|
||||
|
||||
if (this.#stroke_size_old !== this.STROKE_SIZE) {
|
||||
this.set_old_stroke_size(this.STROKE_SIZE);
|
||||
}
|
||||
}
|
||||
|
||||
this.#ctx.stroke();
|
||||
}
|
||||
|
||||
/// Create blob and get URL
|
||||
as_blob() {
|
||||
const blob = this.#ctx.canvas.toBlob();
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
/// Create Carp2 representation of the graph
|
||||
as_carp2() {
|
||||
// most stuff should have an lpad of 4 to make sure it's a u32 (4 bytes)
|
||||
const header = [
|
||||
...enc("CG"),
|
||||
...enc("02"),
|
||||
...lpad(4, enc(this.#ctx.canvas.width)),
|
||||
...lpad(4, enc(this.#ctx.canvas.height)),
|
||||
END_OF_HEADER,
|
||||
];
|
||||
|
||||
// build commands
|
||||
const commands = [];
|
||||
commands.push(COLOR);
|
||||
commands.push(...enc("000000"));
|
||||
commands.push(SIZE);
|
||||
commands.push(...lpad(4, enc(2)).slice(2));
|
||||
|
||||
for (const command of this.COMMANDS) {
|
||||
// this is `impl Into<Vec<u8>> for Command`
|
||||
switch (command.type) {
|
||||
case "Point":
|
||||
commands.push(POINT);
|
||||
break;
|
||||
|
||||
case "Line":
|
||||
commands.push(LINE);
|
||||
break;
|
||||
|
||||
case "Color":
|
||||
commands.push(COLOR);
|
||||
break;
|
||||
|
||||
case "Size":
|
||||
commands.push(SIZE);
|
||||
break;
|
||||
}
|
||||
|
||||
commands.push(...command.data);
|
||||
}
|
||||
|
||||
// this is so fucking stupid the fact that arraybuffers send as a fucking
|
||||
// concatenated string of the NUMBERS of the bytes is so stupid this is
|
||||
// actually crazy what the fuck is this shit
|
||||
//
|
||||
// didn't expect i'd have to do this shit myself considering it's done
|
||||
// for you with File prototypes from a file input
|
||||
const bin = [...header, ...commands, EOF];
|
||||
let bin_str = "";
|
||||
|
||||
for (const byte of bin) {
|
||||
bin_str += String.fromCharCode(byte);
|
||||
}
|
||||
|
||||
// return
|
||||
return bin;
|
||||
}
|
||||
|
||||
/// Export lines as string
|
||||
as_string() {
|
||||
return JSON.stringify(this.COMMANDS);
|
||||
}
|
||||
|
||||
/// From an array of bytes
|
||||
from_bytes(input) {
|
||||
this.clear();
|
||||
|
||||
let idx = -1;
|
||||
function next() {
|
||||
idx += 1;
|
||||
return [idx, input[idx]];
|
||||
}
|
||||
|
||||
function select_bytes(count) {
|
||||
// select_bytes! macro
|
||||
const data = [];
|
||||
let seen_bytes = 0;
|
||||
|
||||
let [_, byte] = next();
|
||||
while (byte !== undefined) {
|
||||
seen_bytes += 1;
|
||||
data.push(byte);
|
||||
|
||||
if (seen_bytes === count) {
|
||||
break;
|
||||
}
|
||||
|
||||
[_, byte] = next();
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// everything past this is just a reverse implementation of carp2.rs in js
|
||||
const commands = [];
|
||||
const dimensions = { x: 0, y: 0 };
|
||||
let in_header = true;
|
||||
let seen_point = false;
|
||||
let byte_buffer = [];
|
||||
|
||||
let [i, byte] = next();
|
||||
while (byte !== undefined) {
|
||||
switch (byte) {
|
||||
case END_OF_HEADER:
|
||||
in_header = false;
|
||||
break;
|
||||
|
||||
case COLOR:
|
||||
{
|
||||
const data = select_bytes(6);
|
||||
commands.push({
|
||||
type: "Color",
|
||||
data,
|
||||
});
|
||||
this.COLOR = `#${dec("string", new Uint8Array(data))}`;
|
||||
}
|
||||
break;
|
||||
|
||||
case SIZE:
|
||||
{
|
||||
const data = select_bytes(2);
|
||||
commands.push({
|
||||
type: "Size",
|
||||
data,
|
||||
});
|
||||
this.STROKE_SIZE = dec("u16", data);
|
||||
}
|
||||
break;
|
||||
|
||||
case POINT:
|
||||
{
|
||||
const data = select_bytes(8);
|
||||
commands.push({
|
||||
type: "Point",
|
||||
data,
|
||||
});
|
||||
|
||||
const point = {
|
||||
x: dec("u32", data.slice(0, 4)),
|
||||
y: dec("u32", data.slice(4, 8)),
|
||||
};
|
||||
|
||||
if (!seen_point) {
|
||||
// this is the FIRST POINT that has been seen...
|
||||
// we need to start drawing from here to avoid a line
|
||||
// from 0,0 to the point
|
||||
this.move(point);
|
||||
seen_point = true;
|
||||
}
|
||||
|
||||
this.draw(point, true);
|
||||
}
|
||||
break;
|
||||
|
||||
case LINE:
|
||||
// each line starts at a new place (probably)
|
||||
seen_point = false;
|
||||
break;
|
||||
|
||||
case EOF:
|
||||
break;
|
||||
|
||||
default:
|
||||
if (in_header) {
|
||||
if (0 <= i < 2) {
|
||||
// tag
|
||||
} else if (2 <= i < 4) {
|
||||
//version
|
||||
} else if (4 <= i < 8) {
|
||||
// width
|
||||
byte_buffer.push(byte);
|
||||
|
||||
if (i === 7) {
|
||||
dimensions.x = dec("u32", byte_buffer);
|
||||
byte_buffer = [];
|
||||
}
|
||||
} else if (8 <= i < 12) {
|
||||
// height
|
||||
byte_buffer.push(byte);
|
||||
|
||||
if (i === 7) {
|
||||
dimensions.y = dec("u32", byte_buffer);
|
||||
byte_buffer = [];
|
||||
this.resize(dimensions); // update canvas
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// misc byte
|
||||
console.log(`extraneous byte at ${i}`);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// ...
|
||||
[i, byte] = next();
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
/// Download image as `.carpgraph`
|
||||
download() {
|
||||
const blob = new Blob([new Uint8Array(this.as_carp2())], {
|
||||
type: "image/carpgraph",
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.setAttribute("download", "image.carpgraph");
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/// Download image as `.carpgraph1`
|
||||
download_json() {
|
||||
const string = this.as_string();
|
||||
const blob = new Blob([string], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.setAttribute("download", "image.carpgraph_json");
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
})();
|
|
@ -16,19 +16,32 @@ function regns_log(level, ...args) {
|
|||
}
|
||||
|
||||
/// Query an existing namespace
|
||||
globalThis.ns = (ns) => {
|
||||
globalThis.ns = async (ns) => {
|
||||
regns_log("info", "namespace query:", ns);
|
||||
|
||||
// get namespace from app base
|
||||
const res = globalThis._app_base.ns_store[`$${ns}`];
|
||||
let res = globalThis._app_base.ns_store[`$${ns}`];
|
||||
let tries = 0;
|
||||
|
||||
if (!res) {
|
||||
while (!res) {
|
||||
if (tries >= 5) {
|
||||
return console.error(
|
||||
"namespace does not exist, please use one of the following:",
|
||||
`namespace "${ns}" does not exist, please use one of the following:`,
|
||||
Object.keys(globalThis._app_base.ns_store),
|
||||
);
|
||||
}
|
||||
|
||||
tries += 1;
|
||||
res = globalThis._app_base.ns_store[`$${ns}`];
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
|
||||
regns_log("info", `found ns "${ns}" after ${tries} tries`);
|
||||
return res;
|
||||
};
|
||||
|
||||
|
@ -51,12 +64,12 @@ globalThis.reg_ns = (ns, deps) => {
|
|||
_ident: ns,
|
||||
_deps: deps || [],
|
||||
/// Pull dependencies (other namespaces) as listed in the given `deps` argument
|
||||
_get_deps: () => {
|
||||
_get_deps: async () => {
|
||||
const self = globalThis._app_base.ns_store[`$${ns}`];
|
||||
const deps = {};
|
||||
|
||||
for (const dep of self._deps) {
|
||||
const res = globalThis.ns(dep);
|
||||
const res = await globalThis.ns(dep);
|
||||
|
||||
if (!res) {
|
||||
regns_log("warn", "failed to pull dependency:", dep);
|
||||
|
@ -72,16 +85,15 @@ globalThis.reg_ns = (ns, deps) => {
|
|||
/// Store the real versions of functions
|
||||
_fn_store: {},
|
||||
/// Call a function in a namespace and load namespace dependencies
|
||||
define: (name, func, types) => {
|
||||
const self = globalThis.ns(ns);
|
||||
define: async (name, func, types) => {
|
||||
const self = await globalThis.ns(ns);
|
||||
self._fn_store[name] = func; // store real function
|
||||
self[name] = function (...args) {
|
||||
self[name] = async (...args) => {
|
||||
regns_log("info", "namespace call:", ns, name);
|
||||
|
||||
// js doesn't provide type checking, we do
|
||||
if (types) {
|
||||
for (const i in args) {
|
||||
// biome-ignore lint: this is incorrect, you do not need a string literal to use typeof
|
||||
if (types[i] && typeof args[i] !== types[i]) {
|
||||
return console.error(
|
||||
"argument does not pass type check:",
|
||||
|
@ -94,7 +106,7 @@ globalThis.reg_ns = (ns, deps) => {
|
|||
|
||||
// ...
|
||||
// we MUST return here, otherwise nothing will work in workers
|
||||
return self._fn_store[name](self._get_deps(), ...args); // call with deps and arguments
|
||||
return self._fn_store[name](await self._get_deps(), ...args); // call with deps and arguments
|
||||
};
|
||||
},
|
||||
};
|
||||
|
@ -104,11 +116,11 @@ globalThis.reg_ns = (ns, deps) => {
|
|||
};
|
||||
|
||||
/// Call a namespace function quickly
|
||||
globalThis.trigger = (id, args) => {
|
||||
globalThis.trigger = async (id, args) => {
|
||||
// get namespace
|
||||
const s = id.split("::");
|
||||
const [namespace, func] = [s[0], s.slice(1, s.length).join("::")];
|
||||
const self = ns(namespace);
|
||||
const self = await ns(namespace);
|
||||
|
||||
if (!self) {
|
||||
return console.error("namespace does not exist:", namespace);
|
||||
|
|
|
@ -204,6 +204,47 @@
|
|||
});
|
||||
});
|
||||
|
||||
self.define("message_react", async (_, element, message, emoji) => {
|
||||
await trigger("atto::debounce", ["reactions::toggle"]);
|
||||
fetch("/api/v1/message_reactions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
emoji,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger("atto::toast", [
|
||||
res.ok ? "success" : "error",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
if (res.message.includes("created")) {
|
||||
const x = element.querySelector(
|
||||
`[ui_ident=emoji_${emoji.replaceAll(":", "\\:")}]`,
|
||||
);
|
||||
|
||||
if (x) {
|
||||
x.classList.remove("lowered");
|
||||
}
|
||||
} else {
|
||||
const x = element.querySelector(
|
||||
`[ui_ident=emoji_${emoji.replaceAll(":", "\\:")}]`,
|
||||
);
|
||||
|
||||
if (x) {
|
||||
x.classList.add("lowered");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
self.define("remove_notification", (_, id) => {
|
||||
fetch(`/api/v1/notifications/${id}`, {
|
||||
method: "DELETE",
|
||||
|
@ -259,7 +300,7 @@
|
|||
|
||||
self.define(
|
||||
"repost",
|
||||
(
|
||||
async (
|
||||
_,
|
||||
id,
|
||||
content,
|
||||
|
@ -267,6 +308,7 @@
|
|||
do_not_redirect = false,
|
||||
is_stack = false,
|
||||
) => {
|
||||
await trigger("atto::debounce", ["posts::create"]);
|
||||
return new Promise((resolve, _) => {
|
||||
fetch(`/api/v1/posts/${id}/repost`, {
|
||||
method: "POST",
|
||||
|
@ -489,6 +531,78 @@
|
|||
return out;
|
||||
});
|
||||
|
||||
// share intents
|
||||
self.define(
|
||||
"gen_share",
|
||||
(
|
||||
_,
|
||||
ids = { q: "0", p: "0" },
|
||||
target_length = 280,
|
||||
include_link = true,
|
||||
) => {
|
||||
const part_1 = (
|
||||
document.getElementById(`question_content:${ids.q}`) || {
|
||||
innerText: "",
|
||||
}
|
||||
).innerText;
|
||||
|
||||
const part_2 = document.getElementById(
|
||||
`post_content:${ids.p}`,
|
||||
).innerText;
|
||||
|
||||
// ...
|
||||
const link =
|
||||
include_link !== false
|
||||
? `${window.location.origin}/post/${ids.p}`
|
||||
: "";
|
||||
|
||||
const link_size = link.length;
|
||||
target_length -= link_size;
|
||||
|
||||
let out = "";
|
||||
const separator = " — ";
|
||||
|
||||
const part_2_size = target_length / 2 - 1;
|
||||
const sep_size = separator.length;
|
||||
const part_1_size = target_length / 2 - sep_size;
|
||||
|
||||
if (part_1 !== "") {
|
||||
out +=
|
||||
part_1_size > part_1.length
|
||||
? part_1
|
||||
: part_1.substring(0, part_1_size);
|
||||
|
||||
out += separator;
|
||||
}
|
||||
|
||||
if (part_2 !== "") {
|
||||
out +=
|
||||
part_2_size > part_2.length
|
||||
? part_2
|
||||
: part_2.substring(0, part_2_size);
|
||||
}
|
||||
|
||||
out += ` ${link}`;
|
||||
return out;
|
||||
},
|
||||
);
|
||||
|
||||
self.define("intent_twitter", async (_, text_promise) => {
|
||||
window.open(
|
||||
`https://twitter.com/intent/tweet?text=${encodeURIComponent(await text_promise)}`,
|
||||
);
|
||||
|
||||
trigger("atto::toast", ["success", "Opened intent!"]);
|
||||
});
|
||||
|
||||
self.define("intent_bluesky", async (_, text_promise) => {
|
||||
window.open(
|
||||
`https://bsky.app/intent/compose?text=${encodeURIComponent(await text_promise)}`,
|
||||
);
|
||||
|
||||
trigger("atto::toast", ["success", "Opened intent!"]);
|
||||
});
|
||||
|
||||
// token switcher
|
||||
self.define("append_associations", (_, tokens) => {
|
||||
fetch("/api/v1/auth/user/me/append_associations", {
|
||||
|
@ -764,6 +878,25 @@
|
|||
return [access_token, refresh_token, expires_in];
|
||||
});
|
||||
|
||||
self.define("refresh", async (_, refresh_token) => {
|
||||
const [new_token, new_refresh_token, expires_in] = await trigger(
|
||||
"spotify::refresh_token",
|
||||
[client_id, refresh_token],
|
||||
);
|
||||
|
||||
await trigger("connections::push_con_data", [
|
||||
"Spotify",
|
||||
{
|
||||
token: new_token,
|
||||
refresh_token: new_refresh_token,
|
||||
expires_in: expires_in.toString(),
|
||||
name: profile.display_name,
|
||||
},
|
||||
]);
|
||||
|
||||
return [new_token, refresh_token];
|
||||
});
|
||||
|
||||
self.define("profile", async (_, token) => {
|
||||
return await (
|
||||
await fetch("https://api.spotify.com/v1/me", {
|
||||
|
@ -846,12 +979,18 @@
|
|||
|
||||
self.define(
|
||||
"timestamp",
|
||||
({ $ }, updated_, progress_ms_, duration_ms_, display = "full") => {
|
||||
async (
|
||||
{ $ },
|
||||
updated_,
|
||||
progress_ms_,
|
||||
duration_ms_,
|
||||
display = "full",
|
||||
) => {
|
||||
if (duration_ms_ === "0") {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().getTime();
|
||||
const now = Date.now();
|
||||
const updated = Number.parseInt(updated_) + 8000;
|
||||
|
||||
let elapsed_since_update = now - updated;
|
||||
|
@ -870,7 +1009,7 @@
|
|||
}
|
||||
|
||||
if (display === "full") {
|
||||
return `${$.ms_time_text(progress_ms)}/${$.ms_time_text(duration_ms)} <span class="fade">(${Math.floor((progress_ms / duration_ms) * 100)}%)</span>`;
|
||||
return `${await $.ms_time_text(progress_ms)}/${await $.ms_time_text(duration_ms)} <span class="fade">(${Math.floor((progress_ms / duration_ms) * 100)}%)</span>`;
|
||||
}
|
||||
|
||||
if (display === "left") {
|
||||
|
@ -1001,7 +1140,7 @@
|
|||
artist: playing.artist.name,
|
||||
album: playing.album["#text"],
|
||||
// times
|
||||
timestamp: new Date().getTime().toString(),
|
||||
timestamp: Date.now().toString(),
|
||||
duration_ms: (mb_info.length || 0).toString(),
|
||||
},
|
||||
},
|
||||
|
|
|
@ -42,7 +42,7 @@
|
|||
},
|
||||
};
|
||||
|
||||
socket.addEventListener("message", (event) => {
|
||||
socket.addEventListener("message", async (event) => {
|
||||
if (event.data === "Ping") {
|
||||
return socket.send("Pong");
|
||||
}
|
||||
|
@ -54,14 +54,14 @@
|
|||
return console.info(`${stream} ${data.data}`);
|
||||
}
|
||||
|
||||
return $.sock(stream).events.message(data);
|
||||
return (await $.sock(stream)).events.message(data);
|
||||
});
|
||||
|
||||
return $.STREAMS[stream];
|
||||
});
|
||||
|
||||
self.define("close", ({ $ }, stream) => {
|
||||
const socket = $.sock(stream);
|
||||
self.define("close", async ({ $ }, stream) => {
|
||||
const socket = await $.sock(stream);
|
||||
|
||||
if (!socket) {
|
||||
console.warn("no such stream to close");
|
||||
|
@ -72,8 +72,8 @@
|
|||
socket.socket.close();
|
||||
});
|
||||
|
||||
self.define("event", ({ $ }, stream, event, handler) => {
|
||||
const socket = $.sock(stream);
|
||||
self.define("event", async ({ $ }, stream, event, handler) => {
|
||||
const socket = await $.sock(stream);
|
||||
|
||||
if (!socket) {
|
||||
console.warn("no such stream to add event to");
|
||||
|
@ -84,7 +84,7 @@
|
|||
});
|
||||
|
||||
self.define("send_packet", async ({ $ }, stream, method, data) => {
|
||||
await (
|
||||
return await (
|
||||
await fetch(`/api/v1/auth/user/${$.USER}/_connect/${stream}/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
@ -97,4 +97,19 @@
|
|||
})
|
||||
).json();
|
||||
});
|
||||
|
||||
self.define("send_packet_to", async (_, user, stream, method, data) => {
|
||||
return await (
|
||||
await fetch(`/api/v1/auth/user/${user}/_connect/${stream}/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
method,
|
||||
data: JSON.stringify(data),
|
||||
}),
|
||||
})
|
||||
).json();
|
||||
});
|
||||
})();
|
||||
|
|
|
@ -81,7 +81,7 @@ pub async fn stripe_webhook(
|
|||
|
||||
loop {
|
||||
if retries >= 5 {
|
||||
// we've already tried 5 times (10 seconds of waiting)... it's not
|
||||
// we've already tried 5 times (25 seconds of waiting)... it's not
|
||||
// going to happen
|
||||
//
|
||||
// we're going to report this error to the audit log so someone can
|
||||
|
@ -111,7 +111,7 @@ pub async fn stripe_webhook(
|
|||
Err(_) => {
|
||||
tracing::info!("checkout session not stored in db yet");
|
||||
retries += 1;
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -82,14 +82,16 @@ pub async fn avatar_request(
|
|||
}
|
||||
};
|
||||
|
||||
let mime = if user.settings.avatar_mime.is_empty() {
|
||||
"image/avif"
|
||||
} else {
|
||||
&user.settings.avatar_mime
|
||||
};
|
||||
|
||||
let path = PathBufD::current().extend(&[
|
||||
data.0.0.dirs.media.as_str(),
|
||||
"avatars",
|
||||
&format!(
|
||||
"{}.{}",
|
||||
&(user.id as i64),
|
||||
user.settings.avatar_mime.replace("image/", "")
|
||||
),
|
||||
&format!("{}.{}", &(user.id as i64), mime.replace("image/", "")),
|
||||
]);
|
||||
|
||||
if !exists(&path).unwrap() {
|
||||
|
@ -104,10 +106,7 @@ pub async fn avatar_request(
|
|||
}
|
||||
|
||||
Ok((
|
||||
[(
|
||||
"Content-Type".to_string(),
|
||||
user.settings.avatar_mime.clone(),
|
||||
)],
|
||||
[("Content-Type".to_string(), mime.to_owned())],
|
||||
Body::from(read_image(path)),
|
||||
))
|
||||
}
|
||||
|
@ -134,14 +133,16 @@ pub async fn banner_request(
|
|||
}
|
||||
};
|
||||
|
||||
let mime = if user.settings.banner_mime.is_empty() {
|
||||
"image/avif"
|
||||
} else {
|
||||
&user.settings.banner_mime
|
||||
};
|
||||
|
||||
let path = PathBufD::current().extend(&[
|
||||
data.0.0.dirs.media.as_str(),
|
||||
"banners",
|
||||
&format!(
|
||||
"{}.{}",
|
||||
&(user.id as i64),
|
||||
user.settings.banner_mime.replace("image/", "")
|
||||
),
|
||||
&format!("{}.{}", &(user.id as i64), mime.replace("image/", "")),
|
||||
]);
|
||||
|
||||
if !exists(&path).unwrap() {
|
||||
|
@ -156,10 +157,7 @@ pub async fn banner_request(
|
|||
}
|
||||
|
||||
Ok((
|
||||
[(
|
||||
"Content-Type".to_string(),
|
||||
user.settings.banner_mime.clone(),
|
||||
)],
|
||||
[("Content-Type".to_string(), mime.to_owned())],
|
||||
Body::from(read_image(path)),
|
||||
))
|
||||
}
|
||||
|
@ -211,8 +209,17 @@ pub async fn upload_avatar_request(
|
|||
mime.replace("image/", "")
|
||||
);
|
||||
|
||||
// upload image (gif)
|
||||
if mime == "image/gif" {
|
||||
// gif image, don't encode
|
||||
if img.0.len() > MAXIMUM_GIF_FILE_SIZE {
|
||||
return Json(Error::FileTooLarge.into());
|
||||
}
|
||||
|
||||
std::fs::write(&path, img.0).unwrap();
|
||||
|
||||
// update user settings
|
||||
auth_user.settings.avatar_mime = mime.to_string();
|
||||
auth_user.settings.avatar_mime = "image/gif".to_string();
|
||||
if let Err(e) = data
|
||||
.update_user_settings(auth_user.id, auth_user.settings)
|
||||
.await
|
||||
|
@ -220,14 +227,7 @@ pub async fn upload_avatar_request(
|
|||
return Json(e.into());
|
||||
}
|
||||
|
||||
// upload image (gif)
|
||||
if mime == "image/gif" {
|
||||
// gif image, don't encode
|
||||
if img.0.len() > MAXIMUM_GIF_FILE_SIZE {
|
||||
return Json(Error::DataTooLong("gif".to_string()).into());
|
||||
}
|
||||
|
||||
std::fs::write(&path, img.0).unwrap();
|
||||
// ...
|
||||
return Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Avatar uploaded. It might take a bit to update".to_string(),
|
||||
|
@ -237,7 +237,16 @@ pub async fn upload_avatar_request(
|
|||
|
||||
// check file size
|
||||
if img.0.len() > MAXIMUM_FILE_SIZE {
|
||||
return Json(Error::DataTooLong("image".to_string()).into());
|
||||
return Json(Error::FileTooLarge.into());
|
||||
}
|
||||
|
||||
// update user settings
|
||||
auth_user.settings.avatar_mime = "image/avif".to_string();
|
||||
if let Err(e) = data
|
||||
.update_user_settings(auth_user.id, auth_user.settings)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
// upload image
|
||||
|
@ -247,15 +256,7 @@ pub async fn upload_avatar_request(
|
|||
bytes.push(byte);
|
||||
}
|
||||
|
||||
match save_buffer(
|
||||
&path,
|
||||
bytes,
|
||||
if mime == "image/gif" {
|
||||
image::ImageFormat::Gif
|
||||
} else {
|
||||
image::ImageFormat::Avif
|
||||
},
|
||||
) {
|
||||
match save_buffer(&path, bytes, image::ImageFormat::Avif) {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Avatar uploaded. It might take a bit to update".to_string(),
|
||||
|
@ -309,8 +310,17 @@ pub async fn upload_banner_request(
|
|||
mime.replace("image/", "")
|
||||
);
|
||||
|
||||
// upload image (gif)
|
||||
if mime == "image/gif" {
|
||||
// gif image, don't encode
|
||||
if img.0.len() > MAXIMUM_GIF_FILE_SIZE {
|
||||
return Json(Error::FileTooLarge.into());
|
||||
}
|
||||
|
||||
std::fs::write(&path, img.0).unwrap();
|
||||
|
||||
// update user settings
|
||||
auth_user.settings.banner_mime = mime.to_string();
|
||||
auth_user.settings.banner_mime = "image/gif".to_string();
|
||||
if let Err(e) = data
|
||||
.update_user_settings(auth_user.id, auth_user.settings)
|
||||
.await
|
||||
|
@ -318,14 +328,7 @@ pub async fn upload_banner_request(
|
|||
return Json(e.into());
|
||||
}
|
||||
|
||||
// upload image (gif)
|
||||
if mime == "image/gif" {
|
||||
// gif image, don't encode
|
||||
if img.0.len() > MAXIMUM_GIF_FILE_SIZE {
|
||||
return Json(Error::DataTooLong("gif".to_string()).into());
|
||||
}
|
||||
|
||||
std::fs::write(&path, img.0).unwrap();
|
||||
// ...
|
||||
return Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Banner uploaded. It might take a bit to update".to_string(),
|
||||
|
@ -335,7 +338,16 @@ pub async fn upload_banner_request(
|
|||
|
||||
// check file size
|
||||
if img.0.len() > MAXIMUM_FILE_SIZE {
|
||||
return Json(Error::DataTooLong("image".to_string()).into());
|
||||
return Json(Error::FileTooLarge.into());
|
||||
}
|
||||
|
||||
// update user settings
|
||||
auth_user.settings.avatar_mime = "image/avif".to_string();
|
||||
if let Err(e) = data
|
||||
.update_user_settings(auth_user.id, auth_user.settings)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
// upload image
|
||||
|
@ -345,15 +357,7 @@ pub async fn upload_banner_request(
|
|||
bytes.push(byte);
|
||||
}
|
||||
|
||||
match save_buffer(
|
||||
&path,
|
||||
bytes,
|
||||
if mime == "image/gif" {
|
||||
image::ImageFormat::Gif
|
||||
} else {
|
||||
image::ImageFormat::Avif
|
||||
},
|
||||
) {
|
||||
match save_buffer(&path, bytes, image::ImageFormat::Avif) {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Banner uploaded. It might take a bit to update".to_string(),
|
||||
|
|
|
@ -86,12 +86,66 @@ pub async fn register_request(
|
|||
let mut user = User::new(props.username.to_lowercase(), props.password);
|
||||
user.settings.policy_consent = true;
|
||||
|
||||
// check invite code
|
||||
if data.0.0.security.enable_invite_codes {
|
||||
if props.invite_code.is_empty() {
|
||||
return (
|
||||
None,
|
||||
Json(Error::MiscError("Missing invite code".to_string()).into()),
|
||||
);
|
||||
}
|
||||
|
||||
let invite_code = match data.get_invite_code_by_code(&props.invite_code).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return (None, Json(e.into())),
|
||||
};
|
||||
|
||||
if invite_code.is_used {
|
||||
return (
|
||||
None,
|
||||
Json(Error::MiscError("This code has already been used".to_string()).into()),
|
||||
);
|
||||
}
|
||||
|
||||
// let owner = match data.get_user_by_id(invite_code.owner).await {
|
||||
// Ok(u) => u,
|
||||
// Err(e) => return (None, Json(e.into())),
|
||||
// };
|
||||
|
||||
// if !owner.permissions.check(FinePermission::SUPPORTER) {
|
||||
// return (
|
||||
// None,
|
||||
// Json(
|
||||
// Error::MiscError("Invite code owner must be an active supporter".to_string())
|
||||
// .into(),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
|
||||
user.invite_code = invite_code.id;
|
||||
}
|
||||
|
||||
// push initial token
|
||||
let (initial_token, t) = User::create_token(&real_ip);
|
||||
user.tokens.push(t);
|
||||
|
||||
// return
|
||||
match data.create_user(user).await {
|
||||
Ok(_) => (
|
||||
Ok(_) => {
|
||||
// mark invite as used
|
||||
if data.0.0.security.enable_invite_codes {
|
||||
let invite_code = match data.get_invite_code_by_code(&props.invite_code).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return (None, Json(e.into())),
|
||||
};
|
||||
|
||||
if let Err(e) = data.update_invite_code_is_used(invite_code.id, true).await {
|
||||
return (None, Json(e.into()));
|
||||
}
|
||||
}
|
||||
|
||||
// ...
|
||||
(
|
||||
Some([(
|
||||
"Set-Cookie",
|
||||
format!(
|
||||
|
@ -105,7 +159,8 @@ pub async fn register_request(
|
|||
message: initial_token,
|
||||
payload: (),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
Err(e) => (None, Json(e.into())),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,8 +3,8 @@ use crate::{
|
|||
get_user_from_token,
|
||||
model::{ApiReturn, Error},
|
||||
routes::api::v1::{
|
||||
AppendAssociations, DeleteUser, DisableTotp, RefreshGrantToken, UpdateUserIsVerified,
|
||||
UpdateUserPassword, UpdateUserRole, UpdateUserUsername,
|
||||
AppendAssociations, DeleteUser, DisableTotp, RefreshGrantToken, UpdateSecondaryUserRole,
|
||||
UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole, UpdateUserUsername,
|
||||
},
|
||||
State,
|
||||
};
|
||||
|
@ -21,7 +21,8 @@ use futures_util::{sink::SinkExt, stream::StreamExt};
|
|||
use tetratto_core::{
|
||||
cache::Cache,
|
||||
model::{
|
||||
auth::{Token, UserSettings},
|
||||
auth::{AchievementName, InviteCode, Token, UserSettings},
|
||||
moderation::AuditLogEntry,
|
||||
oauth,
|
||||
permissions::FinePermission,
|
||||
socket::{PacketType, SocketMessage, SocketMethod},
|
||||
|
@ -30,7 +31,7 @@ use tetratto_core::{
|
|||
};
|
||||
use tetratto_core::cache::redis::Commands;
|
||||
use tetratto_shared::{
|
||||
hash::{self, random_id},
|
||||
hash::{hash, salt, random_id},
|
||||
unix_epoch_timestamp,
|
||||
};
|
||||
|
||||
|
@ -115,7 +116,7 @@ pub async fn update_user_settings_request(
|
|||
Json(mut req): Json<UserSettings>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProfile) {
|
||||
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProfile) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
@ -150,6 +151,14 @@ pub async fn update_user_settings_request(
|
|||
req.theme_lit = format!("{}%", req.theme_lit)
|
||||
}
|
||||
|
||||
// award achievement
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::EditSettings.into())
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
// ...
|
||||
match data.update_user_settings(id, req).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
|
@ -185,7 +194,7 @@ pub async fn append_associations_request(
|
|||
|
||||
// resolve tokens
|
||||
for token in req.tokens {
|
||||
let hashed = hash::hash(token);
|
||||
let hashed = hash(token);
|
||||
let user_from_token = match data.get_user_by_token(&hashed).await {
|
||||
Ok(ua) => ua,
|
||||
Err(_) => continue,
|
||||
|
@ -358,6 +367,34 @@ pub async fn update_user_role_request(
|
|||
}
|
||||
}
|
||||
|
||||
/// Update the role of the given user.
|
||||
///
|
||||
/// Does not support third-party grants.
|
||||
pub async fn update_user_secondary_role_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
Json(req): Json<UpdateSecondaryUserRole>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data
|
||||
.update_user_secondary_role(id, req.role, user, false)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "User updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the current user's last seen value.
|
||||
pub async fn seen_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
|
@ -393,6 +430,16 @@ pub async fn delete_user_request(
|
|||
|
||||
if user.id != id && !user.permissions.check(FinePermission::MANAGE_USERS) {
|
||||
return Json(Error::NotAllowed.into());
|
||||
} else if user.permissions.check(FinePermission::MANAGE_USERS) {
|
||||
if let Err(e) = data
|
||||
.create_audit_log_entry(AuditLogEntry::new(
|
||||
user.id,
|
||||
format!("invoked `delete_user` with x value `{id}`"),
|
||||
))
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
match data
|
||||
|
@ -556,7 +603,7 @@ pub async fn subscription_handler(
|
|||
|
||||
pub async fn handle_socket(socket: WebSocket, db: DataManager, user_id: String, stream_id: String) {
|
||||
let (mut sink, mut stream) = socket.split();
|
||||
let socket_id = tetratto_shared::hash::salt();
|
||||
let socket_id = salt();
|
||||
db.0.1
|
||||
.incr("atto.active_connections:users".to_string())
|
||||
.await;
|
||||
|
@ -669,7 +716,7 @@ pub async fn post_to_socket_request(
|
|||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
if user.id.to_string() != user_id {
|
||||
if user.id.to_string() != user_id && !user.permissions.check(FinePermission::MANAGE_USERS) {
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
|
@ -817,3 +864,50 @@ pub async fn refresh_grant_request(
|
|||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate an invite code.
|
||||
///
|
||||
/// Does not support third-party grants.
|
||||
pub async fn generate_invite_codes_request(
|
||||
jar: CookieJar,
|
||||
Path(count): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
if !data.0.0.security.enable_invite_codes {
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
if count > 48 {
|
||||
return Json(Error::DataTooLong("count".to_string()).into());
|
||||
}
|
||||
|
||||
let mut out_string = String::new();
|
||||
let mut errors_string = String::new();
|
||||
|
||||
for _ in 0..count {
|
||||
// ids will quickly collide, so we need to wait a bit so timestamps are different
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
match data
|
||||
.create_invite_code(InviteCode::new(user.id), &user)
|
||||
.await
|
||||
{
|
||||
Ok(x) => out_string += &(x.code + "\n"),
|
||||
Err(e) => {
|
||||
errors_string = e.to_string();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Success".to_string(),
|
||||
payload: Some((out_string, errors_string)),
|
||||
})
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ use axum::{
|
|||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use tetratto_core::model::{
|
||||
auth::{FollowResult, IpBlock, Notification, UserBlock, UserFollow},
|
||||
auth::{AchievementName, FollowResult, IpBlock, Notification, UserBlock, UserFollow},
|
||||
oauth,
|
||||
};
|
||||
|
||||
|
@ -22,7 +22,7 @@ pub async fn follow_request(
|
|||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageFollowing) {
|
||||
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageFollowing) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
@ -40,7 +40,7 @@ pub async fn follow_request(
|
|||
} else {
|
||||
// create
|
||||
match data
|
||||
.create_userfollow(UserFollow::new(user.id, id), false)
|
||||
.create_userfollow(UserFollow::new(user.id, id), &user, false)
|
||||
.await
|
||||
{
|
||||
Ok(r) => {
|
||||
|
@ -59,6 +59,15 @@ pub async fn follow_request(
|
|||
return Json(e.into());
|
||||
};
|
||||
|
||||
// award achievement
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::FollowUser.into())
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
// ...
|
||||
Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "User followed".to_string(),
|
||||
|
@ -116,7 +125,7 @@ pub async fn accept_follow_request(
|
|||
|
||||
// create follow
|
||||
match data
|
||||
.create_userfollow(UserFollow::new(id, user.id), true)
|
||||
.create_userfollow(UserFollow::new(id, user.id), &user, true)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
|
|
103
crates/app/src/routes/api/v1/channels/message_reactions.rs
Normal file
103
crates/app/src/routes/api/v1/channels/message_reactions.rs
Normal file
|
@ -0,0 +1,103 @@
|
|||
use crate::{get_user_from_token, routes::api::v1::CreateMessageReaction, State};
|
||||
use axum::{Extension, Json, extract::Path, response::IntoResponse};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use tetratto_core::model::{channels::MessageReaction, oauth, ApiReturn, Error};
|
||||
|
||||
pub async fn get_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReact) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data
|
||||
.get_message_reactions_by_owner_message(user.id, id)
|
||||
.await
|
||||
{
|
||||
Ok(r) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Reactions exists".to_string(),
|
||||
payload: Some(r),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Json(req): Json<CreateMessageReaction>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReact) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let message_id = match req.message.parse::<usize>() {
|
||||
Ok(n) => n,
|
||||
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
|
||||
};
|
||||
|
||||
// check for existing reaction
|
||||
if let Ok(r) = data
|
||||
.get_message_reaction_by_owner_message_emoji(user.id, message_id, &req.emoji)
|
||||
.await
|
||||
{
|
||||
if let Err(e) = data.delete_message_reaction(r.id, &user).await {
|
||||
return Json(e.into());
|
||||
} else {
|
||||
return Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Reaction removed".to_string(),
|
||||
payload: (),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// create reaction
|
||||
match data
|
||||
.create_message_reaction(MessageReaction::new(user.id, message_id, req.emoji), &user)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Reaction created".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path((id, emoji)): Path<(usize, String)>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReact) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let reaction = match data
|
||||
.get_message_reaction_by_owner_message_emoji(user.id, id, &emoji)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
match data.delete_message_reaction(reaction.id, &user).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Reaction deleted".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
|
@ -1,2 +1,3 @@
|
|||
pub mod channels;
|
||||
pub mod message_reactions;
|
||||
pub mod messages;
|
||||
|
|
|
@ -292,11 +292,10 @@ pub async fn create_membership(
|
|||
};
|
||||
|
||||
match data
|
||||
.create_membership(CommunityMembership::new(
|
||||
user.id,
|
||||
id,
|
||||
CommunityPermission::default(),
|
||||
))
|
||||
.create_membership(
|
||||
CommunityMembership::new(user.id, id, CommunityPermission::default()),
|
||||
&user,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(m) => Json(ApiReturn {
|
||||
|
|
|
@ -4,7 +4,7 @@ use axum::{
|
|||
Extension, Json,
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use tetratto_core::model::{communities::PostDraft, oauth, ApiReturn, Error};
|
||||
use tetratto_core::model::{auth::AchievementName, communities::PostDraft, oauth, ApiReturn, Error};
|
||||
use crate::{
|
||||
get_user_from_token,
|
||||
routes::{
|
||||
|
@ -20,11 +20,20 @@ pub async fn create_request(
|
|||
Json(req): Json<CreatePostDraft>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateDrafts) {
|
||||
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateDrafts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
// award achievement
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::CreateDraft.into())
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
// ...
|
||||
match data
|
||||
.create_draft(PostDraft::new(req.content, user.id))
|
||||
.await
|
||||
|
|
|
@ -16,12 +16,16 @@ use tetratto_core::model::{
|
|||
|
||||
/// Expand a unicode emoji into its Gemoji shortcode.
|
||||
pub async fn get_emoji_shortcode(emoji: String) -> impl IntoResponse {
|
||||
match emojis::get(&emoji) {
|
||||
match emoji.as_str() {
|
||||
"👍" => "thumbs_up".to_string(),
|
||||
"👎" => "thumbs_down".to_string(),
|
||||
_ => match emojis::get(&emoji) {
|
||||
Some(e) => match e.shortcode() {
|
||||
Some(s) => s.to_string(),
|
||||
None => e.name().replace(" ", "-"),
|
||||
},
|
||||
None => String::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -136,7 +136,7 @@ pub async fn upload_avatar_request(
|
|||
|
||||
// check file size
|
||||
if img.0.len() > MAXIMUM_FILE_SIZE {
|
||||
return Json(Error::DataTooLong("image".to_string()).into());
|
||||
return Json(Error::FileTooLarge.into());
|
||||
}
|
||||
|
||||
// upload image
|
||||
|
@ -191,7 +191,7 @@ pub async fn upload_banner_request(
|
|||
|
||||
// check file size
|
||||
if img.0.len() > MAXIMUM_FILE_SIZE {
|
||||
return Json(Error::DataTooLong("image".to_string()).into());
|
||||
return Json(Error::FileTooLarge.into());
|
||||
}
|
||||
|
||||
// upload image
|
||||
|
|
|
@ -7,6 +7,7 @@ use axum::{
|
|||
use axum_extra::extract::CookieJar;
|
||||
use tetratto_core::model::{
|
||||
addr::RemoteAddr,
|
||||
auth::AchievementName,
|
||||
communities::{Poll, PollVote, Post},
|
||||
oauth,
|
||||
permissions::FinePermission,
|
||||
|
@ -36,7 +37,7 @@ pub async fn create_request(
|
|||
JsonMultipart(images, req): JsonMultipart<CreatePost>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreatePosts) {
|
||||
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreatePosts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
@ -133,7 +134,7 @@ pub async fn create_request(
|
|||
// check sizes
|
||||
for img in &images {
|
||||
if img.len() > MAXIMUM_FILE_SIZE {
|
||||
return Json(Error::DataTooLong("image".to_string()).into());
|
||||
return Json(Error::FileTooLarge.into());
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -178,6 +179,41 @@ pub async fn create_request(
|
|||
}
|
||||
}
|
||||
|
||||
// achievements
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::CreatePost.into())
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
if user.post_count >= 49 {
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::Create50Posts.into())
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
if user.post_count >= 99 {
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::Create100Posts.into())
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
if user.post_count >= 999 {
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::Create1000Posts.into())
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
// return
|
||||
Json(ApiReturn {
|
||||
ok: true,
|
||||
|
@ -305,11 +341,20 @@ pub async fn update_content_request(
|
|||
Json(req): Json<UpdatePostContent>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserEditPosts) {
|
||||
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserEditPosts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
// award achievement
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::EditPost.into())
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
// ...
|
||||
match data.update_post_content(id, user, req.content).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
|
@ -441,10 +486,7 @@ pub async fn posts_request(
|
|||
};
|
||||
|
||||
check_user_blocked_or_private!(Some(&user), other_user, data, @api);
|
||||
match data
|
||||
.get_posts_by_user(id, 12, props.page, &Some(user.clone()))
|
||||
.await
|
||||
{
|
||||
match data.get_posts_by_user(id, 12, props.page).await {
|
||||
Ok(posts) => {
|
||||
let ignore_users = crate::ignore_users_gen!(user!, #data);
|
||||
Json(ApiReturn {
|
||||
|
@ -478,7 +520,10 @@ pub async fn community_posts_request(
|
|||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.get_posts_by_community(id, 12, props.page).await {
|
||||
match data
|
||||
.get_posts_by_community(id, 12, props.page, &Some(user.clone()))
|
||||
.await
|
||||
{
|
||||
Ok(posts) => {
|
||||
let ignore_users = crate::ignore_users_gen!(user!, #data);
|
||||
Json(ApiReturn {
|
||||
|
@ -792,7 +837,10 @@ pub async fn all_request(
|
|||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.get_latest_posts(12, props.page).await {
|
||||
match data
|
||||
.get_latest_posts(12, props.page, &Some(user.clone()))
|
||||
.await
|
||||
{
|
||||
Ok(posts) => {
|
||||
let ignore_users = crate::ignore_users_gen!(user!, #data);
|
||||
Json(ApiReturn {
|
||||
|
|
|
@ -7,7 +7,7 @@ use axum::{
|
|||
use axum_extra::extract::CookieJar;
|
||||
use tetratto_core::model::{
|
||||
addr::RemoteAddr,
|
||||
auth::IpBlock,
|
||||
auth::{AchievementName, IpBlock},
|
||||
communities::{CommunityReadAccess, Question},
|
||||
oauth,
|
||||
permissions::FinePermission,
|
||||
|
@ -15,6 +15,7 @@ use tetratto_core::model::{
|
|||
};
|
||||
use crate::{
|
||||
get_user_from_token,
|
||||
image::JsonMultipart,
|
||||
routes::{api::v1::CreateQuestion, pages::PaginatedQuery},
|
||||
State,
|
||||
};
|
||||
|
@ -23,7 +24,7 @@ pub async fn create_request(
|
|||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Extension(data): Extension<State>,
|
||||
Json(req): Json<CreateQuestion>,
|
||||
JsonMultipart(drawings, req): JsonMultipart<CreateQuestion>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = get_user_from_token!(jar, data, oauth::AppScope::UserCreateQuestions);
|
||||
|
@ -49,6 +50,27 @@ pub async fn create_request(
|
|||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
// award achievement
|
||||
if let Some(ref user) = user {
|
||||
let mut user = user.clone();
|
||||
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::CreateQuestion.into())
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
if drawings.len() > 0 {
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::CreateDrawing.into())
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ...
|
||||
let mut props = Question::new(
|
||||
if let Some(ref ua) = user { ua.id } else { 0 },
|
||||
|
@ -70,7 +92,10 @@ pub async fn create_request(
|
|||
}
|
||||
}
|
||||
|
||||
match data.create_question(props).await {
|
||||
match data
|
||||
.create_question(props, drawings.iter().map(|x| x.to_vec()).collect())
|
||||
.await
|
||||
{
|
||||
Ok(id) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Question created".to_string(),
|
||||
|
|
298
crates/app/src/routes/api/v1/journals.rs
Normal file
298
crates/app/src/routes/api/v1/journals.rs
Normal file
|
@ -0,0 +1,298 @@
|
|||
use axum::{
|
||||
response::IntoResponse,
|
||||
extract::{Json, Path},
|
||||
Extension,
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use tetratto_shared::snow::Snowflake;
|
||||
use crate::{
|
||||
get_user_from_token,
|
||||
routes::api::v1::{
|
||||
AddJournalDir, CreateJournal, RemoveJournalDir, UpdateJournalPrivacy, UpdateJournalTitle,
|
||||
},
|
||||
State,
|
||||
};
|
||||
use tetratto_core::{
|
||||
database::NAME_REGEX,
|
||||
model::{
|
||||
auth::AchievementName,
|
||||
journals::{Journal, JournalPrivacyPermission},
|
||||
oauth,
|
||||
permissions::FinePermission,
|
||||
ApiReturn, Error,
|
||||
},
|
||||
};
|
||||
|
||||
pub async fn get_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadJournals) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let journal = match data.get_journal_by_id(id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
if journal.privacy == JournalPrivacyPermission::Private
|
||||
&& user.id != journal.owner
|
||||
&& !user.permissions.contains(FinePermission::MANAGE_JOURNALS)
|
||||
{
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Success".to_string(),
|
||||
payload: Some(journal),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_css_request(
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
|
||||
let note = match data.get_note_by_journal_title(id, "journal.css").await {
|
||||
Ok(x) => x,
|
||||
Err(e) => {
|
||||
return (
|
||||
[("Content-Type", "text/css"), ("Cache-Control", "no-cache")],
|
||||
format!("/* {e} */"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
(
|
||||
[("Content-Type", "text/css"), ("Cache-Control", "no-cache")],
|
||||
note.content,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn list_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadJournals) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.get_journals_by_user(user.id).await {
|
||||
Ok(x) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Success".to_string(),
|
||||
payload: Some(x),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Json(props): Json<CreateJournal>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateJournals) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data
|
||||
.create_journal(Journal::new(user.id, props.title))
|
||||
.await
|
||||
{
|
||||
Ok(x) => {
|
||||
// award achievement
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::CreateJournal.into())
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
// ...
|
||||
Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Journal created".to_string(),
|
||||
payload: Some(x.id.to_string()),
|
||||
})
|
||||
}
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_title_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
Json(mut props): Json<UpdateJournalTitle>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageJournals) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
props.title = props.title.replace(" ", "_").to_lowercase();
|
||||
|
||||
// check name
|
||||
let regex = regex::RegexBuilder::new(NAME_REGEX)
|
||||
.multi_line(true)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
if regex.captures(&props.title).is_some() {
|
||||
return Json(Error::MiscError("This title contains invalid characters".to_string()).into());
|
||||
}
|
||||
|
||||
// make sure this title isn't already in use
|
||||
if data
|
||||
.get_journal_by_owner_title(user.id, &props.title)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return Json(Error::TitleInUse.into());
|
||||
}
|
||||
|
||||
// ...
|
||||
match data.update_journal_title(id, &user, &props.title).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Journal updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_privacy_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
Json(props): Json<UpdateJournalPrivacy>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageJournals) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.update_journal_privacy(id, &user, props.privacy).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Journal updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageJournals) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.delete_journal(id, &user).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Journal deleted".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn add_dir_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
Json(props): Json<AddJournalDir>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
if props.name.len() > 32 {
|
||||
return Json(Error::DataTooLong("name".to_string()).into());
|
||||
}
|
||||
|
||||
let mut journal = match data.get_journal_by_id(id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
// add dir
|
||||
journal.dirs.push((
|
||||
Snowflake::new().to_string().parse::<usize>().unwrap(),
|
||||
match props.parent.parse() {
|
||||
Ok(p) => p,
|
||||
Err(_) => return Json(Error::Unknown.into()),
|
||||
},
|
||||
props.name,
|
||||
));
|
||||
|
||||
// ...
|
||||
match data.update_journal_dirs(id, &user, journal.dirs).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Journal updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn remove_dir_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
Json(props): Json<RemoveJournalDir>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let mut journal = match data.get_journal_by_id(id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
// add dir
|
||||
let dir_id: usize = match props.dir.parse() {
|
||||
Ok(x) => x,
|
||||
Err(_) => return Json(Error::Unknown.into()),
|
||||
};
|
||||
|
||||
journal
|
||||
.dirs
|
||||
.remove(match journal.dirs.iter().position(|x| x.0 == dir_id) {
|
||||
Some(idx) => idx,
|
||||
None => return Json(Error::GeneralNotFound("directory".to_string()).into()),
|
||||
});
|
||||
|
||||
// ...
|
||||
match data.update_journal_dirs(id, &user, journal.dirs).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Journal updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
|
@ -2,6 +2,8 @@ pub mod apps;
|
|||
pub mod auth;
|
||||
pub mod channels;
|
||||
pub mod communities;
|
||||
pub mod journals;
|
||||
pub mod notes;
|
||||
pub mod notifications;
|
||||
pub mod reactions;
|
||||
pub mod reports;
|
||||
|
@ -22,8 +24,9 @@ use tetratto_core::model::{
|
|||
PollOption, PostContext,
|
||||
},
|
||||
communities_permissions::CommunityPermission,
|
||||
journals::JournalPrivacyPermission,
|
||||
oauth::AppScope,
|
||||
permissions::FinePermission,
|
||||
permissions::{FinePermission, SecondaryPermission},
|
||||
reactions::AssetType,
|
||||
stacks::{StackMode, StackPrivacy, StackSort},
|
||||
};
|
||||
|
@ -34,10 +37,27 @@ pub fn routes() -> Router {
|
|||
.route("/util/proxy", get(util::proxy_request))
|
||||
.route("/util/lang", get(util::set_langfile_request))
|
||||
.route("/util/ip", get(util::ip_test_request))
|
||||
.route(
|
||||
"/invites/{count}",
|
||||
post(auth::profile::generate_invite_codes_request),
|
||||
)
|
||||
// reactions
|
||||
.route("/reactions", post(reactions::create_request))
|
||||
.route("/reactions/{id}", get(reactions::get_request))
|
||||
.route("/reactions/{id}", delete(reactions::delete_request))
|
||||
// message reactions
|
||||
.route(
|
||||
"/message_reactions",
|
||||
post(channels::message_reactions::create_request),
|
||||
)
|
||||
.route(
|
||||
"/message_reactions/{id}",
|
||||
get(channels::message_reactions::get_request),
|
||||
)
|
||||
.route(
|
||||
"/message_reactions/{id}/{emoji}",
|
||||
delete(channels::message_reactions::delete_request),
|
||||
)
|
||||
// communities
|
||||
.route(
|
||||
"/communities/find/{id}",
|
||||
|
@ -276,6 +296,10 @@ pub fn routes() -> Router {
|
|||
"/auth/user/{id}/role",
|
||||
post(auth::profile::update_user_role_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/user/{id}/role/2",
|
||||
post(auth::profile::update_user_secondary_role_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/user/{id}",
|
||||
delete(auth::profile::delete_user_request),
|
||||
|
@ -530,7 +554,9 @@ pub fn routes() -> Router {
|
|||
delete(communities::emojis::delete_request),
|
||||
)
|
||||
// stacks
|
||||
.route("/stacks", get(stacks::list_request))
|
||||
.route("/stacks", post(stacks::create_request))
|
||||
.route("/stacks/{id}", get(stacks::get_request))
|
||||
.route("/stacks/{id}/name", post(stacks::update_name_request))
|
||||
.route("/stacks/{id}/privacy", post(stacks::update_privacy_request))
|
||||
.route("/stacks/{id}/mode", post(stacks::update_mode_request))
|
||||
|
@ -541,6 +567,35 @@ pub fn routes() -> Router {
|
|||
.route("/stacks/{id}/block", post(stacks::block_request))
|
||||
.route("/stacks/{id}/block", delete(stacks::unblock_request))
|
||||
.route("/stacks/{id}", delete(stacks::delete_request))
|
||||
// journals
|
||||
.route("/journals", get(journals::list_request))
|
||||
.route("/journals", post(journals::create_request))
|
||||
.route("/journals/{id}", get(journals::get_request))
|
||||
.route("/journals/{id}", delete(journals::delete_request))
|
||||
.route("/journals/{id}/journal.css", get(journals::get_css_request))
|
||||
.route("/journals/{id}/title", post(journals::update_title_request))
|
||||
.route(
|
||||
"/journals/{id}/privacy",
|
||||
post(journals::update_privacy_request),
|
||||
)
|
||||
.route("/journals/{id}/dirs", post(journals::add_dir_request))
|
||||
.route("/journals/{id}/dirs", delete(journals::remove_dir_request))
|
||||
// notes
|
||||
.route("/notes", post(notes::create_request))
|
||||
.route("/notes/{id}", get(notes::get_request))
|
||||
.route("/notes/{id}", delete(notes::delete_request))
|
||||
.route("/notes/{id}/title", post(notes::update_title_request))
|
||||
.route("/notes/{id}/content", post(notes::update_content_request))
|
||||
.route("/notes/{id}/dir", post(notes::update_dir_request))
|
||||
.route("/notes/{id}/tags", post(notes::update_tags_request))
|
||||
.route("/notes/{id}/global", post(notes::publish_request))
|
||||
.route("/notes/{id}/global", delete(notes::unpublish_request))
|
||||
.route("/notes/from_journal/{id}", get(notes::list_request))
|
||||
.route("/notes/preview", post(notes::render_markdown_request))
|
||||
.route(
|
||||
"/notes/{journal}/dir/{dir}",
|
||||
delete(notes::delete_by_dir_request),
|
||||
)
|
||||
// uploads
|
||||
.route("/uploads/{id}", get(uploads::get_request))
|
||||
.route("/uploads/{id}", delete(uploads::delete_request))
|
||||
|
@ -560,6 +615,8 @@ pub struct RegisterProps {
|
|||
pub password: String,
|
||||
pub policy_consent: bool,
|
||||
pub captcha_response: String,
|
||||
#[serde(default)]
|
||||
pub invite_code: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
@ -687,6 +744,11 @@ pub struct UpdateUserRole {
|
|||
pub role: FinePermission,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateSecondaryUserRole {
|
||||
pub role: SecondaryPermission,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteUser {
|
||||
pub password: String,
|
||||
|
@ -846,3 +908,67 @@ pub struct CreateGrant {
|
|||
pub struct RefreshGrantToken {
|
||||
pub verifier: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateJournal {
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateNote {
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub journal: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateJournalTitle {
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateJournalPrivacy {
|
||||
pub privacy: JournalPrivacyPermission,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateNoteTitle {
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateNoteContent {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RenderMarkdown {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateMessageReaction {
|
||||
pub message: String,
|
||||
pub emoji: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateNoteDir {
|
||||
pub dir: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AddJournalDir {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub parent: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RemoveJournalDir {
|
||||
pub dir: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateNoteTags {
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
|
419
crates/app/src/routes/api/v1/notes.rs
Normal file
419
crates/app/src/routes/api/v1/notes.rs
Normal file
|
@ -0,0 +1,419 @@
|
|||
use axum::{
|
||||
response::IntoResponse,
|
||||
extract::{Json, Path},
|
||||
Extension,
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use tetratto_shared::unix_epoch_timestamp;
|
||||
use crate::{
|
||||
get_user_from_token,
|
||||
routes::api::v1::{
|
||||
CreateNote, RenderMarkdown, UpdateNoteContent, UpdateNoteDir, UpdateNoteTags,
|
||||
UpdateNoteTitle,
|
||||
},
|
||||
State,
|
||||
};
|
||||
use tetratto_core::{
|
||||
database::NAME_REGEX,
|
||||
model::{
|
||||
journals::{JournalPrivacyPermission, Note},
|
||||
oauth,
|
||||
permissions::FinePermission,
|
||||
uploads::CustomEmoji,
|
||||
ApiReturn, Error,
|
||||
},
|
||||
};
|
||||
|
||||
pub async fn get_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadJournals) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let note = match data.get_note_by_id(id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
let journal = match data.get_journal_by_id(note.id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
if journal.privacy == JournalPrivacyPermission::Private
|
||||
&& user.id != journal.owner
|
||||
&& !user.permissions.contains(FinePermission::MANAGE_JOURNALS)
|
||||
{
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Success".to_string(),
|
||||
payload: Some(note),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadJournals) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let journal = match data.get_journal_by_id(id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
if journal.privacy == JournalPrivacyPermission::Private
|
||||
&& user.id != journal.owner
|
||||
&& !user.permissions.contains(FinePermission::MANAGE_JOURNALS)
|
||||
{
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
match data.get_notes_by_journal(id).await {
|
||||
Ok(x) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Success".to_string(),
|
||||
payload: Some(x),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Json(props): Json<CreateNote>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateNotes) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data
|
||||
.create_note(Note::new(
|
||||
user.id,
|
||||
props.title,
|
||||
match props.journal.parse() {
|
||||
Ok(x) => x,
|
||||
Err(_) => return Json(Error::Unknown.into()),
|
||||
},
|
||||
props.content,
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(x) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Note created".to_string(),
|
||||
payload: Some(x.id.to_string()),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_title_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
Json(mut props): Json<UpdateNoteTitle>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let note = match data.get_note_by_id(id).await {
|
||||
Ok(n) => n,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
props.title = props.title.replace(" ", "_").to_lowercase();
|
||||
|
||||
// check name
|
||||
let regex = regex::RegexBuilder::new(NAME_REGEX)
|
||||
.multi_line(true)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
if regex.captures(&props.title).is_some() {
|
||||
return Json(Error::MiscError("This title contains invalid characters".to_string()).into());
|
||||
}
|
||||
|
||||
// make sure this title isn't already in use
|
||||
if data
|
||||
.get_note_by_journal_title(note.journal, &props.title)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return Json(Error::TitleInUse.into());
|
||||
}
|
||||
|
||||
// ...
|
||||
match data.update_note_title(id, &user, &props.title).await {
|
||||
Ok(_) => {
|
||||
// update note global status
|
||||
if note.is_global {
|
||||
if let Err(e) = data.update_note_is_global(id, 0).await {
|
||||
return Json(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
// ...
|
||||
Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Note updated".to_string(),
|
||||
payload: (),
|
||||
})
|
||||
}
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_content_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
Json(props): Json<UpdateNoteContent>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.update_note_content(id, &user, &props.content).await {
|
||||
Ok(_) => {
|
||||
if let Err(e) = data
|
||||
.update_note_edited(id, unix_epoch_timestamp() as i64)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Note updated".to_string(),
|
||||
payload: (),
|
||||
})
|
||||
}
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.delete_note(id, &user).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Note deleted".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_by_dir_request(
|
||||
jar: CookieJar,
|
||||
Path((journal, id)): Path<(usize, usize)>,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.delete_notes_by_journal_dir(journal, id, &user).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Notes deleted".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn render_markdown_request(Json(req): Json<RenderMarkdown>) -> impl IntoResponse {
|
||||
tetratto_shared::markdown::render_markdown(&CustomEmoji::replace(&req.content))
|
||||
.replace("\\@", "@")
|
||||
.replace("%5C@", "@")
|
||||
}
|
||||
|
||||
pub async fn update_dir_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
Json(props): Json<UpdateNoteDir>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let note = match data.get_note_by_id(id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
let journal = match data.get_journal_by_id(note.journal).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
// make sure dir exists
|
||||
let dir = match props.dir.parse::<usize>() {
|
||||
Ok(d) => d,
|
||||
Err(_) => return Json(Error::Unknown.into()),
|
||||
};
|
||||
|
||||
if dir != 0 {
|
||||
if journal.dirs.iter().find(|x| x.0 == dir).is_none() {
|
||||
return Json(Error::GeneralNotFound("directory".to_string()).into());
|
||||
}
|
||||
}
|
||||
|
||||
// ...
|
||||
match data.update_note_dir(id, &user, dir as i64).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Note updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_tags_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
Json(props): Json<UpdateNoteTags>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.update_note_tags(id, &user, props.tags).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Note updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn publish_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let note = match data.get_note_by_id(id).await {
|
||||
Ok(n) => n,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
if user.id != note.owner {
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
// check count
|
||||
if data.get_user_global_notes_count(user.id).await.unwrap_or(0)
|
||||
>= if user.permissions.check(FinePermission::SUPPORTER) {
|
||||
10
|
||||
} else {
|
||||
5
|
||||
}
|
||||
{
|
||||
return Json(
|
||||
Error::MiscError(
|
||||
"You already have the maximum number of global notes you can have".to_string(),
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
// make sure note doesn't already exist globally
|
||||
if data.get_global_note_by_title(¬e.title).await.is_ok() {
|
||||
return Json(
|
||||
Error::MiscError(
|
||||
"Note name is already in use globally. Please change the name and try again"
|
||||
.to_string(),
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
// ...
|
||||
match data.update_note_is_global(id, 1).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Note updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn unpublish_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let note = match data.get_note_by_id(id).await {
|
||||
Ok(n) => n,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
if user.id != note.owner {
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
// ...
|
||||
match data.update_note_is_global(id, 0).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Note updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
|
@ -19,6 +19,54 @@ use super::{
|
|||
UpdateStackSort,
|
||||
};
|
||||
|
||||
pub async fn get_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadStacks) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let stack = match data.get_stack_by_id(id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
if stack.privacy == StackPrivacy::Private
|
||||
&& user.id != stack.owner
|
||||
&& ((stack.mode != StackMode::Circle) | stack.users.contains(&user.id))
|
||||
&& !user.permissions.check(FinePermission::MANAGE_STACKS)
|
||||
{
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Success".to_string(),
|
||||
payload: Some(stack),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadStacks) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.get_stacks_by_user(user.id).await {
|
||||
Ok(x) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Success".to_string(),
|
||||
payload: Some(x),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
|
@ -163,6 +211,10 @@ pub async fn add_user_request(
|
|||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
if stack.users.contains(&other_user.id) {
|
||||
return Json(Error::MiscError("This user is already in this stack".to_string()).into());
|
||||
}
|
||||
|
||||
stack.users.push(other_user.id);
|
||||
|
||||
// check number of stacks
|
||||
|
|
|
@ -4,7 +4,7 @@ use axum_extra::extract::CookieJar;
|
|||
use pathbufd::PathBufD;
|
||||
use crate::{get_user_from_token, State};
|
||||
use super::auth::images::read_image;
|
||||
use tetratto_core::model::{oauth, ApiReturn, Error};
|
||||
use tetratto_core::model::{carp::CarpGraph, oauth, uploads::MediaType, ApiReturn, Error};
|
||||
|
||||
pub async fn get_request(
|
||||
Path(id): Path<usize>,
|
||||
|
@ -12,7 +12,20 @@ pub async fn get_request(
|
|||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
|
||||
let upload = data.get_upload_by_id(id).await.unwrap();
|
||||
let upload = match data.get_upload_by_id(id).await {
|
||||
Ok(u) => u,
|
||||
Err(_) => {
|
||||
return Err((
|
||||
[("Content-Type", "image/svg+xml")],
|
||||
Body::from(read_image(PathBufD::current().extend(&[
|
||||
data.0.0.dirs.media.as_str(),
|
||||
"images",
|
||||
"default-avatar.svg",
|
||||
]))),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let path = upload.path(&data.0.0);
|
||||
|
||||
if !exists(&path).unwrap() {
|
||||
|
@ -26,10 +39,17 @@ pub async fn get_request(
|
|||
));
|
||||
}
|
||||
|
||||
Ok((
|
||||
[("Content-Type", upload.what.mime())],
|
||||
Body::from(read_image(path)),
|
||||
))
|
||||
let bytes = read_image(path);
|
||||
|
||||
if upload.what == MediaType::Carpgraph {
|
||||
// conver to svg and return
|
||||
return Ok((
|
||||
[("Content-Type", "image/svg+xml".to_string())],
|
||||
Body::from(CarpGraph::from_bytes(bytes).to_svg()),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(([("Content-Type", upload.what.mime())], Body::from(bytes)))
|
||||
}
|
||||
|
||||
pub async fn delete_request(
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
use super::auth::images::read_image;
|
||||
use crate::State;
|
||||
use crate::{get_user_from_token, State};
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::Query,
|
||||
|
@ -7,10 +7,13 @@ use axum::{
|
|||
response::IntoResponse,
|
||||
Extension,
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use pathbufd::PathBufD;
|
||||
use serde::Deserialize;
|
||||
use tetratto_core::model::permissions::FinePermission;
|
||||
|
||||
pub const MAXIMUM_PROXY_FILE_SIZE: u64 = 4194304; // 4 MiB
|
||||
pub const MAXIMUM_PROXY_FILE_SIZE: u64 = 4_194_304; // 4 MiB
|
||||
pub const MAXIMUM_SUPPORTER_PROXY_FILE_SIZE: u64 = 10_485_760; // 4 MiB
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ProxyQuery {
|
||||
|
@ -19,10 +22,22 @@ pub struct ProxyQuery {
|
|||
|
||||
/// Proxy an external url
|
||||
pub async fn proxy_request(
|
||||
jar: CookieJar,
|
||||
Query(props): Query<ProxyQuery>,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await);
|
||||
let user = get_user_from_token!(jar, data.0);
|
||||
let maximum_size = if let Some(ref ua) = user {
|
||||
if ua.permissions.check(FinePermission::SUPPORTER) {
|
||||
MAXIMUM_SUPPORTER_PROXY_FILE_SIZE
|
||||
} else {
|
||||
MAXIMUM_PROXY_FILE_SIZE
|
||||
}
|
||||
} else {
|
||||
MAXIMUM_PROXY_FILE_SIZE
|
||||
};
|
||||
|
||||
let http = &data.2;
|
||||
let data = &data.0.0;
|
||||
|
||||
|
@ -60,7 +75,7 @@ pub async fn proxy_request(
|
|||
match http.get(image_url).send().await {
|
||||
Ok(stream) => {
|
||||
let size = stream.content_length();
|
||||
if size.unwrap_or_default() > MAXIMUM_PROXY_FILE_SIZE {
|
||||
if size.unwrap_or_default() > maximum_size {
|
||||
// return defualt image (content too big)
|
||||
return (
|
||||
[("Content-Type", "image/svg+xml")],
|
||||
|
|
|
@ -12,8 +12,10 @@ serve_asset!(favicon_request: FAVICON("image/svg+xml"));
|
|||
serve_asset!(style_css_request: STYLE_CSS("text/css"));
|
||||
serve_asset!(root_css_request: ROOT_CSS("text/css"));
|
||||
serve_asset!(utility_css_request: UTILITY_CSS("text/css"));
|
||||
serve_asset!(chats_css_request: CHATS_CSS("text/css"));
|
||||
|
||||
serve_asset!(loader_js_request: LOADER_JS("text/javascript"));
|
||||
serve_asset!(atto_js_request: ATTO_JS("text/javascript"));
|
||||
serve_asset!(me_js_request: ME_JS("text/javascript"));
|
||||
serve_asset!(streams_js_request: STREAMS_JS("text/javascript"));
|
||||
serve_asset!(carp_js_request: CARP_JS("text/javascript"));
|
||||
|
|
|
@ -14,14 +14,20 @@ pub fn routes(config: &Config) -> Router {
|
|||
.route("/css/style.css", get(assets::style_css_request))
|
||||
.route("/css/root.css", get(assets::root_css_request))
|
||||
.route("/css/utility.css", get(assets::utility_css_request))
|
||||
.route("/css/chats.css", get(assets::chats_css_request))
|
||||
.route("/js/loader.js", get(assets::loader_js_request))
|
||||
.route("/js/atto.js", get(assets::atto_js_request))
|
||||
.route("/js/me.js", get(assets::me_js_request))
|
||||
.route("/js/streams.js", get(assets::streams_js_request))
|
||||
.route("/js/carp.js", get(assets::carp_js_request))
|
||||
.nest_service(
|
||||
"/public",
|
||||
get_service(tower_http::services::ServeDir::new(&config.dirs.assets)),
|
||||
)
|
||||
.nest_service(
|
||||
"/icons",
|
||||
get_service(tower_http::services::ServeDir::new(&config.dirs.icons)),
|
||||
)
|
||||
.nest_service(
|
||||
"/reference",
|
||||
get_service(tower_http::services::ServeDir::new(&config.dirs.rustdoc)),
|
||||
|
|
|
@ -417,7 +417,7 @@ pub async fn feed_request(
|
|||
|
||||
let feed = match data
|
||||
.0
|
||||
.get_posts_by_community(community.id, 12, props.page)
|
||||
.get_posts_by_community(community.id, 12, props.page, &user)
|
||||
.await
|
||||
{
|
||||
Ok(p) => match data.0.fill_posts(p, &ignore_users, &user).await {
|
||||
|
@ -752,7 +752,7 @@ pub async fn post_request(
|
|||
}
|
||||
|
||||
// check repost
|
||||
let reposting = data.0.get_post_reposting(&post, &ignore_users, &user).await;
|
||||
let (_, reposting) = data.0.get_post_reposting(&post, &ignore_users, &user).await;
|
||||
|
||||
// check question
|
||||
let question = match data.0.get_post_question(&post, &ignore_users).await {
|
||||
|
|
|
@ -161,7 +161,7 @@ pub async fn tickets_request(
|
|||
|
||||
let feed = match data
|
||||
.0
|
||||
.get_posts_by_community(community.id, 12, props.page)
|
||||
.get_posts_by_community(community.id, 12, props.page, &user)
|
||||
.await
|
||||
{
|
||||
Ok(p) => match data.0.fill_posts(p, &ignore_users, &user).await {
|
||||
|
|
366
crates/app/src/routes/pages/journals.rs
Normal file
366
crates/app/src/routes/pages/journals.rs
Normal file
|
@ -0,0 +1,366 @@
|
|||
use axum::{
|
||||
extract::{Path, Query},
|
||||
response::{Html, IntoResponse, Redirect},
|
||||
Extension,
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use crate::{
|
||||
assets::initial_context,
|
||||
check_user_blocked_or_private, get_lang, get_user_from_token,
|
||||
routes::pages::{render_error, JournalsAppQuery},
|
||||
State,
|
||||
};
|
||||
use tetratto_core::model::{journals::JournalPrivacyPermission, Error};
|
||||
|
||||
pub async fn redirect_request() -> impl IntoResponse {
|
||||
Redirect::to("/journals/0/0")
|
||||
}
|
||||
|
||||
/// `/journals/{journal}/{note}`
|
||||
pub async fn app_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path((selected_journal, selected_note)): Path<(usize, usize)>,
|
||||
Query(props): Query<JournalsAppQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let data = data.read().await;
|
||||
let user = match get_user_from_token!(jar, data.0) {
|
||||
Some(ua) => ua,
|
||||
None => {
|
||||
return Err(Html(
|
||||
render_error(Error::NotAllowed, &jar, &data, &None).await,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let journals = match data.0.get_journals_by_user(user.id).await {
|
||||
Ok(p) => Some(p),
|
||||
Err(e) => {
|
||||
return Err(Html(
|
||||
render_error(e, &jar, &data, &Some(user.to_owned())).await,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let notes = match data.0.get_notes_by_journal(selected_journal).await {
|
||||
Ok(p) => Some(p),
|
||||
Err(e) => {
|
||||
return Err(Html(render_error(e, &jar, &data, &Some(user)).await));
|
||||
}
|
||||
};
|
||||
|
||||
// get journal and check privacy settings
|
||||
let journal = if selected_journal != 0 {
|
||||
match data.0.get_journal_by_id(selected_journal).await {
|
||||
Ok(p) => Some(p),
|
||||
Err(e) => {
|
||||
return Err(Html(render_error(e, &jar, &data, &Some(user)).await));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(ref j) = journal {
|
||||
// if we're not the owner, we shouldn't be viewing this journal from this endpoint
|
||||
if user.id != j.owner {
|
||||
return Err(Html(
|
||||
render_error(Error::NotAllowed, &jar, &data, &Some(user.to_owned())).await,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ...
|
||||
let note = if selected_note != 0 {
|
||||
match data.0.get_note_by_id(selected_note).await {
|
||||
Ok(p) => Some(p),
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &Some(user.clone())).await;
|
||||
|
||||
context.insert("selected_journal", &selected_journal);
|
||||
context.insert("selected_note", &selected_note);
|
||||
|
||||
context.insert("journal", &journal);
|
||||
context.insert("note", ¬e);
|
||||
|
||||
context.insert("owner", &user);
|
||||
context.insert("journals", &journals);
|
||||
context.insert("notes", ¬es);
|
||||
|
||||
context.insert("view_mode", &props.view);
|
||||
context.insert("is_editor", &true);
|
||||
|
||||
// return
|
||||
Ok(Html(data.1.render("journals/app.html", &context).unwrap()))
|
||||
}
|
||||
|
||||
/// `/@{owner}/{journal}/{note}`
|
||||
pub async fn view_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path((owner, selected_journal, mut selected_note)): Path<(String, String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
let data = data.read().await;
|
||||
let user = match get_user_from_token!(jar, data.0) {
|
||||
Some(ua) => Some(ua),
|
||||
None => None,
|
||||
};
|
||||
|
||||
if selected_note == "index" {
|
||||
selected_note = String::new();
|
||||
}
|
||||
|
||||
// if we don't have a selected journal, we shouldn't be here probably
|
||||
if selected_journal.is_empty() | (selected_note == "journal.css") {
|
||||
return Err(Html(
|
||||
render_error(Error::NotAllowed, &jar, &data, &user).await,
|
||||
));
|
||||
}
|
||||
|
||||
// get owner
|
||||
let owner = match data.0.get_user_by_username(&owner).await {
|
||||
Ok(ua) => ua,
|
||||
Err(e) => {
|
||||
return Err(Html(render_error(e, &jar, &data, &user).await));
|
||||
}
|
||||
};
|
||||
|
||||
check_user_blocked_or_private!(user, owner, data, jar);
|
||||
|
||||
// get journal and check privacy settings
|
||||
let journal = match data
|
||||
.0
|
||||
.get_journal_by_owner_title(owner.id, &selected_journal)
|
||||
.await
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
return Err(Html(render_error(e, &jar, &data, &user).await));
|
||||
}
|
||||
};
|
||||
|
||||
if journal.privacy == JournalPrivacyPermission::Private {
|
||||
if let Some(ref user) = user {
|
||||
if user.id != journal.owner {
|
||||
return Err(Html(
|
||||
render_error(Error::NotAllowed, &jar, &data, &Some(user.to_owned())).await,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
return Err(Html(
|
||||
render_error(Error::NotAllowed, &jar, &data, &user).await,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ...
|
||||
let note = if !selected_note.is_empty() {
|
||||
match data
|
||||
.0
|
||||
.get_note_by_journal_title(journal.id, &selected_note)
|
||||
.await
|
||||
{
|
||||
Ok(p) => Some(p),
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &user).await;
|
||||
|
||||
if selected_journal.is_empty() {
|
||||
context.insert("selected_journal", &0);
|
||||
} else {
|
||||
context.insert("selected_journal", &selected_journal);
|
||||
}
|
||||
|
||||
if selected_note.is_empty() {
|
||||
context.insert("selected_note", &0);
|
||||
} else {
|
||||
context.insert("selected_note", &selected_note);
|
||||
context.insert(
|
||||
"redis_views",
|
||||
&data.0.get_note_views(note.as_ref().unwrap().id).await,
|
||||
);
|
||||
}
|
||||
|
||||
context.insert("journal", &journal);
|
||||
context.insert("note", ¬e);
|
||||
|
||||
context.insert("owner", &owner);
|
||||
context.insert::<[i8; 0], &str>("notes", &[]);
|
||||
|
||||
context.insert("view_mode", &true);
|
||||
context.insert("is_editor", &false);
|
||||
|
||||
// return
|
||||
Ok(Html(data.1.render("journals/app.html", &context).unwrap()))
|
||||
}
|
||||
|
||||
/// `/@{owner}/{journal}`
|
||||
pub async fn index_view_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path((owner, selected_journal)): Path<(String, String)>,
|
||||
Query(props): Query<JournalsAppQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let data = data.read().await;
|
||||
let user = match get_user_from_token!(jar, data.0) {
|
||||
Some(ua) => Some(ua),
|
||||
None => None,
|
||||
};
|
||||
|
||||
// get owner
|
||||
let owner = match data.0.get_user_by_username(&owner).await {
|
||||
Ok(ua) => ua,
|
||||
Err(e) => {
|
||||
return Err(Html(render_error(e, &jar, &data, &user).await));
|
||||
}
|
||||
};
|
||||
|
||||
check_user_blocked_or_private!(user, owner, data, jar);
|
||||
|
||||
// get journal and check privacy settings
|
||||
let journal = match data
|
||||
.0
|
||||
.get_journal_by_owner_title(owner.id, &selected_journal)
|
||||
.await
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
return Err(Html(render_error(e, &jar, &data, &user).await));
|
||||
}
|
||||
};
|
||||
|
||||
if journal.privacy == JournalPrivacyPermission::Private {
|
||||
if let Some(ref user) = user {
|
||||
if user.id != journal.owner {
|
||||
return Err(Html(
|
||||
render_error(Error::NotAllowed, &jar, &data, &Some(user.to_owned())).await,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
return Err(Html(
|
||||
render_error(Error::NotAllowed, &jar, &data, &user).await,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ...
|
||||
let notes = if props.tag.is_empty() {
|
||||
match data.0.get_notes_by_journal(journal.id).await {
|
||||
Ok(p) => Some(p),
|
||||
Err(e) => {
|
||||
return Err(Html(render_error(e, &jar, &data, &user).await));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match data
|
||||
.0
|
||||
.get_notes_by_journal_tag(journal.id, &props.tag)
|
||||
.await
|
||||
{
|
||||
Ok(p) => Some(p),
|
||||
Err(e) => {
|
||||
return Err(Html(render_error(e, &jar, &data, &user).await));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &user).await;
|
||||
|
||||
if selected_journal.is_empty() {
|
||||
context.insert("selected_journal", &0);
|
||||
} else {
|
||||
context.insert("selected_journal", &selected_journal);
|
||||
}
|
||||
|
||||
context.insert("selected_note", &0);
|
||||
context.insert("journal", &journal);
|
||||
|
||||
context.insert("owner", &owner);
|
||||
context.insert("notes", ¬es);
|
||||
|
||||
context.insert("view_mode", &true);
|
||||
context.insert("is_editor", &false);
|
||||
context.insert("tag", &props.tag);
|
||||
|
||||
// return
|
||||
Ok(Html(data.1.render("journals/app.html", &context).unwrap()))
|
||||
}
|
||||
|
||||
/// `/x/{note}`
|
||||
pub async fn global_view_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(mut selected_note): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let data = data.read().await;
|
||||
let user = match get_user_from_token!(jar, data.0) {
|
||||
Some(ua) => Some(ua),
|
||||
None => None,
|
||||
};
|
||||
|
||||
if selected_note == "index" {
|
||||
selected_note = String::new();
|
||||
}
|
||||
|
||||
// if we don't have a selected journal, we shouldn't be here probably
|
||||
if selected_note == "journal.css" {
|
||||
return Err(Html(
|
||||
render_error(Error::NotAllowed, &jar, &data, &user).await,
|
||||
));
|
||||
}
|
||||
|
||||
// ...
|
||||
let note = match data.0.get_global_note_by_title(&selected_note).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
|
||||
};
|
||||
|
||||
let journal = match data.0.get_journal_by_id(note.journal).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
|
||||
};
|
||||
|
||||
// get owner
|
||||
let owner = match data.0.get_user_by_id(note.owner).await {
|
||||
Ok(ua) => ua,
|
||||
Err(e) => {
|
||||
return Err(Html(render_error(e, &jar, &data, &user).await));
|
||||
}
|
||||
};
|
||||
|
||||
check_user_blocked_or_private!(user, owner, data, jar);
|
||||
|
||||
// ...
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &user).await;
|
||||
data.0.incr_note_views(note.id).await;
|
||||
|
||||
context.insert("selected_journal", ¬e.journal);
|
||||
context.insert("selected_note", &selected_note);
|
||||
context.insert("redis_views", &data.0.get_note_views(note.id).await);
|
||||
|
||||
context.insert("journal", &journal);
|
||||
context.insert("note", ¬e);
|
||||
|
||||
context.insert("owner", &owner);
|
||||
context.insert::<[i8; 0], &str>("notes", &[]);
|
||||
|
||||
context.insert("view_mode", &true);
|
||||
context.insert("is_editor", &false);
|
||||
context.insert("global_mode", &true);
|
||||
|
||||
// return
|
||||
Ok(Html(data.1.render("journals/app.html", &context).unwrap()))
|
||||
}
|
|
@ -9,7 +9,12 @@ use axum::{
|
|||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use serde::Deserialize;
|
||||
use tetratto_core::model::{permissions::FinePermission, requests::ActionType, Error};
|
||||
use tetratto_core::model::{
|
||||
auth::{AchievementName, DefaultTimelineChoice, ACHIEVEMENTS},
|
||||
permissions::FinePermission,
|
||||
requests::ActionType,
|
||||
Error,
|
||||
};
|
||||
use std::fs::read_to_string;
|
||||
use pathbufd::PathBufD;
|
||||
|
||||
|
@ -40,22 +45,9 @@ pub async fn index_request(
|
|||
return {
|
||||
// all timeline for unauthenticated users
|
||||
// i'm only changing this for stripe
|
||||
let list = match data.0.get_latest_posts(12, req.page).await {
|
||||
Ok(l) => match data
|
||||
.0
|
||||
.fill_posts_with_community(l, 0, &Vec::new(), &None)
|
||||
.await
|
||||
{
|
||||
Ok(l) => l,
|
||||
Err(e) => return Html(render_error(e, &jar, &data, &None).await),
|
||||
},
|
||||
Err(e) => return Html(render_error(e, &jar, &data, &None).await),
|
||||
};
|
||||
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &None).await;
|
||||
|
||||
context.insert("list", &list);
|
||||
context.insert("page", &req.page);
|
||||
Html(data.1.render("timelines/all.html", &context).unwrap())
|
||||
};
|
||||
|
@ -99,36 +91,9 @@ pub async fn popular_request(
|
|||
let data = data.read().await;
|
||||
let user = get_user_from_token!(jar, data.0);
|
||||
|
||||
let ignore_users = crate::ignore_users_gen!(user, data);
|
||||
|
||||
let list = match data.0.get_popular_posts(12, req.page, 604_800_000).await {
|
||||
Ok(l) => match data
|
||||
.0
|
||||
.fill_posts_with_community(
|
||||
l,
|
||||
if let Some(ref ua) = user { ua.id } else { 0 },
|
||||
&ignore_users,
|
||||
&user,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(l) => data.0.posts_muted_phrase_filter(
|
||||
&l,
|
||||
if let Some(ref ua) = user {
|
||||
Some(&ua.settings.muted)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
),
|
||||
Err(e) => return Html(render_error(e, &jar, &data, &user).await),
|
||||
},
|
||||
Err(e) => return Html(render_error(e, &jar, &data, &user).await),
|
||||
};
|
||||
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &user).await;
|
||||
|
||||
context.insert("list", &list);
|
||||
context.insert("page", &req.page);
|
||||
Html(data.1.render("timelines/popular.html", &context).unwrap())
|
||||
}
|
||||
|
@ -149,30 +114,9 @@ pub async fn following_request(
|
|||
}
|
||||
};
|
||||
|
||||
let ignore_users = crate::ignore_users_gen!(user!, data);
|
||||
|
||||
let list = match data
|
||||
.0
|
||||
.get_posts_from_user_following(user.id, 12, req.page)
|
||||
.await
|
||||
{
|
||||
Ok(l) => match data
|
||||
.0
|
||||
.fill_posts_with_community(l, user.id, &ignore_users, &Some(user.clone()))
|
||||
.await
|
||||
{
|
||||
Ok(l) => data
|
||||
.0
|
||||
.posts_muted_phrase_filter(&l, Some(&user.settings.muted)),
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
|
||||
},
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
|
||||
};
|
||||
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
|
||||
|
||||
context.insert("list", &list);
|
||||
context.insert("page", &req.page);
|
||||
Ok(Html(
|
||||
data.1.render("timelines/following.html", &context).unwrap(),
|
||||
|
@ -188,36 +132,9 @@ pub async fn all_request(
|
|||
let data = data.read().await;
|
||||
let user = get_user_from_token!(jar, data.0);
|
||||
|
||||
let ignore_users = crate::ignore_users_gen!(user, data);
|
||||
|
||||
let list = match data.0.get_latest_posts(12, req.page).await {
|
||||
Ok(l) => match data
|
||||
.0
|
||||
.fill_posts_with_community(
|
||||
l,
|
||||
if let Some(ref ua) = user { ua.id } else { 0 },
|
||||
&ignore_users,
|
||||
&user,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(l) => data.0.posts_muted_phrase_filter(
|
||||
&l,
|
||||
if let Some(ref ua) = user {
|
||||
Some(&ua.settings.muted)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
),
|
||||
Err(e) => return Html(render_error(e, &jar, &data, &user).await),
|
||||
},
|
||||
Err(e) => return Html(render_error(e, &jar, &data, &user).await),
|
||||
};
|
||||
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &user).await;
|
||||
|
||||
context.insert("list", &list);
|
||||
context.insert("page", &req.page);
|
||||
Html(data.1.render("timelines/all.html", &context).unwrap())
|
||||
}
|
||||
|
@ -417,6 +334,14 @@ pub async fn notifications_request(
|
|||
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
|
||||
};
|
||||
|
||||
// check and clear
|
||||
if profile.settings.auto_clear_notifs {
|
||||
if let Err(e) = data.0.delete_all_notifications(&user).await {
|
||||
return Err(Html(render_error(e, &jar, &data, &Some(user)).await));
|
||||
}
|
||||
}
|
||||
|
||||
// ...
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
|
||||
|
||||
|
@ -519,6 +444,48 @@ pub async fn requests_request(
|
|||
Ok(Html(data.1.render("misc/requests.html", &context).unwrap()))
|
||||
}
|
||||
|
||||
/// `/achievements`
|
||||
pub async fn achievements_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = data.read().await;
|
||||
let mut user = match get_user_from_token!(jar, data.0) {
|
||||
Some(ua) => ua,
|
||||
None => {
|
||||
return Err(Html(
|
||||
render_error(Error::NotAllowed, &jar, &data, &None).await,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let achievements = data.0.fill_achievements(user.achievements.clone());
|
||||
|
||||
// award achievement
|
||||
if let Err(e) = data
|
||||
.0
|
||||
.add_achievement(&mut user, AchievementName::OpenAchievements.into())
|
||||
.await
|
||||
{
|
||||
return Err(Html(render_error(e, &jar, &data, &None).await));
|
||||
}
|
||||
|
||||
// ...
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
|
||||
|
||||
context.insert(
|
||||
"percentage",
|
||||
&((achievements.len() as f32 / ACHIEVEMENTS as f32) * 100.0),
|
||||
);
|
||||
context.insert("achievements", &achievements);
|
||||
|
||||
// return
|
||||
Ok(Html(
|
||||
data.1.render("misc/achievements.html", &context).unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
/// `/doc/{file_name}`
|
||||
pub async fn markdown_document_request(
|
||||
jar: CookieJar,
|
||||
|
@ -649,3 +616,146 @@ pub async fn search_request(
|
|||
data.1.render("timelines/search.html", &context).unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TimelineQuery {
|
||||
#[serde(default)]
|
||||
pub tl: DefaultTimelineChoice,
|
||||
#[serde(default)]
|
||||
pub page: usize,
|
||||
#[serde(default)]
|
||||
pub stack_id: usize,
|
||||
#[serde(default)]
|
||||
pub user_id: usize,
|
||||
#[serde(default)]
|
||||
pub tag: String,
|
||||
#[serde(default)]
|
||||
pub paginated: bool,
|
||||
}
|
||||
|
||||
/// `/_swiss_army_timeline`
|
||||
pub async fn swiss_army_timeline_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Query(req): Query<TimelineQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let data = data.read().await;
|
||||
let user = get_user_from_token!(jar, data.0);
|
||||
|
||||
let ignore_users = crate::ignore_users_gen!(user, data);
|
||||
|
||||
let list = if req.stack_id != 0 {
|
||||
// stacks
|
||||
if let Some(ref ua) = user {
|
||||
match data
|
||||
.0
|
||||
.get_stack_posts(
|
||||
ua.id,
|
||||
req.stack_id,
|
||||
12,
|
||||
req.page,
|
||||
&ignore_users,
|
||||
&Some(ua.to_owned()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(l) => l,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
|
||||
}
|
||||
} else {
|
||||
return Err(Html(
|
||||
render_error(Error::NotAllowed, &jar, &data, &user).await,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
match if req.user_id != 0 {
|
||||
// users
|
||||
let other_user = match data.0.get_user_by_id(req.user_id).await {
|
||||
Ok(ua) => ua,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
|
||||
};
|
||||
|
||||
check_user_blocked_or_private!(user, other_user, data, jar);
|
||||
|
||||
if req.tag.is_empty() {
|
||||
data.0.get_posts_by_user(req.user_id, 12, req.page).await
|
||||
} else {
|
||||
data.0
|
||||
.get_posts_by_user_tag(req.user_id, &req.tag, 12, req.page)
|
||||
.await
|
||||
}
|
||||
} else {
|
||||
// everything else
|
||||
match req.tl {
|
||||
DefaultTimelineChoice::AllPosts => {
|
||||
data.0.get_latest_posts(12, req.page, &user).await
|
||||
}
|
||||
DefaultTimelineChoice::PopularPosts => {
|
||||
data.0.get_popular_posts(12, req.page, 604_800_000).await
|
||||
}
|
||||
DefaultTimelineChoice::FollowingPosts => {
|
||||
if let Some(ref ua) = user {
|
||||
data.0
|
||||
.get_posts_from_user_following(ua.id, 12, req.page)
|
||||
.await
|
||||
} else {
|
||||
return Err(Html(
|
||||
render_error(Error::NotAllowed, &jar, &data, &user).await,
|
||||
));
|
||||
}
|
||||
}
|
||||
DefaultTimelineChoice::MyCommunities => {
|
||||
if let Some(ref ua) = user {
|
||||
data.0
|
||||
.get_posts_from_user_communities(ua.id, 12, req.page)
|
||||
.await
|
||||
} else {
|
||||
return Err(Html(
|
||||
render_error(Error::NotAllowed, &jar, &data, &user).await,
|
||||
));
|
||||
}
|
||||
}
|
||||
// questions bad
|
||||
_ => {
|
||||
return Err(Html(
|
||||
render_error(Error::NotAllowed, &jar, &data, &user).await,
|
||||
));
|
||||
}
|
||||
}
|
||||
} {
|
||||
Ok(l) => match data
|
||||
.0
|
||||
.fill_posts_with_community(
|
||||
l,
|
||||
if let Some(ref ua) = user { ua.id } else { 0 },
|
||||
&ignore_users,
|
||||
&user,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(l) => data.0.posts_muted_phrase_filter(
|
||||
&l,
|
||||
if let Some(ref ua) = user {
|
||||
Some(&ua.settings.muted)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
),
|
||||
Err(e) => return Ok(Html(render_error(e, &jar, &data, &user).await)),
|
||||
},
|
||||
Err(e) => return Ok(Html(render_error(e, &jar, &data, &user).await)),
|
||||
}
|
||||
};
|
||||
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &user).await;
|
||||
|
||||
context.insert("list", &list);
|
||||
context.insert("page", &req.page);
|
||||
context.insert("paginated", &req.paginated);
|
||||
Ok(Html(
|
||||
data.1
|
||||
.render("timelines/swiss_army.html", &context)
|
||||
.unwrap(),
|
||||
))
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ pub mod chats;
|
|||
pub mod communities;
|
||||
pub mod developer;
|
||||
pub mod forge;
|
||||
pub mod journals;
|
||||
pub mod misc;
|
||||
pub mod mod_panel;
|
||||
pub mod profile;
|
||||
|
@ -29,6 +30,10 @@ pub fn routes() -> Router {
|
|||
.route("/following", get(misc::following_request))
|
||||
.route("/all", get(misc::all_request))
|
||||
.route("/search", get(misc::search_request))
|
||||
.route(
|
||||
"/_swiss_army_timeline",
|
||||
get(misc::swiss_army_timeline_request),
|
||||
)
|
||||
// question timelines
|
||||
.route("/questions", get(misc::index_questions_request))
|
||||
.route("/popular/questions", get(misc::popular_questions_request))
|
||||
|
@ -40,6 +45,7 @@ pub fn routes() -> Router {
|
|||
// misc
|
||||
.route("/notifs", get(misc::notifications_request))
|
||||
.route("/requests", get(misc::requests_request))
|
||||
.route("/achievements", get(misc::achievements_request))
|
||||
.route("/doc/{*file_name}", get(misc::markdown_document_request))
|
||||
.fallback_service(get(misc::not_found))
|
||||
// mod
|
||||
|
@ -126,6 +132,13 @@ pub fn routes() -> Router {
|
|||
.route("/stacks", get(stacks::list_request))
|
||||
.route("/stacks/{id}", get(stacks::feed_request))
|
||||
.route("/stacks/{id}/manage", get(stacks::manage_request))
|
||||
.route("/stacks/add_user/{id}", get(stacks::add_user_request))
|
||||
// journals
|
||||
.route("/journals", get(journals::redirect_request))
|
||||
.route("/journals/{journal}/{note}", get(journals::app_request))
|
||||
.route("/@{owner}/{journal}", get(journals::index_view_request))
|
||||
.route("/@{owner}/{journal}/{note}", get(journals::view_request))
|
||||
.route("/x/{note}", get(journals::global_view_request))
|
||||
}
|
||||
|
||||
pub async fn render_error(
|
||||
|
@ -181,3 +194,11 @@ pub struct RepostsQuery {
|
|||
#[serde(default)]
|
||||
pub page: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct JournalsAppQuery {
|
||||
#[serde(default)]
|
||||
pub view: bool,
|
||||
#[serde(default)]
|
||||
pub tag: String,
|
||||
}
|
||||
|
|
|
@ -194,10 +194,23 @@ pub async fn manage_profile_request(
|
|||
out
|
||||
};
|
||||
|
||||
let invite_code = if profile.invite_code != 0 {
|
||||
match data.0.get_invite_code_by_id(profile.invite_code).await {
|
||||
Ok(i) => match data.0.get_user_by_id(i.owner).await {
|
||||
Ok(u) => Some((u, i)),
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
|
||||
},
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
|
||||
|
||||
context.insert("profile", &profile);
|
||||
context.insert("invite", &invite_code);
|
||||
context.insert("associations", &associations);
|
||||
|
||||
// return
|
||||
|
@ -298,6 +311,35 @@ pub async fn stats_request(jar: CookieJar, Extension(data): Extension<State>) ->
|
|||
.unwrap(),
|
||||
);
|
||||
|
||||
context.insert(
|
||||
"table_users",
|
||||
&data.0.get_table_row_count("users").await.unwrap_or(0),
|
||||
);
|
||||
context.insert(
|
||||
"table_posts",
|
||||
&data.0.get_table_row_count("posts").await.unwrap_or(0),
|
||||
);
|
||||
context.insert(
|
||||
"table_invite_codes",
|
||||
&data
|
||||
.0
|
||||
.get_table_row_count("invite_codes")
|
||||
.await
|
||||
.unwrap_or(0),
|
||||
);
|
||||
context.insert(
|
||||
"table_uploads",
|
||||
&data.0.get_table_row_count("uploads").await.unwrap_or(0),
|
||||
);
|
||||
context.insert(
|
||||
"table_communities",
|
||||
&data.0.get_table_row_count("communities").await.unwrap_or(0),
|
||||
);
|
||||
context.insert(
|
||||
"table_ipbans",
|
||||
&data.0.get_table_row_count("ipbans").await.unwrap_or(0),
|
||||
);
|
||||
|
||||
// return
|
||||
Ok(Html(data.1.render("mod/stats.html", &context).unwrap()))
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
use super::{render_error, PaginatedQuery, ProfileQuery};
|
||||
use crate::{
|
||||
assets::initial_context, check_user_blocked_or_private, get_lang, get_user_from_token, State,
|
||||
assets::initial_context, check_user_blocked_or_private, get_lang, get_user_from_token,
|
||||
ignore_users_gen, State,
|
||||
};
|
||||
use axum::{
|
||||
Extension,
|
||||
|
@ -100,6 +101,22 @@ pub async fn settings_request(
|
|||
}
|
||||
};
|
||||
|
||||
let invites = match data
|
||||
.0
|
||||
.get_invite_codes_by_owner(profile.id, 12, req.page)
|
||||
.await
|
||||
{
|
||||
Ok(l) => match data.0.fill_invite_codes(l).await {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
return Err(Html(render_error(e, &jar, &data, &None).await));
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
return Err(Html(render_error(e, &jar, &data, &None).await));
|
||||
}
|
||||
};
|
||||
|
||||
let tokens = profile.tokens.clone();
|
||||
|
||||
let lang = get_lang!(jar, data.0);
|
||||
|
@ -112,6 +129,7 @@ pub async fn settings_request(
|
|||
context.insert("following", &following);
|
||||
context.insert("blocks", &blocks);
|
||||
context.insert("stackblocks", &stackblocks);
|
||||
context.insert("invites", &invites);
|
||||
context.insert(
|
||||
"user_tokens_serde",
|
||||
&serde_json::to_string(&tokens)
|
||||
|
@ -241,67 +259,8 @@ pub async fn posts_request(
|
|||
));
|
||||
}
|
||||
|
||||
// fetch data
|
||||
let ignore_users = crate::ignore_users_gen!(user, data);
|
||||
|
||||
let posts = if props.tag.is_empty() {
|
||||
match data
|
||||
.0
|
||||
.get_posts_by_user(other_user.id, 12, props.page, &user)
|
||||
.await
|
||||
{
|
||||
Ok(p) => match data
|
||||
.0
|
||||
.fill_posts_with_community(
|
||||
p,
|
||||
if let Some(ref ua) = user { ua.id } else { 0 },
|
||||
&ignore_users,
|
||||
&user,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(p) => data.0.posts_muted_phrase_filter(
|
||||
&p,
|
||||
if let Some(ref ua) = user {
|
||||
Some(&ua.settings.muted)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
),
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
|
||||
},
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
|
||||
}
|
||||
} else {
|
||||
match data
|
||||
.0
|
||||
.get_posts_by_user_tag(other_user.id, &props.tag, 12, props.page, &user)
|
||||
.await
|
||||
{
|
||||
Ok(p) => match data
|
||||
.0
|
||||
.fill_posts_with_community(
|
||||
p,
|
||||
if let Some(ref ua) = user { ua.id } else { 0 },
|
||||
&ignore_users,
|
||||
&user,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(p) => data.0.posts_muted_phrase_filter(
|
||||
&p,
|
||||
if let Some(ref ua) = user {
|
||||
Some(&ua.settings.muted)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
),
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
|
||||
},
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
|
||||
}
|
||||
};
|
||||
|
||||
// fetch pinned
|
||||
let ignore_users = ignore_users_gen!(user, data);
|
||||
let pinned = if props.tag.is_empty() {
|
||||
match data.0.get_pinned_posts_by_user(other_user.id).await {
|
||||
Ok(p) => match data
|
||||
|
@ -375,7 +334,6 @@ pub async fn posts_request(
|
|||
false
|
||||
};
|
||||
|
||||
context.insert("posts", &posts);
|
||||
context.insert("pinned", &pinned);
|
||||
context.insert("page", &props.page);
|
||||
context.insert("tag", &props.tag);
|
||||
|
|
|
@ -92,25 +92,6 @@ pub async fn feed_request(
|
|||
.await
|
||||
.is_ok(),
|
||||
);
|
||||
} else {
|
||||
let ignore_users = crate::ignore_users_gen!(user!, data);
|
||||
let list = match data
|
||||
.0
|
||||
.get_stack_posts(
|
||||
user.id,
|
||||
stack.id,
|
||||
12,
|
||||
req.page,
|
||||
&ignore_users,
|
||||
&Some(user.clone()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(l) => l,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
|
||||
};
|
||||
|
||||
context.insert("list", &list);
|
||||
}
|
||||
|
||||
// return
|
||||
|
@ -176,3 +157,41 @@ pub async fn manage_request(
|
|||
// return
|
||||
Ok(Html(data.1.render("stacks/manage.html", &context).unwrap()))
|
||||
}
|
||||
|
||||
/// `/stacks/add_user`
|
||||
pub async fn add_user_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = data.read().await;
|
||||
let user = match get_user_from_token!(jar, data.0) {
|
||||
Some(ua) => ua,
|
||||
None => {
|
||||
return Err(Html(
|
||||
render_error(Error::NotAllowed, &jar, &data, &None).await,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let add_user = match data.0.get_user_by_id(id).await {
|
||||
Ok(ua) => ua,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)),
|
||||
};
|
||||
|
||||
let stacks = match data.0.get_stacks_by_user(user.id).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
|
||||
};
|
||||
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
|
||||
|
||||
context.insert("stacks", &stacks);
|
||||
context.insert("add_user", &add_user);
|
||||
|
||||
// return
|
||||
Ok(Html(
|
||||
data.1.render("stacks/add_user.html", &context).unwrap(),
|
||||
))
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "tetratto-core"
|
||||
version = "8.0.0"
|
||||
version = "10.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
|
|
@ -13,6 +13,9 @@ pub struct SecurityConfig {
|
|||
/// The name of the header which will contain the real IP of the connecting user.
|
||||
#[serde(default = "default_real_ip_header")]
|
||||
pub real_ip_header: String,
|
||||
/// If users require an invite code to register. Invite codes can be generated by supporters.
|
||||
#[serde(default = "default_enable_invite_codes")]
|
||||
pub enable_invite_codes: bool,
|
||||
}
|
||||
|
||||
fn default_security_registration_enabled() -> bool {
|
||||
|
@ -23,11 +26,16 @@ fn default_real_ip_header() -> String {
|
|||
"CF-Connecting-IP".to_string()
|
||||
}
|
||||
|
||||
fn default_enable_invite_codes() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
impl Default for SecurityConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
registration_enabled: default_security_registration_enabled(),
|
||||
real_ip_header: default_real_ip_header(),
|
||||
enable_invite_codes: default_enable_invite_codes(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -341,6 +349,8 @@ fn default_banned_usernames() -> Vec<String> {
|
|||
"stacks".to_string(),
|
||||
"stack".to_string(),
|
||||
"search".to_string(),
|
||||
"journals".to_string(),
|
||||
"links".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
use super::common::NAME_REGEX;
|
||||
use oiseau::cache::Cache;
|
||||
use crate::model::auth::UserConnections;
|
||||
use crate::model::auth::{Achievement, AchievementRarity, Notification, UserConnections};
|
||||
use crate::model::moderation::AuditLogEntry;
|
||||
use crate::model::oauth::AuthGrant;
|
||||
use crate::model::permissions::SecondaryPermission;
|
||||
use crate::model::{
|
||||
Error, Result,
|
||||
auth::{Token, User, UserSettings},
|
||||
|
@ -15,10 +16,73 @@ use tetratto_shared::{
|
|||
unix_epoch_timestamp,
|
||||
};
|
||||
use crate::{auto_method, DataManager};
|
||||
use oiseau::{PostgresRow, execute, get, query_row, params};
|
||||
|
||||
use oiseau::PostgresRow;
|
||||
macro_rules! update_role_fn {
|
||||
($name:ident, $role_ty:ty, $col:literal) => {
|
||||
pub async fn $name(
|
||||
&self,
|
||||
id: usize,
|
||||
role: $role_ty,
|
||||
user: User,
|
||||
force: bool,
|
||||
) -> Result<()> {
|
||||
let other_user = self.get_user_by_id(id).await?;
|
||||
|
||||
use oiseau::{execute, get, query_row, params};
|
||||
if !force {
|
||||
// check permission
|
||||
if !user.permissions.check(FinePermission::MANAGE_USERS) {
|
||||
return Err(Error::NotAllowed);
|
||||
}
|
||||
|
||||
if other_user.permissions.check_manager() && !user.permissions.check_admin() {
|
||||
return Err(Error::MiscError(
|
||||
"Cannot manage the role of other managers".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if other_user.permissions == user.permissions {
|
||||
return Err(Error::MiscError(
|
||||
"Cannot manage users of equal level to you".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ...
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = execute!(
|
||||
&conn,
|
||||
&format!("UPDATE users SET {} = $1 WHERE id = $2", $col),
|
||||
params![&(role.bits() as i32), &(id as i64)]
|
||||
);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
self.cache_clear_user(&other_user).await;
|
||||
|
||||
// create audit log entry
|
||||
self.create_audit_log_entry(AuditLogEntry::new(
|
||||
user.id,
|
||||
format!(
|
||||
"invoked `{}` with x value `{}` and y value `{}`",
|
||||
$col,
|
||||
other_user.id,
|
||||
role.bits()
|
||||
),
|
||||
))
|
||||
.await?;
|
||||
|
||||
// ...
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
impl DataManager {
|
||||
/// Get a [`User`] from an SQL row.
|
||||
|
@ -45,6 +109,9 @@ impl DataManager {
|
|||
stripe_id: get!(x->18(String)),
|
||||
grants: serde_json::from_str(&get!(x->19(String)).to_string()).unwrap(),
|
||||
associated: serde_json::from_str(&get!(x->20(String)).to_string()).unwrap(),
|
||||
invite_code: get!(x->21(i64)) as usize,
|
||||
secondary_permissions: SecondaryPermission::from_bits(get!(x->22(i32)) as u32).unwrap(),
|
||||
achievements: serde_json::from_str(&get!(x->23(String)).to_string()).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -200,7 +267,7 @@ impl DataManager {
|
|||
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)",
|
||||
"INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)",
|
||||
params![
|
||||
&(data.id as i64),
|
||||
&(data.created as i64),
|
||||
|
@ -216,13 +283,16 @@ impl DataManager {
|
|||
&0_i32,
|
||||
&(data.last_seen as i64),
|
||||
&String::new(),
|
||||
&"[]",
|
||||
"[]",
|
||||
&0_i32,
|
||||
&0_i32,
|
||||
&serde_json::to_string(&data.connections).unwrap(),
|
||||
&"",
|
||||
&serde_json::to_string(&data.grants).unwrap(),
|
||||
&serde_json::to_string(&data.associated).unwrap(),
|
||||
&(data.invite_code as i64),
|
||||
&(SecondaryPermission::DEFAULT.bits() as i32),
|
||||
&serde_json::to_string(&data.achievements).unwrap(),
|
||||
]
|
||||
);
|
||||
|
||||
|
@ -389,6 +459,57 @@ impl DataManager {
|
|||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
// delete stackblocks
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"DELETE FROM stackblocks WHERE initiator = $1",
|
||||
&[&(id as i64)]
|
||||
);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
// delete journals
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"DELETE FROM journals WHERE owner = $1",
|
||||
&[&(id as i64)]
|
||||
);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
// delete notes
|
||||
let res = execute!(&conn, "DELETE FROM notes WHERE owner = $1", &[&(id as i64)]);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
// delete invite codes
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"DELETE FROM invite_codes WHERE owner = $1",
|
||||
&[&(id as i64)]
|
||||
);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
// delete message reactions
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"DELETE FROM message_reactions WHERE owner = $1",
|
||||
&[&(id as i64)]
|
||||
);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
// delete user follows... individually since it requires updating user counts
|
||||
for follow in self.get_userfollows_by_receiver_all(id).await? {
|
||||
self.delete_userfollow(follow.id, &user, true).await?;
|
||||
|
@ -437,6 +558,16 @@ impl DataManager {
|
|||
self.delete_poll(poll.id, &user).await?;
|
||||
}
|
||||
|
||||
// free up invite code
|
||||
if self.0.0.security.enable_invite_codes {
|
||||
if user.invite_code != 0 && self.get_invite_code_by_id(user.invite_code).await.is_ok() {
|
||||
// we're checking if the code is ok because the owner might've deleted their account,
|
||||
// deleting all of their invite codes as well
|
||||
self.update_invite_code_is_used(user.invite_code, false)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
// ...
|
||||
Ok(())
|
||||
}
|
||||
|
@ -557,67 +688,6 @@ impl DataManager {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_user_role(
|
||||
&self,
|
||||
id: usize,
|
||||
role: FinePermission,
|
||||
user: User,
|
||||
force: bool,
|
||||
) -> Result<()> {
|
||||
let other_user = self.get_user_by_id(id).await?;
|
||||
|
||||
if !force {
|
||||
// check permission
|
||||
if !user.permissions.check(FinePermission::MANAGE_USERS) {
|
||||
return Err(Error::NotAllowed);
|
||||
}
|
||||
|
||||
if other_user.permissions.check_manager() && !user.permissions.check_admin() {
|
||||
return Err(Error::MiscError(
|
||||
"Cannot manage the role of other managers".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if other_user.permissions == user.permissions {
|
||||
return Err(Error::MiscError(
|
||||
"Cannot manage users of equal level to you".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ...
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"UPDATE users SET permissions = $1 WHERE id = $2",
|
||||
params![&(role.bits() as i32), &(id as i64)]
|
||||
);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
self.cache_clear_user(&other_user).await;
|
||||
|
||||
// create audit log entry
|
||||
self.create_audit_log_entry(AuditLogEntry::new(
|
||||
user.id,
|
||||
format!(
|
||||
"invoked `update_user_role` with x value `{}` and y value `{}`",
|
||||
other_user.id,
|
||||
role.bits()
|
||||
),
|
||||
))
|
||||
.await?;
|
||||
|
||||
// ...
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn seen_user(&self, user: &User) -> Result<()> {
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
|
@ -639,6 +709,69 @@ impl DataManager {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Add an achievement to a user.
|
||||
///
|
||||
/// Still returns `Ok` if the user already has the achievement.
|
||||
pub async fn add_achievement(&self, user: &mut User, achievement: Achievement) -> Result<()> {
|
||||
if user.settings.disable_achievements {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if user
|
||||
.achievements
|
||||
.iter()
|
||||
.find(|x| x.name == achievement.name)
|
||||
.is_some()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// send notif
|
||||
self.create_notification(Notification::new(
|
||||
"You've earned a new achievement!".to_string(),
|
||||
format!(
|
||||
"You've earned the \"{}\" [achievement](/achievements)!",
|
||||
achievement.name.title()
|
||||
),
|
||||
user.id,
|
||||
))
|
||||
.await?;
|
||||
|
||||
// add achievement
|
||||
user.achievements.push(achievement);
|
||||
self.update_user_achievements(user.id, user.achievements.to_owned())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fill achievements with their title and description.
|
||||
///
|
||||
/// # Returns
|
||||
/// `(name, description, rarity, achievement)`
|
||||
pub fn fill_achievements(
|
||||
&self,
|
||||
mut list: Vec<Achievement>,
|
||||
) -> Vec<(String, String, AchievementRarity, Achievement)> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
// sort by unlocked desc
|
||||
list.sort_by(|a, b| a.unlocked.cmp(&b.unlocked));
|
||||
list.reverse();
|
||||
|
||||
// ...
|
||||
for x in list {
|
||||
out.push((
|
||||
x.name.title().to_string(),
|
||||
x.name.description().to_string(),
|
||||
x.name.rarity(),
|
||||
x,
|
||||
))
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Validate a given TOTP code for the given profile.
|
||||
pub fn check_totp(&self, ua: &User, code: &str) -> bool {
|
||||
let totp = ua.totp(Some(
|
||||
|
@ -777,11 +910,19 @@ impl DataManager {
|
|||
.await;
|
||||
}
|
||||
|
||||
update_role_fn!(update_user_role, FinePermission, "permissions");
|
||||
update_role_fn!(
|
||||
update_user_secondary_role,
|
||||
SecondaryPermission,
|
||||
"secondary_permissions"
|
||||
);
|
||||
|
||||
auto_method!(update_user_tokens(Vec<Token>)@get_user_by_id -> "UPDATE users SET tokens = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
|
||||
auto_method!(update_user_grants(Vec<AuthGrant>)@get_user_by_id -> "UPDATE users SET grants = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
|
||||
auto_method!(update_user_settings(UserSettings)@get_user_by_id -> "UPDATE users SET settings = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
|
||||
auto_method!(update_user_connections(UserConnections)@get_user_by_id -> "UPDATE users SET connections = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
|
||||
auto_method!(update_user_associated(Vec<usize>)@get_user_by_id -> "UPDATE users SET associated = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
|
||||
auto_method!(update_user_achievements(Vec<Achievement>)@get_user_by_id -> "UPDATE users SET achievements = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
|
||||
|
||||
auto_method!(get_user_by_stripe_id(&str)@get_user_from_row -> "SELECT * FROM users WHERE stripe_id = $1" --name="user" --returns=User);
|
||||
auto_method!(update_user_stripe_id(&str)@get_user_by_id -> "UPDATE users SET stripe_id = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);
|
||||
|
@ -802,4 +943,6 @@ impl DataManager {
|
|||
auto_method!(update_user_request_count(i32)@get_user_by_id -> "UPDATE users SET request_count = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);
|
||||
auto_method!(incr_user_request_count()@get_user_by_id -> "UPDATE users SET request_count = request_count + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_user --incr);
|
||||
auto_method!(decr_user_request_count()@get_user_by_id -> "UPDATE users SET request_count = request_count - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_user --decr=request_count);
|
||||
|
||||
auto_method!(get_user_by_invite_code(i64)@get_user_from_row -> "SELECT * FROM users WHERE invite_code = $1" --name="user" --returns=User);
|
||||
}
|
||||
|
|
|
@ -21,6 +21,7 @@ impl DataManager {
|
|||
position: get!(x->6(i32)) as usize,
|
||||
members: serde_json::from_str(&get!(x->7(String))).unwrap(),
|
||||
title: get!(x->8(String)),
|
||||
last_message: get!(x->9(i64)) as usize,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -81,7 +82,7 @@ impl DataManager {
|
|||
|
||||
let res = query_rows!(
|
||||
&conn,
|
||||
"SELECT * FROM channels WHERE (owner = $1 OR members LIKE $2) AND community = 0 ORDER BY created DESC",
|
||||
"SELECT * FROM channels WHERE (owner = $1 OR members LIKE $2) AND community = 0 ORDER BY last_message DESC",
|
||||
params![&(user as i64), &format!("%{user}%")],
|
||||
|x| { Self::get_channel_from_row(x) }
|
||||
);
|
||||
|
@ -162,7 +163,7 @@ impl DataManager {
|
|||
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"INSERT INTO channels VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
||||
"INSERT INTO channels VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
|
||||
params![
|
||||
&(data.id as i64),
|
||||
&(data.community as i64),
|
||||
|
@ -172,7 +173,8 @@ impl DataManager {
|
|||
&(data.minimum_role_write as i32),
|
||||
&(data.position as i32),
|
||||
&serde_json::to_string(&data.members).unwrap(),
|
||||
&data.title
|
||||
&data.title,
|
||||
&(data.last_message as i64)
|
||||
]
|
||||
);
|
||||
|
||||
|
@ -320,4 +322,5 @@ impl DataManager {
|
|||
auto_method!(update_channel_minimum_role_read(i32)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET minimum_role_read = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}");
|
||||
auto_method!(update_channel_minimum_role_write(i32)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET minimum_role_write = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}");
|
||||
auto_method!(update_channel_members(Vec<usize>)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET members = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.channel:{}");
|
||||
auto_method!(update_channel_last_message(i64) -> "UPDATE channels SET last_message = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}");
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
use crate::model::{Error, Result};
|
||||
use super::{DataManager, drivers::common};
|
||||
use oiseau::{cache::Cache, execute};
|
||||
use oiseau::{cache::Cache, execute, query_row, params};
|
||||
|
||||
pub const NAME_REGEX: &str = r"[^\w_\-\.,!]+";
|
||||
|
||||
|
@ -36,6 +36,10 @@ impl DataManager {
|
|||
execute!(&conn, common::CREATE_TABLE_POLLVOTES).unwrap();
|
||||
execute!(&conn, common::CREATE_TABLE_APPS).unwrap();
|
||||
execute!(&conn, common::CREATE_TABLE_STACKBLOCKS).unwrap();
|
||||
execute!(&conn, common::CREATE_TABLE_JOURNALS).unwrap();
|
||||
execute!(&conn, common::CREATE_TABLE_NOTES).unwrap();
|
||||
execute!(&conn, common::CREATE_TABLE_MESSAGE_REACTIONS).unwrap();
|
||||
execute!(&conn, common::CREATE_TABLE_INVITE_CODES).unwrap();
|
||||
|
||||
self.0
|
||||
.1
|
||||
|
@ -48,6 +52,26 @@ impl DataManager {
|
|||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_table_row_count(&self, table: &str) -> Result<i32> {
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = query_row!(
|
||||
&conn,
|
||||
&format!("SELECT COUNT(*)::int FROM {}", table),
|
||||
params![],
|
||||
|x| Ok(x.get::<usize, i32>(0))
|
||||
);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
Ok(res.unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
|
@ -112,7 +136,8 @@ macro_rules! auto_method {
|
|||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = query_row!(&conn, $query, &[&selector], |x| { Ok(Self::$select_fn(x)) });
|
||||
let res =
|
||||
oiseau::query_row!(&conn, $query, &[&selector], |x| { Ok(Self::$select_fn(x)) });
|
||||
|
||||
if res.is_err() {
|
||||
return Err(Error::GeneralNotFound($name_.to_string()));
|
||||
|
@ -164,7 +189,15 @@ macro_rules! auto_method {
|
|||
.get(format!($cache_key_tmpl, selector.to_string()))
|
||||
.await
|
||||
{
|
||||
return Ok(serde_json::from_str(&cached).unwrap());
|
||||
match serde_json::from_str(&cached) {
|
||||
Ok(x) => return Ok(x),
|
||||
Err(_) => {
|
||||
self.0
|
||||
.1
|
||||
.remove(format!($cache_key_tmpl, selector.to_string()))
|
||||
.await
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let conn = match self.0.connect().await {
|
||||
|
|
|
@ -299,11 +299,10 @@ impl DataManager {
|
|||
}
|
||||
|
||||
// add community owner as admin
|
||||
self.create_membership(CommunityMembership::new(
|
||||
data.owner,
|
||||
data.id,
|
||||
CommunityPermission::ADMINISTRATOR,
|
||||
))
|
||||
self.create_membership(
|
||||
CommunityMembership::new(data.owner, data.id, CommunityPermission::ADMINISTRATOR),
|
||||
&owner,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
|
|
@ -23,3 +23,7 @@ pub const CREATE_TABLE_POLLS: &str = include_str!("./sql/create_polls.sql");
|
|||
pub const CREATE_TABLE_POLLVOTES: &str = include_str!("./sql/create_pollvotes.sql");
|
||||
pub const CREATE_TABLE_APPS: &str = include_str!("./sql/create_apps.sql");
|
||||
pub const CREATE_TABLE_STACKBLOCKS: &str = include_str!("./sql/create_stackblocks.sql");
|
||||
pub const CREATE_TABLE_JOURNALS: &str = include_str!("./sql/create_journals.sql");
|
||||
pub const CREATE_TABLE_NOTES: &str = include_str!("./sql/create_notes.sql");
|
||||
pub const CREATE_TABLE_MESSAGE_REACTIONS: &str = include_str!("./sql/create_message_reactions.sql");
|
||||
pub const CREATE_TABLE_INVITE_CODES: &str = include_str!("./sql/create_invite_codes.sql");
|
||||
|
|
|
@ -7,5 +7,6 @@ CREATE TABLE IF NOT EXISTS channels (
|
|||
minimum_role_write INT NOT NULL,
|
||||
position INT NOT NULL,
|
||||
members TEXT NOT NULL,
|
||||
title TEXT NOT NULL
|
||||
title TEXT NOT NULL,
|
||||
last_message BIGINT NOT NULL
|
||||
)
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
CREATE TABLE IF NOT EXISTS invite_codes (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
created BIGINT NOT NULL,
|
||||
owner BIGINT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
is_used INT NOT NULL
|
||||
)
|
8
crates/core/src/database/drivers/sql/create_journals.sql
Normal file
8
crates/core/src/database/drivers/sql/create_journals.sql
Normal file
|
@ -0,0 +1,8 @@
|
|||
CREATE TABLE IF NOT EXISTS journals (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
created BIGINT NOT NULL,
|
||||
owner BIGINT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
privacy TEXT NOT NULL,
|
||||
dirs TEXT NOT NUll
|
||||
)
|
|
@ -0,0 +1,8 @@
|
|||
CREATE TABLE IF NOT EXISTS message_reactions (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
created BIGINT NOT NULL,
|
||||
owner BIGINT NOT NULL,
|
||||
message BIGINT NOT NULL,
|
||||
emoji TEXT NOT NULL,
|
||||
UNIQUE (owner, message, emoji)
|
||||
)
|
|
@ -5,5 +5,6 @@ CREATE TABLE IF NOT EXISTS messages (
|
|||
created BIGINT NOT NULL,
|
||||
edited BIGINT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
context TEXT NOT NULL
|
||||
context TEXT NOT NULL,
|
||||
reactions TEXT NOT NULL
|
||||
)
|
||||
|
|
12
crates/core/src/database/drivers/sql/create_notes.sql
Normal file
12
crates/core/src/database/drivers/sql/create_notes.sql
Normal file
|
@ -0,0 +1,12 @@
|
|||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
created BIGINT NOT NULL,
|
||||
owner BIGINT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
journal BIGINT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
edited BIGINT NOT NULL,
|
||||
dir BIGINT NOT NULL,
|
||||
tags TEXT NOT NULL,
|
||||
is_global INT NOT NULL
|
||||
)
|
|
@ -12,5 +12,6 @@ CREATE TABLE IF NOT EXISTS questions (
|
|||
dislikes INT NOT NULL,
|
||||
-- ...
|
||||
context TEXT NOT NULL,
|
||||
ip TEXT NOT NULL
|
||||
ip TEXT NOT NULL,
|
||||
drawings TEXT NOT NULL
|
||||
)
|
||||
|
|
|
@ -19,5 +19,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||
connections TEXT NOT NULL,
|
||||
stripe_id TEXT NOT NULL,
|
||||
grants TEXT NOT NULL,
|
||||
associated TEXT NOT NULL
|
||||
associated TEXT NOT NULL,
|
||||
secondary_permissions INT NOT NULL,
|
||||
achievements TEXT NOT NULL
|
||||
)
|
||||
|
|
206
crates/core/src/database/invite_codes.rs
Normal file
206
crates/core/src/database/invite_codes.rs
Normal file
|
@ -0,0 +1,206 @@
|
|||
use oiseau::{cache::Cache, query_row, query_rows};
|
||||
use tetratto_shared::unix_epoch_timestamp;
|
||||
use crate::model::{
|
||||
Error, Result,
|
||||
auth::{User, InviteCode},
|
||||
permissions::FinePermission,
|
||||
};
|
||||
use crate::{auto_method, DataManager};
|
||||
use oiseau::{PostgresRow, execute, get, params};
|
||||
|
||||
impl DataManager {
|
||||
/// Get a [`InviteCode`] from an SQL row.
|
||||
pub(crate) fn get_invite_code_from_row(x: &PostgresRow) -> InviteCode {
|
||||
InviteCode {
|
||||
id: get!(x->0(i64)) as usize,
|
||||
created: get!(x->1(i64)) as usize,
|
||||
owner: get!(x->2(i64)) as usize,
|
||||
code: get!(x->3(String)),
|
||||
is_used: get!(x->4(i32)) as i8 == 1,
|
||||
}
|
||||
}
|
||||
|
||||
auto_method!(get_invite_code_by_id()@get_invite_code_from_row -> "SELECT * FROM invite_codes WHERE id = $1" --name="invite_code" --returns=InviteCode --cache-key-tmpl="atto.invite_code:{}");
|
||||
auto_method!(get_invite_code_by_code(&str)@get_invite_code_from_row -> "SELECT * FROM invite_codes WHERE code = $1" --name="invite_code" --returns=InviteCode);
|
||||
|
||||
/// Get invite_codes by `owner`.
|
||||
pub async fn get_invite_codes_by_owner(
|
||||
&self,
|
||||
owner: usize,
|
||||
batch: usize,
|
||||
page: usize,
|
||||
) -> Result<Vec<InviteCode>> {
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = query_rows!(
|
||||
&conn,
|
||||
"SELECT * FROM invite_codes WHERE owner = $1 ORDER BY created DESC LIMIT $2 OFFSET $3",
|
||||
&[&(owner as i64), &(batch as i64), &((page * batch) as i64)],
|
||||
|x| { Self::get_invite_code_from_row(x) }
|
||||
);
|
||||
|
||||
if res.is_err() {
|
||||
return Err(Error::GeneralNotFound("invite_code".to_string()));
|
||||
}
|
||||
|
||||
Ok(res.unwrap())
|
||||
}
|
||||
|
||||
/// Get invite_codes by `owner`.
|
||||
pub async fn get_invite_codes_by_owner_count(&self, owner: usize) -> Result<i32> {
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = query_row!(
|
||||
&conn,
|
||||
"SELECT COUNT(*)::int FROM invite_codes WHERE owner = $1",
|
||||
&[&(owner as i64)],
|
||||
|x| Ok(x.get::<usize, i32>(0))
|
||||
);
|
||||
|
||||
if res.is_err() {
|
||||
return Err(Error::GeneralNotFound("invite_code".to_string()));
|
||||
}
|
||||
|
||||
Ok(res.unwrap())
|
||||
}
|
||||
|
||||
/// Fill a vector of invite codes with the user that used them.
|
||||
pub async fn fill_invite_codes(
|
||||
&self,
|
||||
codes: Vec<InviteCode>,
|
||||
) -> Result<Vec<(Option<User>, InviteCode)>> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
for code in codes {
|
||||
if code.is_used {
|
||||
out.push((
|
||||
match self.get_user_by_invite_code(code.id as i64).await {
|
||||
Ok(u) => Some(u),
|
||||
Err(_) => None,
|
||||
},
|
||||
code,
|
||||
))
|
||||
} else {
|
||||
out.push((None, code))
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
const MAXIMUM_FREE_INVITE_CODES: usize = 4;
|
||||
const MAXIMUM_SUPPORTER_INVITE_CODES: usize = 48;
|
||||
const MINIMUM_ACCOUNT_AGE_FOR_FREE_INVITE_CODES: usize = 2_629_800_000; // 1mo
|
||||
|
||||
/// Create a new invite_code in the database.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - a mock [`InviteCode`] object to insert
|
||||
pub async fn create_invite_code(&self, data: InviteCode, user: &User) -> Result<InviteCode> {
|
||||
if !user.permissions.check(FinePermission::SUPPORTER) {
|
||||
// check account creation date
|
||||
if unix_epoch_timestamp() - user.created
|
||||
< Self::MINIMUM_ACCOUNT_AGE_FOR_FREE_INVITE_CODES
|
||||
{
|
||||
return Err(Error::MiscError(
|
||||
"Your account is too young to do this".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// our account is old enough, but we need to make sure we don't already have
|
||||
// 2 invite codes
|
||||
if (self.get_invite_codes_by_owner_count(user.id).await? as usize)
|
||||
>= Self::MAXIMUM_FREE_INVITE_CODES
|
||||
{
|
||||
return Err(Error::MiscError(
|
||||
"You already have the maximum number of invite codes you can create"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
} else if !user.permissions.check(FinePermission::MANAGE_USERS) {
|
||||
// check count since we're also not a moderator with MANAGE_USERS
|
||||
if (self.get_invite_codes_by_owner_count(user.id).await? as usize)
|
||||
>= Self::MAXIMUM_SUPPORTER_INVITE_CODES
|
||||
{
|
||||
return Err(Error::MiscError(
|
||||
"You already have the maximum number of invite codes you can create"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"INSERT INTO invite_codes VALUES ($1, $2, $3, $4, $5)",
|
||||
params![
|
||||
&(data.id as i64),
|
||||
&(data.created as i64),
|
||||
&(data.owner as i64),
|
||||
&data.code,
|
||||
&{ if data.is_used { 1 } else { 0 } }
|
||||
]
|
||||
);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
pub async fn delete_invite_code(&self, id: usize, user: &User) -> Result<()> {
|
||||
if !user.permissions.check(FinePermission::MANAGE_USERS) {
|
||||
return Err(Error::NotAllowed);
|
||||
}
|
||||
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"DELETE FROM invite_codes WHERE id = $1",
|
||||
&[&(id as i64)]
|
||||
);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
self.0.1.remove(format!("atto.invite_code:{}", id)).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_invite_code_is_used(&self, id: usize, new_is_used: bool) -> Result<()> {
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"UPDATE invite_codes SET is_used = $1 WHERE id = $2",
|
||||
params![&{ if new_is_used { 1 } else { 0 } }, &(id as i64)]
|
||||
);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
self.0.1.remove(format!("atto.invite_code:{}", id)).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
189
crates/core/src/database/journals.rs
Normal file
189
crates/core/src/database/journals.rs
Normal file
|
@ -0,0 +1,189 @@
|
|||
use oiseau::{cache::Cache, query_row};
|
||||
use crate::{
|
||||
database::common::NAME_REGEX,
|
||||
model::{
|
||||
auth::User,
|
||||
journals::{Journal, JournalPrivacyPermission},
|
||||
permissions::FinePermission,
|
||||
Error, Result,
|
||||
},
|
||||
};
|
||||
use crate::{auto_method, DataManager};
|
||||
use oiseau::{PostgresRow, execute, get, query_rows, params};
|
||||
|
||||
impl DataManager {
|
||||
/// Get a [`Journal`] from an SQL row.
|
||||
pub(crate) fn get_journal_from_row(x: &PostgresRow) -> Journal {
|
||||
Journal {
|
||||
id: get!(x->0(i64)) as usize,
|
||||
created: get!(x->1(i64)) as usize,
|
||||
owner: get!(x->2(i64)) as usize,
|
||||
title: get!(x->3(String)),
|
||||
privacy: serde_json::from_str(&get!(x->4(String))).unwrap(),
|
||||
dirs: serde_json::from_str(&get!(x->5(String))).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
auto_method!(get_journal_by_id(usize as i64)@get_journal_from_row -> "SELECT * FROM journals WHERE id = $1" --name="journal" --returns=Journal --cache-key-tmpl="atto.journal:{}");
|
||||
|
||||
/// Get a journal by `owner` and `title`.
|
||||
pub async fn get_journal_by_owner_title(&self, owner: usize, title: &str) -> Result<Journal> {
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = query_row!(
|
||||
&conn,
|
||||
"SELECT * FROM journals WHERE owner = $1 AND title = $2",
|
||||
params![&(owner as i64), &title],
|
||||
|x| { Ok(Self::get_journal_from_row(x)) }
|
||||
);
|
||||
|
||||
if res.is_err() {
|
||||
return Err(Error::GeneralNotFound("journal".to_string()));
|
||||
}
|
||||
|
||||
Ok(res.unwrap())
|
||||
}
|
||||
|
||||
/// Get all journals by user.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `id` - the ID of the user to fetch journals for
|
||||
pub async fn get_journals_by_user(&self, id: usize) -> Result<Vec<Journal>> {
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = query_rows!(
|
||||
&conn,
|
||||
"SELECT * FROM journals WHERE owner = $1 ORDER BY title ASC",
|
||||
&[&(id as i64)],
|
||||
|x| { Self::get_journal_from_row(x) }
|
||||
);
|
||||
|
||||
if res.is_err() {
|
||||
return Err(Error::GeneralNotFound("journal".to_string()));
|
||||
}
|
||||
|
||||
Ok(res.unwrap())
|
||||
}
|
||||
|
||||
const MAXIMUM_FREE_JOURNALS: usize = 5;
|
||||
|
||||
/// Create a new journal in the database.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - a mock [`Journal`] object to insert
|
||||
pub async fn create_journal(&self, mut data: Journal) -> Result<Journal> {
|
||||
// check values
|
||||
if data.title.len() < 2 {
|
||||
return Err(Error::DataTooShort("title".to_string()));
|
||||
} else if data.title.len() > 32 {
|
||||
return Err(Error::DataTooLong("title".to_string()));
|
||||
}
|
||||
|
||||
data.title = data.title.replace(" ", "_").to_lowercase();
|
||||
|
||||
// check name
|
||||
let regex = regex::RegexBuilder::new(NAME_REGEX)
|
||||
.multi_line(true)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
if regex.captures(&data.title).is_some() {
|
||||
return Err(Error::MiscError(
|
||||
"This title contains invalid characters".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// make sure this title isn't already in use
|
||||
if self
|
||||
.get_journal_by_owner_title(data.owner, &data.title)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return Err(Error::TitleInUse);
|
||||
}
|
||||
|
||||
// check number of journals
|
||||
let owner = self.get_user_by_id(data.owner).await?;
|
||||
|
||||
if !owner.permissions.check(FinePermission::SUPPORTER) {
|
||||
let journals = self.get_journals_by_user(data.owner).await?;
|
||||
|
||||
if journals.len() >= Self::MAXIMUM_FREE_JOURNALS {
|
||||
return Err(Error::MiscError(
|
||||
"You already have the maximum number of journals you can have".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ...
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"INSERT INTO journals VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
params![
|
||||
&(data.id as i64),
|
||||
&(data.created as i64),
|
||||
&(data.owner as i64),
|
||||
&data.title,
|
||||
&serde_json::to_string(&data.privacy).unwrap(),
|
||||
&serde_json::to_string(&data.dirs).unwrap(),
|
||||
]
|
||||
);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
pub async fn delete_journal(&self, id: usize, user: &User) -> Result<()> {
|
||||
let journal = self.get_journal_by_id(id).await?;
|
||||
|
||||
// check user permission
|
||||
if user.id != journal.owner && !user.permissions.check(FinePermission::MANAGE_JOURNALS) {
|
||||
return Err(Error::NotAllowed);
|
||||
}
|
||||
|
||||
// ...
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = execute!(&conn, "DELETE FROM journals WHERE id = $1", &[&(id as i64)]);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
// delete notes
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"DELETE FROM notes WHERE journal = $1",
|
||||
&[&(id as i64)]
|
||||
);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
// ...
|
||||
self.0.1.remove(format!("atto.journal:{}", id)).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
auto_method!(update_journal_title(&str)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.journal:{}");
|
||||
auto_method!(update_journal_privacy(JournalPrivacyPermission)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.journal:{}");
|
||||
auto_method!(update_journal_dirs(Vec<(usize, usize, String)>)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET dirs = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.journal:{}");
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
use oiseau::cache::Cache;
|
||||
use crate::model::auth::AchievementName;
|
||||
use crate::model::communities::Community;
|
||||
use crate::model::requests::{ActionRequest, ActionType};
|
||||
use crate::model::{
|
||||
|
@ -169,7 +170,11 @@ impl DataManager {
|
|||
/// # Arguments
|
||||
/// * `data` - a mock [`CommunityMembership`] object to insert
|
||||
#[async_recursion::async_recursion]
|
||||
pub async fn create_membership(&self, data: CommunityMembership) -> Result<String> {
|
||||
pub async fn create_membership(
|
||||
&self,
|
||||
data: CommunityMembership,
|
||||
user: &User,
|
||||
) -> Result<String> {
|
||||
// make sure membership doesn't already exist
|
||||
if self
|
||||
.get_membership_by_owner_community_no_void(data.owner, data.community)
|
||||
|
@ -199,7 +204,7 @@ impl DataManager {
|
|||
.await?;
|
||||
|
||||
// ...
|
||||
return self.create_membership(data).await;
|
||||
return self.create_membership(data, user).await;
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
|
@ -237,6 +242,9 @@ impl DataManager {
|
|||
Ok(if data.role.check(CommunityPermission::REQUESTED) {
|
||||
"Join request sent".to_string()
|
||||
} else {
|
||||
self.add_achievement(&mut user.clone(), AchievementName::JoinCommunity.into())
|
||||
.await?;
|
||||
|
||||
"Community joined".to_string()
|
||||
})
|
||||
}
|
||||
|
|
183
crates/core/src/database/message_reactions.rs
Normal file
183
crates/core/src/database/message_reactions.rs
Normal file
|
@ -0,0 +1,183 @@
|
|||
use oiseau::{cache::Cache, query_rows};
|
||||
use crate::model::{
|
||||
Error, Result,
|
||||
auth::{Notification, User},
|
||||
permissions::FinePermission,
|
||||
channels::MessageReaction,
|
||||
};
|
||||
use crate::{auto_method, DataManager};
|
||||
|
||||
use oiseau::{PostgresRow, execute, get, query_row, params};
|
||||
|
||||
impl DataManager {
|
||||
/// Get a [`MessageReaction`] from an SQL row.
|
||||
pub(crate) fn get_message_reaction_from_row(x: &PostgresRow) -> MessageReaction {
|
||||
MessageReaction {
|
||||
id: get!(x->0(i64)) as usize,
|
||||
created: get!(x->1(i64)) as usize,
|
||||
owner: get!(x->2(i64)) as usize,
|
||||
message: get!(x->3(i64)) as usize,
|
||||
emoji: get!(x->4(String)),
|
||||
}
|
||||
}
|
||||
|
||||
auto_method!(get_message_reaction_by_id()@get_message_reaction_from_row -> "SELECT * FROM message_reactions WHERE id = $1" --name="message_reaction" --returns=MessageReaction --cache-key-tmpl="atto.message_reaction:{}");
|
||||
|
||||
/// Get message_reactions by `owner` and `message`.
|
||||
pub async fn get_message_reactions_by_owner_message(
|
||||
&self,
|
||||
owner: usize,
|
||||
message: usize,
|
||||
) -> Result<Vec<MessageReaction>> {
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = query_rows!(
|
||||
&conn,
|
||||
"SELECT * FROM message_reactions WHERE owner = $1 AND message = $2",
|
||||
&[&(owner as i64), &(message as i64)],
|
||||
|x| { Self::get_message_reaction_from_row(x) }
|
||||
);
|
||||
|
||||
if res.is_err() {
|
||||
return Err(Error::GeneralNotFound("message_reaction".to_string()));
|
||||
}
|
||||
|
||||
Ok(res.unwrap())
|
||||
}
|
||||
|
||||
/// Get a message_reaction by `owner`, `message`, and `emoji`.
|
||||
pub async fn get_message_reaction_by_owner_message_emoji(
|
||||
&self,
|
||||
owner: usize,
|
||||
message: usize,
|
||||
emoji: &str,
|
||||
) -> Result<MessageReaction> {
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = query_row!(
|
||||
&conn,
|
||||
"SELECT * FROM message_reactions WHERE owner = $1 AND message = $2 AND emoji = $3",
|
||||
params![&(owner as i64), &(message as i64), &emoji],
|
||||
|x| { Ok(Self::get_message_reaction_from_row(x)) }
|
||||
);
|
||||
|
||||
if res.is_err() {
|
||||
return Err(Error::GeneralNotFound("message_reaction".to_string()));
|
||||
}
|
||||
|
||||
Ok(res.unwrap())
|
||||
}
|
||||
|
||||
/// Create a new message_reaction in the database.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - a mock [`MessageReaction`] object to insert
|
||||
pub async fn create_message_reaction(&self, data: MessageReaction, user: &User) -> Result<()> {
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let mut message = self.get_message_by_id(data.message).await?;
|
||||
let channel = self.get_channel_by_id(message.channel).await?;
|
||||
|
||||
// ...
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"INSERT INTO message_reactions VALUES ($1, $2, $3, $4, $5)",
|
||||
params![
|
||||
&(data.id as i64),
|
||||
&(data.created as i64),
|
||||
&(data.owner as i64),
|
||||
&(data.message as i64),
|
||||
&data.emoji
|
||||
]
|
||||
);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
// incr corresponding
|
||||
if let Some(x) = message.reactions.get(&data.emoji) {
|
||||
message.reactions.insert(data.emoji.clone(), x + 1);
|
||||
} else {
|
||||
message.reactions.insert(data.emoji.clone(), 1);
|
||||
}
|
||||
|
||||
self.update_message_reactions(message.id, message.reactions)
|
||||
.await?;
|
||||
|
||||
// send notif
|
||||
if message.owner != user.id {
|
||||
self
|
||||
.create_notification(Notification::new(
|
||||
"Your message has received a reaction!".to_string(),
|
||||
format!(
|
||||
"[@{}](/api/v1/auth/user/find/{}) has reacted \"{}\" to your [message](/chats/{}/{}?message={})!",
|
||||
user.username, user.id, data.emoji, channel.community, channel.id, message.id
|
||||
),
|
||||
message.owner,
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
|
||||
// return
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_message_reaction(&self, id: usize, user: &User) -> Result<()> {
|
||||
let message_reaction = self.get_message_reaction_by_id(id).await?;
|
||||
|
||||
if user.id != message_reaction.owner
|
||||
&& !user.permissions.check(FinePermission::MANAGE_REACTIONS)
|
||||
{
|
||||
return Err(Error::NotAllowed);
|
||||
}
|
||||
|
||||
let mut message = self.get_message_by_id(message_reaction.message).await?;
|
||||
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"DELETE FROM message_reactions WHERE id = $1",
|
||||
&[&(id as i64)]
|
||||
);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
self.0
|
||||
.1
|
||||
.remove(format!("atto.message_reaction:{}", id))
|
||||
.await;
|
||||
|
||||
// decr message reaction count
|
||||
if let Some(x) = message.reactions.get(&message_reaction.emoji) {
|
||||
if *x == 1 {
|
||||
// there are no 0 of this reaction
|
||||
message.reactions.remove(&message_reaction.emoji);
|
||||
} else {
|
||||
// decr 1
|
||||
message.reactions.insert(message_reaction.emoji, x - 1);
|
||||
}
|
||||
}
|
||||
|
||||
self.update_message_reactions(message.id, message.reactions)
|
||||
.await?;
|
||||
|
||||
// return
|
||||
Ok(())
|
||||
}
|
||||
}
|
|
@ -31,6 +31,7 @@ impl DataManager {
|
|||
edited: get!(x->4(i64)) as usize,
|
||||
content: get!(x->5(String)),
|
||||
context: serde_json::from_str(&get!(x->6(String))).unwrap(),
|
||||
reactions: serde_json::from_str(&get!(x->7(String))).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -218,7 +219,7 @@ impl DataManager {
|
|||
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"INSERT INTO messages VALUES ($1, $2, $3, $4, $5, $6, $7)",
|
||||
"INSERT INTO messages VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
|
||||
params![
|
||||
&(data.id as i64),
|
||||
&(data.channel as i64),
|
||||
|
@ -226,7 +227,8 @@ impl DataManager {
|
|||
&(data.created as i64),
|
||||
&(data.edited as i64),
|
||||
&data.content,
|
||||
&serde_json::to_string(&data.context).unwrap()
|
||||
&serde_json::to_string(&data.context).unwrap(),
|
||||
&serde_json::to_string(&data.reactions).unwrap(),
|
||||
]
|
||||
);
|
||||
|
||||
|
@ -254,6 +256,10 @@ impl DataManager {
|
|||
return Err(Error::MiscError(e.to_string()));
|
||||
}
|
||||
|
||||
// update channel position
|
||||
self.update_channel_last_message(channel.id, unix_epoch_timestamp() as i64)
|
||||
.await?;
|
||||
|
||||
// ...
|
||||
Ok(())
|
||||
}
|
||||
|
@ -353,4 +359,6 @@ impl DataManager {
|
|||
// return
|
||||
Ok(())
|
||||
}
|
||||
|
||||
auto_method!(update_message_reactions(HashMap<String, usize>) -> "UPDATE messages SET reactions = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.message:{}");
|
||||
}
|
||||
|
|
|
@ -8,10 +8,14 @@ pub mod connections;
|
|||
mod drafts;
|
||||
mod drivers;
|
||||
mod emojis;
|
||||
mod invite_codes;
|
||||
mod ipbans;
|
||||
mod ipblocks;
|
||||
mod journals;
|
||||
mod memberships;
|
||||
mod message_reactions;
|
||||
mod messages;
|
||||
mod notes;
|
||||
mod notifications;
|
||||
mod polls;
|
||||
mod pollvotes;
|
||||
|
@ -28,3 +32,4 @@ mod userblocks;
|
|||
mod userfollows;
|
||||
|
||||
pub use drivers::DataManager;
|
||||
pub use common::NAME_REGEX;
|
||||
|
|
298
crates/core/src/database/notes.rs
Normal file
298
crates/core/src/database/notes.rs
Normal file
|
@ -0,0 +1,298 @@
|
|||
use oiseau::cache::Cache;
|
||||
use crate::database::common::NAME_REGEX;
|
||||
use crate::model::{auth::User, journals::Note, permissions::FinePermission, Error, Result};
|
||||
use crate::{auto_method, DataManager};
|
||||
use oiseau::{execute, get, params, query_row, query_rows, PostgresRow};
|
||||
|
||||
impl DataManager {
|
||||
/// Get a [`Note`] from an SQL row.
|
||||
pub(crate) fn get_note_from_row(x: &PostgresRow) -> Note {
|
||||
Note {
|
||||
id: get!(x->0(i64)) as usize,
|
||||
created: get!(x->1(i64)) as usize,
|
||||
owner: get!(x->2(i64)) as usize,
|
||||
title: get!(x->3(String)),
|
||||
journal: get!(x->4(i64)) as usize,
|
||||
content: get!(x->5(String)),
|
||||
edited: get!(x->6(i64)) as usize,
|
||||
dir: get!(x->7(i64)) as usize,
|
||||
tags: serde_json::from_str(&get!(x->8(String))).unwrap(),
|
||||
is_global: get!(x->9(i32)) as i8 == 1,
|
||||
}
|
||||
}
|
||||
|
||||
auto_method!(get_note_by_id(usize as i64)@get_note_from_row -> "SELECT * FROM notes WHERE id = $1" --name="note" --returns=Note --cache-key-tmpl="atto.note:{}");
|
||||
auto_method!(get_global_note_by_title(&str)@get_note_from_row -> "SELECT * FROM notes WHERE title = $1 AND is_global = 1" --name="note" --returns=Note --cache-key-tmpl="atto.note:{}");
|
||||
|
||||
/// Get the number of global notes a user has.
|
||||
pub async fn get_user_global_notes_count(&self, owner: usize) -> Result<i32> {
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = query_row!(
|
||||
&conn,
|
||||
"SELECT COUNT(*)::int FROM notes WHERE owner = $1 AND is_global = 1",
|
||||
&[&(owner as i64)],
|
||||
|x| Ok(x.get::<usize, i32>(0))
|
||||
);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
Ok(res.unwrap())
|
||||
}
|
||||
|
||||
/// Get a note by `journal` and `title`.
|
||||
pub async fn get_note_by_journal_title(&self, journal: usize, title: &str) -> Result<Note> {
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = query_row!(
|
||||
&conn,
|
||||
"SELECT * FROM notes WHERE journal = $1 AND title = $2",
|
||||
params![&(journal as i64), &title],
|
||||
|x| { Ok(Self::get_note_from_row(x)) }
|
||||
);
|
||||
|
||||
if res.is_err() {
|
||||
return Err(Error::GeneralNotFound("note".to_string()));
|
||||
}
|
||||
|
||||
Ok(res.unwrap())
|
||||
}
|
||||
|
||||
/// Get all notes by journal.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `id` - the ID of the journal to fetch notes for
|
||||
pub async fn get_notes_by_journal(&self, id: usize) -> Result<Vec<Note>> {
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = query_rows!(
|
||||
&conn,
|
||||
"SELECT * FROM notes WHERE journal = $1 ORDER BY edited DESC",
|
||||
&[&(id as i64)],
|
||||
|x| { Self::get_note_from_row(x) }
|
||||
);
|
||||
|
||||
if res.is_err() {
|
||||
return Err(Error::GeneralNotFound("note".to_string()));
|
||||
}
|
||||
|
||||
Ok(res.unwrap())
|
||||
}
|
||||
|
||||
/// Get all notes by journal with the given tag.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `id` - the ID of the journal to fetch notes for
|
||||
/// * `tag`
|
||||
pub async fn get_notes_by_journal_tag(&self, id: usize, tag: &str) -> Result<Vec<Note>> {
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = query_rows!(
|
||||
&conn,
|
||||
"SELECT * FROM notes WHERE journal = $1 AND tags::jsonb ? $2 ORDER BY edited DESC",
|
||||
params![&(id as i64), tag],
|
||||
|x| { Self::get_note_from_row(x) }
|
||||
);
|
||||
|
||||
if res.is_err() {
|
||||
return Err(Error::GeneralNotFound("note".to_string()));
|
||||
}
|
||||
|
||||
Ok(res.unwrap())
|
||||
}
|
||||
|
||||
const MAXIMUM_FREE_NOTES_PER_JOURNAL: usize = 10;
|
||||
|
||||
pub const MAXIMUM_FREE_GLOBAL_NOTES: usize = 10;
|
||||
pub const MAXIMUM_SUPPORTER_GLOBAL_NOTES: usize = 50;
|
||||
|
||||
/// Create a new note in the database.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - a mock [`Note`] object to insert
|
||||
pub async fn create_note(&self, mut data: Note) -> Result<Note> {
|
||||
// check values
|
||||
if data.title.len() < 2 {
|
||||
return Err(Error::DataTooShort("title".to_string()));
|
||||
} else if data.title.len() > 64 {
|
||||
return Err(Error::DataTooLong("title".to_string()));
|
||||
}
|
||||
|
||||
if data.content.len() < 2 {
|
||||
return Err(Error::DataTooShort("content".to_string()));
|
||||
} else if data.content.len() > 262144 {
|
||||
return Err(Error::DataTooLong("content".to_string()));
|
||||
}
|
||||
|
||||
data.title = data.title.replace(" ", "_").to_lowercase();
|
||||
|
||||
// check number of notes
|
||||
let owner = self.get_user_by_id(data.owner).await?;
|
||||
|
||||
if !owner.permissions.check(FinePermission::SUPPORTER) {
|
||||
let journals = self.get_notes_by_journal(data.owner).await?;
|
||||
|
||||
if journals.len() >= Self::MAXIMUM_FREE_NOTES_PER_JOURNAL {
|
||||
return Err(Error::MiscError(
|
||||
"You already have the maximum number of notes you can have in this journal"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// check name
|
||||
let regex = regex::RegexBuilder::new(NAME_REGEX)
|
||||
.multi_line(true)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
if regex.captures(&data.title).is_some() {
|
||||
return Err(Error::MiscError(
|
||||
"This title contains invalid characters".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// make sure this title isn't already in use
|
||||
if self
|
||||
.get_note_by_journal_title(data.journal, &data.title)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return Err(Error::TitleInUse);
|
||||
}
|
||||
|
||||
// check permission
|
||||
let journal = self.get_journal_by_id(data.journal).await?;
|
||||
|
||||
if data.owner != journal.owner {
|
||||
return Err(Error::NotAllowed);
|
||||
}
|
||||
|
||||
// ...
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"INSERT INTO notes VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
|
||||
params![
|
||||
&(data.id as i64),
|
||||
&(data.created as i64),
|
||||
&(data.owner as i64),
|
||||
&data.title,
|
||||
&(data.journal as i64),
|
||||
&data.content,
|
||||
&(data.edited as i64),
|
||||
&(data.dir as i64),
|
||||
&serde_json::to_string(&data.tags).unwrap(),
|
||||
&if data.is_global { 1 } else { 0 }
|
||||
]
|
||||
);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
pub async fn delete_note(&self, id: usize, user: &User) -> Result<()> {
|
||||
let note = self.get_note_by_id(id).await?;
|
||||
|
||||
// check user permission
|
||||
if user.id != note.owner && !user.permissions.check(FinePermission::MANAGE_NOTES) {
|
||||
return Err(Error::NotAllowed);
|
||||
}
|
||||
|
||||
// ...
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = execute!(&conn, "DELETE FROM notes WHERE id = $1", &[&(id as i64)]);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
// ...
|
||||
self.cache_clear_note(¬e).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete all notes by dir ID.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `journal`
|
||||
/// * `dir`
|
||||
pub async fn delete_notes_by_journal_dir(
|
||||
&self,
|
||||
journal: usize,
|
||||
dir: usize,
|
||||
user: &User,
|
||||
) -> Result<()> {
|
||||
let journal = self.get_journal_by_id(journal).await?;
|
||||
|
||||
if journal.owner != user.id && !user.permissions.check(FinePermission::MANAGE_NOTES) {
|
||||
return Err(Error::NotAllowed);
|
||||
}
|
||||
|
||||
// ...
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"DELETE FROM notes WHERE dir = $1 AND journal = $2 ORDER BY edited DESC",
|
||||
&[&(dir as i64), &(journal.id as i64)]
|
||||
);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Incremenet note views. Views are only stored in the cache.
|
||||
///
|
||||
/// This should only be done for global notes.
|
||||
pub async fn incr_note_views(&self, id: usize) {
|
||||
self.0.1.incr(format!("atto.note:{id}/views")).await;
|
||||
}
|
||||
|
||||
pub async fn get_note_views(&self, id: usize) -> Option<String> {
|
||||
self.0.1.get(format!("atto.note:{id}/views")).await
|
||||
}
|
||||
|
||||
pub async fn cache_clear_note(&self, x: &Note) {
|
||||
self.0.1.remove(format!("atto.note:{}", x.id)).await;
|
||||
self.0.1.remove(format!("atto.note:{}", x.title)).await;
|
||||
}
|
||||
|
||||
auto_method!(update_note_title(&str)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET title = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note);
|
||||
auto_method!(update_note_content(&str)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET content = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note);
|
||||
auto_method!(update_note_dir(i64)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET dir = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note);
|
||||
auto_method!(update_note_tags(Vec<String>)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET tags = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_note);
|
||||
auto_method!(update_note_edited(i64)@get_note_by_id -> "UPDATE notes SET edited = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note);
|
||||
auto_method!(update_note_is_global(i32)@get_note_by_id -> "UPDATE notes SET is_global = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note);
|
||||
}
|
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