Compare commits
74 commits
Author | SHA1 | Date | |
---|---|---|---|
3fc0872867 | |||
c4de17058b | |||
07a23f505b | |||
9ba6320d46 | |||
e5b6b5a4d4 | |||
1dc0611298 | |||
2ec8d86edf | |||
0aa2ea362f | |||
ee2f7c7cbb | |||
b493b2ade8 | |||
c83d0a9fc0 | |||
0634819278 | |||
973373426a | |||
d90b08720a | |||
d6348f7d67 | |||
f5faed7762 | |||
14936b8b90 | |||
b501a7c5f0 | |||
50f4592de2 | |||
0272985b81 | |||
0163391380 | |||
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 |
151 changed files with 9590 additions and 974 deletions
Cargo.lockREADME.md
crates
app
Cargo.toml
src
assets.rsassets.rsmod.rs
langs
macros.rsmain.rspublic
css
html
auth
body.lispchats
communities
components.lispdeveloper
journals
littleweb
macros.lispmisc
mod
post
profile
base.lispblocked.lispmedia.lispoutbox.lispposts.lispprivate.lispreplies.lispresponses.lispsettings.lisp
root.lispstacks
timelines
js
routes
api/v1
auth
channels
communities
domains.rsjournals.rslayouts.rsmod.rsnotes.rsreactions.rsservices.rsstacks.rsuploads.rspages
core
54
Cargo.lock
generated
54
Cargo.lock
generated
|
@ -488,7 +488,7 @@ checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb"
|
|||
dependencies = [
|
||||
"chrono",
|
||||
"chrono-tz-build",
|
||||
"phf",
|
||||
"phf 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -498,7 +498,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1"
|
||||
dependencies = [
|
||||
"parse-zoneinfo",
|
||||
"phf",
|
||||
"phf 0.11.3",
|
||||
"phf_codegen",
|
||||
]
|
||||
|
||||
|
@ -648,7 +648,7 @@ dependencies = [
|
|||
"cssparser-macros",
|
||||
"dtoa-short",
|
||||
"itoa",
|
||||
"phf",
|
||||
"phf 0.11.3",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
|
@ -728,11 +728,11 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
|||
|
||||
[[package]]
|
||||
name = "emojis"
|
||||
version = "0.6.4"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "99e1f1df1f181f2539bac8bf027d31ca5ffbf9e559e3f2d09413b9107b5c02f4"
|
||||
checksum = "0a08afd8e599463c275703532e707c767b8c068a826eea9ca8fceaf3435029df"
|
||||
dependencies = [
|
||||
"phf",
|
||||
"phf 0.12.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -2164,7 +2164,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||
dependencies = [
|
||||
"phf_macros",
|
||||
"phf_shared",
|
||||
"phf_shared 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
|
||||
dependencies = [
|
||||
"phf_shared 0.12.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -2174,7 +2183,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"phf_shared 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -2183,7 +2192,7 @@ version = "0.11.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"phf_shared 0.11.3",
|
||||
"rand 0.8.5",
|
||||
]
|
||||
|
||||
|
@ -2194,7 +2203,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"phf_shared 0.11.3",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
|
@ -2209,6 +2218,15 @@ dependencies = [
|
|||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.16"
|
||||
|
@ -3067,7 +3085,7 @@ checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
|
|||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"parking_lot",
|
||||
"phf_shared",
|
||||
"phf_shared 0.11.3",
|
||||
"precomputed-hash",
|
||||
"serde",
|
||||
]
|
||||
|
@ -3079,7 +3097,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"phf_shared 0.11.3",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
@ -3231,7 +3249,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tetratto"
|
||||
version = "9.0.0"
|
||||
version = "10.0.0"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"async-stripe",
|
||||
|
@ -3262,7 +3280,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tetratto-core"
|
||||
version = "9.0.0"
|
||||
version = "10.0.0"
|
||||
dependencies = [
|
||||
"async-recursion",
|
||||
"base16ct",
|
||||
|
@ -3284,7 +3302,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tetratto-l10n"
|
||||
version = "9.0.0"
|
||||
version = "10.0.0"
|
||||
dependencies = [
|
||||
"pathbufd",
|
||||
"serde",
|
||||
|
@ -3293,7 +3311,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tetratto-shared"
|
||||
version = "9.0.0"
|
||||
version = "10.0.0"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"chrono",
|
||||
|
@ -3476,7 +3494,7 @@ dependencies = [
|
|||
"log",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"phf",
|
||||
"phf 0.11.3",
|
||||
"pin-project-lite",
|
||||
"postgres-protocol",
|
||||
"postgres-types",
|
||||
|
@ -4066,7 +4084,7 @@ version = "0.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "954c5a41f2bcb7314344079d0891505458cc2f4b422bdea1d5bfbe6d1a04903b"
|
||||
dependencies = [
|
||||
"phf",
|
||||
"phf 0.11.3",
|
||||
"phf_codegen",
|
||||
"string_cache",
|
||||
"string_cache_codegen",
|
||||
|
|
|
@ -33,6 +33,10 @@ 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.
|
||||
|
||||
You can launch with the `LITTLEWEB=true` environment variable to start the littleweb viewer/fake DNS server. This should be used in combination with `PORT`, as well as set as the `lw_host` in your configuration file. This secondary server is required to allow users to view their littleweb projects.
|
||||
|
||||
## 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 = "9.0.0"
|
||||
version = "10.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
@ -33,6 +33,6 @@ async-stripe = { version = "0.41.0", features = [
|
|||
"billing",
|
||||
"runtime-tokio-hyper",
|
||||
] }
|
||||
emojis = "0.6.4"
|
||||
emojis = "0.7.0"
|
||||
webp = "0.3.0"
|
||||
bberry = "0.2.0"
|
||||
|
|
|
@ -39,6 +39,8 @@ 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");
|
||||
pub const LAYOUT_EDITOR_JS: &str = include_str!("./public/js/layout_editor.js");
|
||||
|
||||
// html
|
||||
pub const BODY: &str = include_str!("./public/html/body.lisp");
|
||||
|
@ -50,6 +52,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");
|
||||
|
@ -68,6 +71,7 @@ pub const PROFILE_BANNED: &str = include_str!("./public/html/profile/banned.lisp
|
|||
pub const PROFILE_REPLIES: &str = include_str!("./public/html/profile/replies.lisp");
|
||||
pub const PROFILE_MEDIA: &str = include_str!("./public/html/profile/media.lisp");
|
||||
pub const PROFILE_OUTBOX: &str = include_str!("./public/html/profile/outbox.lisp");
|
||||
pub const PROFILE_RESPONSES: &str = include_str!("./public/html/profile/responses.lisp");
|
||||
|
||||
pub const COMMUNITIES_LIST: &str = include_str!("./public/html/communities/list.lisp");
|
||||
pub const COMMUNITIES_BASE: &str = include_str!("./public/html/communities/base.lisp");
|
||||
|
@ -116,6 +120,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");
|
||||
|
@ -153,7 +158,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());
|
||||
|
@ -178,7 +183,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();
|
||||
}};
|
||||
}
|
||||
|
@ -346,6 +352,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);
|
||||
|
@ -364,6 +371,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
|
|||
write_template!(html_path->"profile/replies.html"(crate::assets::PROFILE_REPLIES) --config=config --lisp plugins);
|
||||
write_template!(html_path->"profile/media.html"(crate::assets::PROFILE_MEDIA) --config=config --lisp plugins);
|
||||
write_template!(html_path->"profile/outbox.html"(crate::assets::PROFILE_OUTBOX) --config=config --lisp plugins);
|
||||
write_template!(html_path->"profile/responses.html"(crate::assets::PROFILE_RESPONSES) --config=config --lisp plugins);
|
||||
|
||||
write_template!(html_path->"communities/list.html"(crate::assets::COMMUNITIES_LIST) -d "communities" --config=config --lisp plugins);
|
||||
write_template!(html_path->"communities/base.html"(crate::assets::COMMUNITIES_BASE) --config=config --lisp plugins);
|
||||
|
@ -407,6 +415,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);
|
||||
|
@ -494,6 +503,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
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ version = "1.0.0"
|
|||
"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"
|
||||
|
@ -42,6 +43,8 @@ version = "1.0.0"
|
|||
"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.send_anonymously" = "Send anonymously"
|
||||
"general:label.must_activate_account" = "You need to activate your account!"
|
||||
|
||||
"general:label.supporter_motivation" = "Become a supporter!"
|
||||
"general:action.become_supporter" = "Become supporter"
|
||||
|
@ -73,6 +76,7 @@ version = "1.0.0"
|
|||
"auth:label.recent_replies" = "Recent replies"
|
||||
"auth:label.recent_posts_with_media" = "Recent posts (with media)"
|
||||
"auth:label.posts" = "Posts"
|
||||
"auth:label.responses" = "Answers"
|
||||
"auth:label.replies" = "Replies"
|
||||
"auth:label.media" = "Media"
|
||||
"auth:label.outbox" = "Outbox"
|
||||
|
@ -86,6 +90,9 @@ version = "1.0.0"
|
|||
"auth:action.message" = "Message"
|
||||
"auth:label.banned" = "Banned"
|
||||
"auth:label.banned_message" = "This user has been banned for breaking the site's rules."
|
||||
"auth:action.create_account" = "Create account"
|
||||
"auth:action.purchase_account" = "Purchase account"
|
||||
"auth:action.continue" = "Continue"
|
||||
|
||||
"communities:action.create" = "Create"
|
||||
"communities:action.select" = "Select"
|
||||
|
@ -135,6 +142,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"
|
||||
|
@ -166,10 +175,14 @@ version = "1.0.0"
|
|||
"settings:label.export" = "Export"
|
||||
"settings:label.manage_blocks" = "Manage blocks"
|
||||
"settings:label.users" = "Users"
|
||||
"settings:label.ips" = "IPs"
|
||||
"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"
|
||||
|
@ -177,6 +190,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"
|
||||
|
@ -242,3 +258,15 @@ version = "1.0.0"
|
|||
"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"
|
||||
|
||||
"littleweb:label.create_new" = "Create new site"
|
||||
"littleweb:label.my_services" = "My services"
|
||||
|
|
|
@ -166,7 +166,7 @@ macro_rules! user_banned {
|
|||
let mut context = initial_context(&$data.0.0.0, lang, &$user).await;
|
||||
context.insert("profile", &$other_user);
|
||||
|
||||
return Ok(Html(
|
||||
return Err(Html(
|
||||
$data.1.render("profile/banned.html", &context).unwrap(),
|
||||
));
|
||||
};
|
||||
|
@ -233,7 +233,7 @@ macro_rules! check_user_blocked_or_private {
|
|||
.is_ok(),
|
||||
);
|
||||
|
||||
return Ok(Html(
|
||||
return Err(Html(
|
||||
$data.1.render("profile/blocked.html", &context).unwrap(),
|
||||
));
|
||||
}
|
||||
|
@ -281,7 +281,7 @@ macro_rules! check_user_blocked_or_private {
|
|||
.is_ok(),
|
||||
);
|
||||
|
||||
return Ok(Html(
|
||||
return Err(Html(
|
||||
$data.1.render("profile/private.html", &context).unwrap(),
|
||||
));
|
||||
}
|
||||
|
@ -293,7 +293,7 @@ macro_rules! check_user_blocked_or_private {
|
|||
context.insert("follow_requested", &false);
|
||||
context.insert("is_following", &false);
|
||||
|
||||
return Ok(Html(
|
||||
return Err(Html(
|
||||
$data.1.render("profile/private.html", &context).unwrap(),
|
||||
));
|
||||
}
|
||||
|
@ -352,7 +352,14 @@ macro_rules! ignore_users_gen {
|
|||
($user:ident, $data:ident) => {
|
||||
if let Some(ref ua) = $user {
|
||||
[
|
||||
$data.0.get_userblocks_receivers(ua.id).await,
|
||||
$data
|
||||
.0
|
||||
.get_userblocks_receivers(
|
||||
ua.id,
|
||||
&ua.associated,
|
||||
ua.settings.hide_associated_blocked_users,
|
||||
)
|
||||
.await,
|
||||
$data.0.get_userblocks_initiator_by_receivers(ua.id).await,
|
||||
$data.0.get_user_stack_blocked_users(ua.id).await,
|
||||
]
|
||||
|
@ -364,7 +371,14 @@ macro_rules! ignore_users_gen {
|
|||
|
||||
($user:ident!, $data:ident) => {{
|
||||
[
|
||||
$data.0.get_userblocks_receivers($user.id).await,
|
||||
$data
|
||||
.0
|
||||
.get_userblocks_receivers(
|
||||
$user.id,
|
||||
&$user.associated,
|
||||
$user.settings.hide_associated_blocked_users,
|
||||
)
|
||||
.await,
|
||||
$data
|
||||
.0
|
||||
.get_userblocks_initiator_by_receivers($user.id)
|
||||
|
@ -376,7 +390,13 @@ macro_rules! ignore_users_gen {
|
|||
|
||||
($user:ident!, #$data:ident) => {
|
||||
[
|
||||
$data.get_userblocks_receivers($user.id).await,
|
||||
$data
|
||||
.get_userblocks_receivers(
|
||||
$user.id,
|
||||
&$user.associated,
|
||||
$user.settings.hide_associated_blocked_users,
|
||||
)
|
||||
.await,
|
||||
$data.get_userblocks_initiator_by_receivers($user.id).await,
|
||||
]
|
||||
.concat()
|
||||
|
|
|
@ -38,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())
|
||||
}
|
||||
|
@ -78,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;
|
||||
|
@ -102,11 +110,25 @@ 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();
|
||||
let mut app = Router::new();
|
||||
|
||||
let app = Router::new()
|
||||
.merge(routes::routes(&config))
|
||||
// add correct routes
|
||||
if var("LITTLEWEB").is_ok() {
|
||||
app = app.merge(routes::lw_routes());
|
||||
} else {
|
||||
app = app
|
||||
.merge(routes::routes(&config))
|
||||
.layer(SetResponseHeaderLayer::if_not_present(
|
||||
HeaderName::from_static("content-security-policy"),
|
||||
HeaderValue::from_static("default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' *.cloudflare.com; frame-ancestors 'self'"),
|
||||
));
|
||||
}
|
||||
|
||||
// add junk
|
||||
app = app
|
||||
.layer(Extension(Arc::new(RwLock::new((database, tera, client)))))
|
||||
.layer(axum::extract::DefaultBodyLimit::max(
|
||||
var("BODY_LIMIT")
|
||||
|
@ -119,12 +141,9 @@ 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))
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
|
@ -38,6 +38,10 @@
|
|||
--pad-2: 0.5rem;
|
||||
--pad-3: 0.75rem;
|
||||
--pad-4: 1rem;
|
||||
|
||||
--online: var(--color-green);
|
||||
--idle: var(--color-yellow);
|
||||
--offline: hsl(0, 0%, 50%);
|
||||
}
|
||||
|
||||
.dark,
|
||||
|
@ -218,7 +222,7 @@ pre {
|
|||
}
|
||||
|
||||
code {
|
||||
padding: var(--pad-1);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
pre,
|
||||
|
@ -263,7 +267,7 @@ span,
|
|||
code {
|
||||
max-width: 100%;
|
||||
overflow-wrap: normal;
|
||||
text-wrap: pretty;
|
||||
text-wrap: stable;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
|
@ -389,3 +393,11 @@ blockquote {
|
|||
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);
|
||||
}
|
||||
|
|
|
@ -413,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;
|
||||
|
@ -544,6 +582,7 @@ select:focus {
|
|||
font-size: 12px;
|
||||
border-radius: 6px;
|
||||
height: max-content;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notification.tr {
|
||||
|
@ -632,7 +671,7 @@ nav .button:not(.title):not(.active):hover {
|
|||
margin-bottom: 0;
|
||||
backdrop-filter: none;
|
||||
bottom: 0;
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
height: max-content;
|
||||
top: unset;
|
||||
}
|
||||
|
@ -1065,14 +1104,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;
|
||||
|
@ -1250,3 +1289,32 @@ details.accordion .inner {
|
|||
.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;
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
(text "{% extends \"root.html\" %} {% block body %}")
|
||||
(main
|
||||
("class" "flex flex-col gap-2")
|
||||
("style" "max-width: 25rem")
|
||||
("style" "max-width: 48ch")
|
||||
(h2
|
||||
("class" "w-full text-center")
|
||||
; block for title
|
||||
|
|
|
@ -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 }}\";
|
||||
|
|
|
@ -48,7 +48,8 @@
|
|||
("name" "totp")
|
||||
("id" "totp"))))
|
||||
(button
|
||||
(text "Submit")))
|
||||
(icon (text "arrow-right"))
|
||||
(str (text "auth:action.continue"))))
|
||||
|
||||
(script
|
||||
(text "let flow_page = 1;
|
||||
|
@ -90,7 +91,7 @@
|
|||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
.then(async (res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
|
@ -98,7 +99,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,35 @@
|
|||
("required" "")
|
||||
("name" "password")
|
||||
("id" "password")))
|
||||
(text "{% if config.security.enable_invite_codes -%}")
|
||||
(div
|
||||
("class" "flex flex-col gap-1")
|
||||
("oninput" "check_should_show_purchase(event)")
|
||||
(label
|
||||
("for" "invite_code")
|
||||
(b
|
||||
(text "Invite code (optional)")))
|
||||
(input
|
||||
("type" "text")
|
||||
("placeholder" "invite code")
|
||||
("name" "invite_code")
|
||||
("id" "invite_code")))
|
||||
|
||||
(script
|
||||
(text "function check_should_show_purchase(e) {
|
||||
if (e.target.value.length > 0) {
|
||||
document.querySelector('[ui_ident=purchase_account]').classList.add('hidden');
|
||||
document.querySelector('[ui_ident=create_account]').classList.remove('hidden');
|
||||
globalThis.DO_PURCHASE = false;
|
||||
} else {
|
||||
document.querySelector('[ui_ident=purchase_account]').classList.remove('hidden');
|
||||
document.querySelector('[ui_ident=create_account]').classList.add('hidden');
|
||||
globalThis.DO_PURCHASE = true;
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.DO_PURCHASE = true;"))
|
||||
(text "{%- endif %}")
|
||||
(hr)
|
||||
(div
|
||||
("class" "card-nest w-full")
|
||||
|
@ -56,7 +85,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")
|
||||
|
@ -70,8 +99,33 @@
|
|||
("class" "cf-turnstile")
|
||||
("data-sitekey" "{{ config.turnstile.site_key }}"))
|
||||
(hr)
|
||||
(text "{% if config.security.enable_invite_codes -%}")
|
||||
(div
|
||||
("class" "w-full flex gap-2 justify-between")
|
||||
("ui_ident" "purchase_account")
|
||||
|
||||
(button
|
||||
(icon (text "credit-card"))
|
||||
(str (text "auth:action.purchase_account")))
|
||||
|
||||
(button
|
||||
("class" "small square lowered")
|
||||
("type" "button")
|
||||
("onclick" "document.querySelector('[ui_ident=purchase_help]').classList.toggle('hidden')")
|
||||
(icon (text "circle-question-mark"))))
|
||||
|
||||
(div
|
||||
("class" "hidden lowered card w-full no_p_margin")
|
||||
("ui_ident" "purchase_help")
|
||||
(b (text "What does \"Purchase account\" mean?"))
|
||||
(p (text "Your account will be created, but you cannot use it until you activate it for {{ config.stripe.supporter_price_text }}."))
|
||||
(p (text "Alternatively, you can provide an invite code to create your account for free.")))
|
||||
(text "{%- endif %}")
|
||||
(button
|
||||
(text "Submit")))
|
||||
("class" "{% if config.security.enable_invite_codes -%} hidden {%- endif %}")
|
||||
("ui_ident" "create_account")
|
||||
(icon (text "plus"))
|
||||
(str (text "auth:action.create_account"))))
|
||||
|
||||
(script
|
||||
(text "async function register(e) {
|
||||
|
@ -89,10 +143,12 @@
|
|||
captcha_response: e.target.querySelector(
|
||||
\"[name=cf-turnstile-response]\",
|
||||
).value,
|
||||
invite_code: (e.target.invite_code || { value: \"\" }).value,
|
||||
purchase: globalThis.DO_PURCHASE,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
.then(async (res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
|
@ -100,7 +156,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,5 +1,17 @@
|
|||
(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")
|
||||
|
@ -18,10 +30,46 @@
|
|||
(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 +101,11 @@
|
|||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (globalThis.notifs_stream_init) {
|
||||
return;
|
||||
}
|
||||
|
||||
globalThis.notifs_stream_init = true;
|
||||
trigger(\"me::notifications_stream\");
|
||||
}, 250);
|
||||
});
|
||||
|
@ -250,6 +303,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 }}\";
|
||||
|
@ -265,21 +329,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;
|
||||
|
|
|
@ -109,6 +109,23 @@
|
|||
("title" "Send")
|
||||
(text "{{ icon \"send-horizontal\" }}"))))
|
||||
(text "{%- endif %}")
|
||||
|
||||
; 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;
|
||||
}
|
||||
|
||||
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]);
|
||||
});"))
|
||||
|
||||
; ...
|
||||
(script
|
||||
(text "window.CURRENT_PAGE = Number.parseInt(\"{{ page }}\");
|
||||
window.VIEWING_SINGLE = \"{{ message }}\".length > 0;
|
||||
|
@ -434,6 +451,7 @@
|
|||
const clean_text = () => {
|
||||
trigger(\"atto::clean_date_codes\");
|
||||
trigger(\"atto::hooks::online_indicator\");
|
||||
trigger(\"atto::hooks::check_message_reactions\");
|
||||
};
|
||||
|
||||
document.addEventListener(
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
|
|
|
@ -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 post_outer:{{ post.id }}")
|
||||
("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,9 +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 post:{{ post.id }} {% if secondary -%}secondary{%- endif %}")
|
||||
("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")
|
||||
|
@ -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,7 +235,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, is_repost=true) }} {% else %}")
|
||||
(div
|
||||
("class" "card lowered red flex items-center gap-2")
|
||||
(text "{{ icon \"frown\" }}")
|
||||
|
@ -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\" }}")
|
||||
|
@ -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 %}")
|
||||
|
@ -515,7 +528,7 @@
|
|||
("width" "24")
|
||||
("height" "24")
|
||||
("viewBox" "0 0 24 24")
|
||||
("style" "fill: var(--color-green)")
|
||||
("style" "fill: var(--online)")
|
||||
(circle
|
||||
("cx" "12")
|
||||
("cy" "12")
|
||||
|
@ -528,7 +541,7 @@
|
|||
("width" "24")
|
||||
("height" "24")
|
||||
("viewBox" "0 0 24 24")
|
||||
("style" "fill: var(--color-yellow)")
|
||||
("style" "fill: var(--idle)")
|
||||
(circle
|
||||
("cx" "12")
|
||||
("cy" "12")
|
||||
|
@ -541,7 +554,7 @@
|
|||
("width" "24")
|
||||
("height" "24")
|
||||
("viewBox" "0 0 24 24")
|
||||
("style" "fill: hsl(0, 0%, 50%)")
|
||||
("style" "fill: var(--offline)")
|
||||
(circle
|
||||
("cx" "12")
|
||||
("cy" "12")
|
||||
|
@ -598,7 +611,8 @@
|
|||
(text "{%- endif %}")
|
||||
(div
|
||||
("style" "display: none;")
|
||||
(text "{{ self::theme_color(color=user.settings.theme_color_surface, css=\"color-surface\") }} {{ self::theme_color(color=user.settings.theme_color_text, css=\"color-text\") }} {{ self::theme_color(color=user.settings.theme_color_text_link, css=\"color-link\") }} {{ self::theme_color(color=user.settings.theme_color_lowered, css=\"color-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_text_lowered, css=\"color-text-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_super_lowered, css=\"color-super-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_raised, css=\"color-raised\") }} {{ self::theme_color(color=user.settings.theme_color_text_raised, css=\"color-text-raised\") }} {{ self::theme_color(color=user.settings.theme_color_super_raised, css=\"color-super-raised\") }} {{ self::theme_color(color=user.settings.theme_color_primary, css=\"color-primary\") }} {{ self::theme_color(color=user.settings.theme_color_text_primary, css=\"color-text-primary\") }} {{ self::theme_color(color=user.settings.theme_color_primary_lowered, css=\"color-primary-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_secondary, css=\"color-secondary\") }} {{ self::theme_color(color=user.settings.theme_color_text_secondary, css=\"color-text-secondary\") }} {{ self::theme_color(color=user.settings.theme_color_secondary_lowered, css=\"color-secondary-lowered\") }} {% if user.permissions|has_supporter -%}")
|
||||
(text "{{ self::theme_color(color=user.settings.theme_color_surface, css=\"color-surface\") }} {{ self::theme_color(color=user.settings.theme_color_text, css=\"color-text\") }} {{ self::theme_color(color=user.settings.theme_color_text_link, css=\"color-link\") }} {{ self::theme_color(color=user.settings.theme_color_lowered, css=\"color-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_text_lowered, css=\"color-text-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_super_lowered, css=\"color-super-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_raised, css=\"color-raised\") }} {{ self::theme_color(color=user.settings.theme_color_text_raised, css=\"color-text-raised\") }} {{ self::theme_color(color=user.settings.theme_color_super_raised, css=\"color-super-raised\") }} {{ self::theme_color(color=user.settings.theme_color_primary, css=\"color-primary\") }} {{ self::theme_color(color=user.settings.theme_color_text_primary, css=\"color-text-primary\") }} {{ self::theme_color(color=user.settings.theme_color_primary_lowered, css=\"color-primary-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_secondary, css=\"color-secondary\") }} {{ self::theme_color(color=user.settings.theme_color_text_secondary, css=\"color-text-secondary\") }} {{ self::theme_color(color=user.settings.theme_color_secondary_lowered, css=\"color-secondary-lowered\") }}
|
||||
{{ self::theme_color(color=user.settings.theme_color_online, css=\"online\") }} {{ self::theme_color(color=user.settings.theme_color_idle, css=\"idle\") }} {{ self::theme_color(color=user.settings.theme_color_offline, css=\"offline\") }} {% if user.permissions|has_supporter -%}")
|
||||
(style
|
||||
(text "{{ user.settings.theme_custom_css|remove_script_tags|safe }}"))
|
||||
(text "{%- endif %}"))
|
||||
|
@ -612,8 +626,8 @@
|
|||
|
||||
(text "{%- endif %} {%- endmacro %} {% macro question(question, owner, show_community=true, secondary=false, profile=false) -%}")
|
||||
(div
|
||||
("class" "card {% if secondary -%}secondary{%- endif %} flex gap-2")
|
||||
(text "{% if owner.id == 0 -%}")
|
||||
("class" "card question {% if secondary -%}secondary{%- endif %} flex gap-2")
|
||||
(text "{% if owner.id == 0 or question.context.mask_owner -%}")
|
||||
(span
|
||||
(text "{% if profile and profile.settings.anonymous_avatar_url -%}")
|
||||
(img
|
||||
|
@ -622,7 +636,7 @@
|
|||
("class" "avatar shadow")
|
||||
("loading" "lazy")
|
||||
("style" "--size: 52px"))
|
||||
(text "{% else %} {{ self::avatar(username=owner.username, selector_type=\"username\", size=\"52px\") }} {%- endif %}"))
|
||||
(text "{% else %} {{ self::avatar(username=\"anonymous\", selector_type=\"username\", size=\"52px\") }} {%- endif %}"))
|
||||
(text "{% else %}")
|
||||
(a
|
||||
("href" "/@{{ owner.username }}")
|
||||
|
@ -634,7 +648,7 @@
|
|||
("class" "flex items-center gap-2 flex-wrap")
|
||||
(span
|
||||
("class" "name")
|
||||
(text "{% if owner.id == 0 -%} {% if profile and profile.settings.anonymous_username -%}")
|
||||
(text "{% if owner.id == 0 or question.context.mask_owner -%} {% if profile and profile.settings.anonymous_username -%}")
|
||||
(span
|
||||
("class" "flex items-center gap-2")
|
||||
(b
|
||||
|
@ -676,10 +690,13 @@
|
|||
(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 %}")
|
||||
(text "{% if is_helper and (owner.id == 0 or question.context.mask_owner) -%}")
|
||||
(details
|
||||
("class" "card tiny lowered w-full")
|
||||
(summary
|
||||
|
@ -688,12 +705,22 @@
|
|||
(span (text "View IP")))
|
||||
|
||||
(pre (code (text "{{ question.ip }}"))))
|
||||
(text "{% endif %}")
|
||||
|
||||
(text "{% if question.context.mask_owner -%}")
|
||||
(details
|
||||
("class" "card tiny lowered w-full")
|
||||
(summary
|
||||
("class" "w-full flex gap-2 flex-wrap items-center")
|
||||
(icon (text "venetian-mask"))
|
||||
(span (text "Unmask")))
|
||||
|
||||
(text "{{ self::full_username(user=owner) }}"))
|
||||
(text "{%- endif %} {%- endif %}")
|
||||
; ...
|
||||
(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, allow_anonymous=false) -%}")
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
|
@ -707,6 +734,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 +751,100 @@
|
|||
("required" "")
|
||||
("minlength" "2")
|
||||
("maxlength" "4096")))
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ text \"communities:action.create\" }}"))))
|
||||
(div
|
||||
("class" "flex w-full justify-between gap-2 flex-collapse")
|
||||
(div
|
||||
("class" "flex gap-2")
|
||||
(button
|
||||
("class" "primary")
|
||||
(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 "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 %}"))
|
||||
|
||||
(text "{% if not is_global and allow_anonymous and not user -%}")
|
||||
(div
|
||||
("class" "flex gap-2 items-center")
|
||||
(input
|
||||
("type" "checkbox")
|
||||
("name" "mask_owner")
|
||||
("id" "mask_owner")
|
||||
("class" "w-content"))
|
||||
|
||||
(label
|
||||
("for" "mask_owner")
|
||||
(b (str (text "general:label.send_anonymously")))))
|
||||
(text "{%- endif %}"))))
|
||||
|
||||
(script
|
||||
(text "async function create_question_from_form(e) {
|
||||
(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\",
|
||||
mask_owner: (e.target.mask_owner || { checked:false }).checked
|
||||
}),
|
||||
);
|
||||
|
||||
// ...
|
||||
fetch(\"/api/v1/questions\", {
|
||||
method: \"POST\",
|
||||
body,
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
|
@ -747,6 +855,10 @@
|
|||
|
||||
if (res.ok) {
|
||||
e.target.reset();
|
||||
|
||||
if (globalThis.gerald) {
|
||||
globalThis.gerald.clear();
|
||||
}
|
||||
}
|
||||
});
|
||||
}"))
|
||||
|
@ -956,9 +1068,29 @@
|
|||
(text "{%- endif %}")
|
||||
(div
|
||||
("class" "flex w-full gap-2 justify-between")
|
||||
(span
|
||||
("class" "no_p_margin")
|
||||
(text "{{ message.content|markdown|safe }}"))
|
||||
(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")
|
||||
|
@ -980,6 +1112,12 @@
|
|||
("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\" }}")
|
||||
|
@ -1017,22 +1155,18 @@
|
|||
(icon (text "code"))
|
||||
(str (text "general:link.source_code")))
|
||||
|
||||
(a
|
||||
("href" "/reference/tetratto/index.html")
|
||||
("class" "button")
|
||||
("data-turbo" "false")
|
||||
(button
|
||||
("onclick" "trigger('me::achievement_link', ['OpenReference', '/reference/tetratto/index.html'])")
|
||||
(icon (text "rabbit"))
|
||||
(str (text "general:link.reference")))
|
||||
|
||||
(a
|
||||
("href" "{{ config.policies.terms_of_service }}")
|
||||
("class" "button")
|
||||
(button
|
||||
("onclick" "trigger('me::achievement_link', ['OpenTos', '{{ config.policies.terms_of_service }}'])")
|
||||
(icon (text "heart-handshake"))
|
||||
(text "Terms of service"))
|
||||
|
||||
(a
|
||||
("href" "{{ config.policies.privacy }}")
|
||||
("class" "button")
|
||||
(button
|
||||
("onclick" "trigger('me::achievement_link', ['OpenPrivacyPolicy', '{{ config.policies.privacy }}'])")
|
||||
(icon (text "cookie"))
|
||||
(text "Privacy policy"))
|
||||
(b ("class" "title") (str (text "general:label.account")))
|
||||
|
@ -1115,13 +1249,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
|
||||
|
@ -1167,20 +1303,41 @@
|
|||
}
|
||||
|
||||
if (event.detail.unicode) {
|
||||
document.getElementById(
|
||||
window.EMOJI_PICKER_TEXT_ID,
|
||||
).value += ` :${await (
|
||||
await fetch(\"/api/v1/lookup_emoji\", {
|
||||
method: \"POST\",
|
||||
body: event.detail.unicode,
|
||||
})
|
||||
).text()}:`;
|
||||
if (window.EMOJI_PICKER_MODE === \"replace\") {
|
||||
document.getElementById(
|
||||
window.EMOJI_PICKER_TEXT_ID,
|
||||
).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 {
|
||||
document.getElementById(
|
||||
window.EMOJI_PICKER_TEXT_ID,
|
||||
).value += ` :${event.detail.emoji.shortcodes[0]}:`;
|
||||
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();
|
||||
});"))
|
||||
(div
|
||||
|
@ -1287,7 +1444,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
|
||||
|
@ -1302,7 +1459,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")
|
||||
|
@ -1362,11 +1532,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,
|
||||
|
@ -1915,6 +2085,22 @@
|
|||
(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)")
|
||||
|
@ -1928,36 +2114,249 @@
|
|||
(text "{%- endif %}")
|
||||
|
||||
; note listings
|
||||
(text "{% for note in notes %}")
|
||||
(div
|
||||
("class" "flex flex-row gap-1")
|
||||
(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")))
|
||||
(button
|
||||
("onclick" "delete_note('{{ note.id }}')")
|
||||
("class" "red")
|
||||
(icon (text "trash"))
|
||||
(str (text "general:action.delete")))))
|
||||
(text "{%- endif %}"))
|
||||
(text "{% endfor %}"))
|
||||
(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 %}")
|
||||
|
||||
(text "{% macro become_supporter_button() -%}")
|
||||
(p
|
||||
(text "You're ")
|
||||
(b
|
||||
(text "not "))
|
||||
(text "currently a supporter! No
|
||||
pressure, but it helps us do some pretty cool
|
||||
things! As a supporter, you'll get:"))
|
||||
(ul
|
||||
("style" "margin-bottom: var(--pad-4)")
|
||||
(li
|
||||
(text "Vanity badge on profile"))
|
||||
(li
|
||||
(text "No more supporter ads (duh)"))
|
||||
(li
|
||||
(text "Ability to upload gif avatars/banners"))
|
||||
(li
|
||||
(text "Be an admin/owner of up to 10 communities"))
|
||||
(li
|
||||
(text "Use custom CSS on your profile"))
|
||||
(li
|
||||
(text "Use community emojis outside of
|
||||
their community"))
|
||||
(li
|
||||
(text "Upload and use gif emojis"))
|
||||
(li
|
||||
(text "Create infinite stack timelines"))
|
||||
(li
|
||||
(text "Upload images to posts"))
|
||||
(li
|
||||
(text "Save infinite post drafts"))
|
||||
(li
|
||||
(text "Ability to search through all posts"))
|
||||
(li
|
||||
(text "Ability to create forges"))
|
||||
(li
|
||||
(text "Create more than 1 app"))
|
||||
(li
|
||||
(text "Create up to 10 stack blocks"))
|
||||
(li
|
||||
(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")
|
||||
(sup (a ("href" "#footnote-1") (text "1"))))
|
||||
(text "{%- endif %}"))
|
||||
(a
|
||||
("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}")
|
||||
("class" "button")
|
||||
("target" "_blank")
|
||||
(text "Become a supporter ({{ config.stripe.supporter_price_text }})"))
|
||||
(span
|
||||
("class" "fade")
|
||||
(text "Please use your")
|
||||
(b
|
||||
(text " real email "))
|
||||
(text "when
|
||||
completing payment. It is required to manage
|
||||
your billing settings."))
|
||||
|
||||
(text "{% if config.security.enable_invite_codes -%}")
|
||||
(span
|
||||
("class" "fade")
|
||||
("id" "footnote-1")
|
||||
(b (text "1: ")) (text "After your account is at least 1 month old"))
|
||||
(text "{%- endif %}")
|
||||
(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
|
||||
|
|
|
@ -1,7 +1,87 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(text "{% if journal -%}") (title (text "{{ journal.title }}")) (text "{% else %}") (title (text "Journals - {{ config.name }}")) (text "{%- endif %}")
|
||||
|
||||
(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 }}"))
|
||||
|
@ -9,7 +89,12 @@
|
|||
; redirect to journal homepage
|
||||
(meta ("http-equiv" "refresh") ("content" "0; url=/@{{ user.username }}/{{ journal.title }}"))
|
||||
(text "{%- endif %} {%- endif %}")
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"journals\") }}")
|
||||
|
||||
(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")
|
||||
|
@ -53,27 +138,31 @@
|
|||
(main
|
||||
("class" "flex flex-col gap-2")
|
||||
; the journal/note header is always shown
|
||||
(text "{% if journal -%}")
|
||||
(text "{% if journal and not global_mode -%}")
|
||||
(div
|
||||
("class" "mobile_nav w-full flex items-center justify-between gap-2")
|
||||
(div
|
||||
("class" "flex gap-2 items-center")
|
||||
(a
|
||||
("class" "flex items-center")
|
||||
("href" "/api/v1/auth/user/find/{{ journal.owner }}")
|
||||
(text "{{ components::avatar(username=journal.owner, selector_type=\"id\", size=\"18px\") }}"))
|
||||
("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\") }}"))
|
||||
|
||||
(a
|
||||
("class" "flush")
|
||||
("href" "{% if view_mode -%} /@{{ owner.username }}/{{ journal.title }}/index {%- else -%} /journals/{{ journal.id }}/0 {%- endif %}")
|
||||
(b (text "{{ journal.title }}")))
|
||||
(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 note -%}")
|
||||
(span (text "/"))
|
||||
(b (text "{{ note.title }}"))
|
||||
(text "{%- endif %}")))
|
||||
|
||||
(text "{% if user and user.id == journal.owner -%}")
|
||||
(text "{% if user and user.id == journal.owner and (not note or note.title != \"journal.css\") -%}")
|
||||
(div
|
||||
("class" "pillmenu")
|
||||
(a
|
||||
|
@ -83,7 +172,7 @@
|
|||
(icon (text "pencil")))
|
||||
(a
|
||||
("class" "{% if view_mode -%}active{%- endif %}")
|
||||
("href" "/@{{ user.username }}/{{ journal.title }}/{% if note -%} {{ note.title }} {%- else -%} index {%- endif %}")
|
||||
("href" "/@{{ user.username }}/{{ journal.title }}{% if note -%} /{{ note.title }} {%- endif %}")
|
||||
(icon (text "eye"))))
|
||||
(text "{%- endif %}"))
|
||||
(text "{%- endif %}")
|
||||
|
@ -96,6 +185,7 @@
|
|||
("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"))
|
||||
|
@ -167,10 +257,29 @@
|
|||
(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 %}")
|
||||
|
@ -180,10 +289,36 @@
|
|||
; 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"))
|
||||
(script ("src" "https://unpkg.com/codemirror@5.39.2/mode/markdown/markdown.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
|
||||
|
@ -197,14 +332,31 @@
|
|||
("href" "#/preview")
|
||||
("data-tab-button" "preview")
|
||||
("data-turbo" "false")
|
||||
(str (text "journals:label.preview_pane"))))
|
||||
(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 card")
|
||||
("style" "animation: fadein ease-in-out 1 0.5s forwards running")
|
||||
("id" "editor_tab"))
|
||||
("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")
|
||||
|
@ -212,19 +364,62 @@
|
|||
("style" "animation: fadein ease-in-out 1 0.5s forwards running")
|
||||
("id" "preview_tab"))
|
||||
|
||||
(button
|
||||
("onclick" "change_note_content('{{ note.id }}')")
|
||||
(icon (text "check"))
|
||||
(str (text "general:action.save")))
|
||||
(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(() => {
|
||||
(text "setTimeout(async () => {
|
||||
document.getElementById(\"editor_tab\").innerHTML = \"\";
|
||||
globalThis.editor = CodeMirror(document.getElementById(\"editor_tab\"), {
|
||||
value: document.getElementById(\"editor_content\").innerHTML,
|
||||
mode: \"markdown\",
|
||||
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,
|
||||
|
@ -232,7 +427,8 @@
|
|||
highlightFormatting: false,
|
||||
fencedCodeBlockHighlighting: false,
|
||||
xml: false,
|
||||
smartIndent: false,
|
||||
smartIndent: true,
|
||||
indentUnit: 4,
|
||||
placeholder: `# {{ note.title }}`,
|
||||
extraKeys: {
|
||||
Home: \"goLineLeft\",
|
||||
|
@ -243,6 +439,15 @@
|
|||
},
|
||||
});
|
||||
|
||||
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\"]);
|
||||
|
@ -262,7 +467,10 @@
|
|||
})
|
||||
).text();
|
||||
|
||||
document.getElementById(\"preview_tab\").innerHTML = res;
|
||||
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);"))
|
||||
|
@ -272,7 +480,54 @@
|
|||
("class" "flex flex-col gap-2 card")
|
||||
(text "{{ note.content|markdown|safe }}"))
|
||||
|
||||
(span (text "Last updated: ") (span ("class" "date") (text "{{ note.edited }}")))
|
||||
(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
|
||||
|
@ -332,7 +587,7 @@
|
|||
},
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
content: `# ${title}`,
|
||||
content: title === \"journal.css\" ? `/* ${title} */\\n` : `# ${title}`,
|
||||
journal: \"{{ selected_journal }}\",
|
||||
}),
|
||||
})
|
||||
|
@ -431,8 +686,8 @@
|
|||
|
||||
globalThis.change_journal_privacy = async (e) => {
|
||||
e.preventDefault();
|
||||
const selected = event.target.selectedOptions[0];
|
||||
fetch(\"/api/v1/journals/{{ selected_journal }}/privacy\", {
|
||||
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\",
|
||||
|
@ -498,6 +753,155 @@
|
|||
});
|
||||
}
|
||||
|
||||
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\") {
|
||||
|
@ -540,4 +944,24 @@
|
|||
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 %}")
|
||||
|
|
92
crates/app/src/public/html/littleweb/services.lisp
Normal file
92
crates/app/src/public/html/littleweb/services.lisp
Normal file
|
@ -0,0 +1,92 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "My stacks - {{ config.name }}"))
|
||||
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
|
||||
(main
|
||||
("class" "flex flex-col gap-2")
|
||||
(text "{{ macros::timelines_nav(selected=\"littleweb\") }} {% if user -%}")
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small")
|
||||
(b
|
||||
(str (text "littleweb:label.create_new"))))
|
||||
(form
|
||||
("class" "card flex flex-col gap-2")
|
||||
("onsubmit" "create_service_from_form(event)")
|
||||
(div
|
||||
("class" "flex flex-col gap-1")
|
||||
(label
|
||||
("for" "name")
|
||||
(text "{{ text \"communities:label.name\" }}"))
|
||||
(input
|
||||
("type" "text")
|
||||
("name" "name")
|
||||
("id" "name")
|
||||
("placeholder" "name")
|
||||
("required" "")
|
||||
("minlength" "2")
|
||||
("maxlength" "32")))
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ text \"communities:action.create\" }}"))))
|
||||
(text "{%- endif %}")
|
||||
(div
|
||||
("class" "card-nest w-full")
|
||||
(div
|
||||
("class" "card small flex items-center justify-between gap-2")
|
||||
(div
|
||||
("class" "flex items-center gap-2")
|
||||
(icon (text "panel-top"))
|
||||
(span
|
||||
(str (text "littleweb:label.my_services")))))
|
||||
(div
|
||||
("class" "card flex flex-col gap-2")
|
||||
(text "{% for item in list %}")
|
||||
(a
|
||||
("href" "/services/{{ item.id }}")
|
||||
("class" "card secondary flex flex-col gap-2")
|
||||
(div
|
||||
("class" "flex items-center gap-2")
|
||||
(icon (text "globe"))
|
||||
(b
|
||||
(text "{{ item.name }}")))
|
||||
(span
|
||||
(text "Created ")
|
||||
(span
|
||||
("class" "date")
|
||||
(text "{{ item.created }}"))
|
||||
(text "; {{ item.files|length }} files")))
|
||||
(text "{% endfor %}"))))
|
||||
|
||||
(script
|
||||
(text "async function create_service_from_form(e) {
|
||||
e.preventDefault();
|
||||
await trigger(\"atto::debounce\", [\"services::create\"]);
|
||||
|
||||
fetch(\"/api/v1/services\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: e.target.name.value,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
setTimeout(() => {
|
||||
window.location.href = `/services/${res.payload}`;
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}"))
|
||||
|
||||
(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 %}")
|
||||
|
@ -136,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"))
|
||||
|
@ -252,10 +252,17 @@
|
|||
("class" "pillmenu")
|
||||
(text "{% if is_self or is_helper or not profile.settings.hide_extra_post_tabs -%}")
|
||||
(a
|
||||
("href" "/@{{ profile.username }}")
|
||||
("href" "/@{{ profile.username }}?f=true")
|
||||
("class" "{% if selected == 'posts' -%}active{%- endif %}")
|
||||
(str (text "auth:label.posts")))
|
||||
|
||||
(text "{% if profile.settings.enable_questions -%}")
|
||||
(a
|
||||
("href" "/@{{ profile.username }}?r=true")
|
||||
("class" "{% if selected == 'responses' -%}active{%- endif %}")
|
||||
(str (text "auth:label.responses")))
|
||||
(text "{%- endif %}")
|
||||
|
||||
(a
|
||||
("href" "/@{{ profile.username }}/replies")
|
||||
("class" "{% if selected == 'replies' -%}active{%- endif %}")
|
||||
|
@ -311,8 +318,9 @@
|
|||
(span
|
||||
(text "{{ text \"settings:tab.theme\" }}")))
|
||||
(a
|
||||
("href" "#")
|
||||
("data-tab-button" "sessions")
|
||||
("href" "#/sessions")
|
||||
("onclick" "trigger('me::achievement_link', ['OpenSessionSettings', '#/sessions'])")
|
||||
(text "{{ icon \"cookie\" }}")
|
||||
(span
|
||||
(text "{{ text \"settings:tab.sessions\" }}")))
|
||||
|
|
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 %}")
|
|
@ -113,21 +113,24 @@
|
|||
("id" "files_list")
|
||||
("class" "flex gap-2 flex-wrap"))
|
||||
(div
|
||||
("class" "flex flex-wrap w-full gap-2")
|
||||
(text "{{ components::create_post_options() }}")
|
||||
("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
|
||||
("type" "button")
|
||||
("class" "red lowered")
|
||||
("onclick" "trigger('me::remove_question', ['{{ question[0].id }}'])")
|
||||
(text "{{ text \"general:action.delete\" }}"))
|
||||
(button
|
||||
("type" "button")
|
||||
("class" "red lowered")
|
||||
("onclick" "trigger('me::ip_block_question', ['{{ question[0].id }}'])")
|
||||
(text "{{ text \"auth:action.ip_block\" }}")))
|
||||
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ text \"requests:label.answer\" }}"))
|
||||
(button
|
||||
("type" "button")
|
||||
("class" "red lowered")
|
||||
("onclick" "trigger('me::remove_question', ['{{ question[0].id }}'])")
|
||||
(text "{{ text \"general:action.delete\" }}"))
|
||||
(button
|
||||
("type" "button")
|
||||
("class" "red lowered")
|
||||
("onclick" "trigger('me::ip_block_question', ['{{ question[0].id }}'])")
|
||||
(text "{{ text \"auth:action.ip_block\" }}")))))
|
||||
(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) {
|
||||
|
@ -168,6 +168,11 @@
|
|||
\"{{ profile.is_verified }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
[
|
||||
[\"awaiting_purchase\", \"Awaiting purchase\"],
|
||||
\"{{ profile.awaiting_purchase }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
[
|
||||
[\"role\", \"Permission level\"],
|
||||
\"{{ profile.permissions }}\",
|
||||
|
@ -181,6 +186,11 @@
|
|||
is_verified: value,
|
||||
});
|
||||
},
|
||||
awaiting_purchase: (value) => {
|
||||
profile_request(false, \"awaiting_purchase\", {
|
||||
awaiting_purchase: value,
|
||||
});
|
||||
},
|
||||
role: (new_role) => {
|
||||
return update_user_role(new_role);
|
||||
},
|
||||
|
@ -202,6 +212,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 +245,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 +280,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
|
||||
|
@ -217,12 +219,24 @@
|
|||
(text "{{ icon \"user-minus\" }}")
|
||||
(span
|
||||
(text "{{ text \"auth:action.unfollow\" }}")))
|
||||
(button
|
||||
("onclick" "toggle_block_user()")
|
||||
("class" "lowered red")
|
||||
(text "{{ icon \"shield\" }}")
|
||||
(span
|
||||
(text "{{ text \"auth:action.block\" }}")))
|
||||
(div
|
||||
("class" "dropdown")
|
||||
(button
|
||||
("onclick" "trigger('atto::hooks::dropdown', [event])")
|
||||
("exclude" "dropdown")
|
||||
("class" "lowered red")
|
||||
(icon_class (text "chevron-down") (text "dropdown-arrow"))
|
||||
(str (text "auth:action.block")))
|
||||
(div
|
||||
("class" "inner left")
|
||||
(button
|
||||
("onclick" "toggle_block_user()")
|
||||
(icon (text "shield"))
|
||||
(str (text "auth:action.block")))
|
||||
(button
|
||||
("onclick" "ip_block_user()")
|
||||
(icon (text "wifi"))
|
||||
(str (text "auth:action.ip_block")))))
|
||||
(text "{% else %}")
|
||||
(button
|
||||
("onclick" "toggle_block_user()")
|
||||
|
@ -340,6 +354,30 @@
|
|||
res.message,
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
||||
globalThis.ip_block_user = async () => {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(
|
||||
\"/api/v1/auth/user/{{ profile.id }}/block_ip\",
|
||||
{
|
||||
method: \"POST\",
|
||||
},
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
};"))))
|
||||
(text "{%- endif %} {% if not profile.settings.private_communities or is_self or is_helper %}")
|
||||
(div
|
||||
|
|
|
@ -24,12 +24,24 @@
|
|||
(div
|
||||
("class" "card w-full secondary flex gap-2")
|
||||
(text "{% if user -%} {% if not is_blocking -%}")
|
||||
(button
|
||||
("onclick" "toggle_block_user()")
|
||||
("class" "lowered red")
|
||||
(text "{{ icon \"shield\" }}")
|
||||
(span
|
||||
(text "{{ text \"auth:action.block\" }}")))
|
||||
(div
|
||||
("class" "dropdown")
|
||||
(button
|
||||
("onclick" "trigger('atto::hooks::dropdown', [event])")
|
||||
("exclude" "dropdown")
|
||||
("class" "lowered red")
|
||||
(icon_class (text "chevron-down") (text "dropdown-arrow"))
|
||||
(str (text "auth:action.block")))
|
||||
(div
|
||||
("class" "inner left")
|
||||
(button
|
||||
("onclick" "toggle_block_user()")
|
||||
(icon (text "shield"))
|
||||
(str (text "auth:action.block")))
|
||||
(button
|
||||
("onclick" "ip_block_user()")
|
||||
(icon (text "wifi"))
|
||||
(str (text "auth:action.ip_block")))))
|
||||
(text "{% else %}")
|
||||
(button
|
||||
("onclick" "toggle_block_user()")
|
||||
|
@ -58,6 +70,30 @@
|
|||
res.message,
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
||||
globalThis.ip_block_user = async () => {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(
|
||||
\"/api/v1/auth/user/{{ profile.id }}/block_ip\",
|
||||
{
|
||||
method: \"POST\",
|
||||
},
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
};"))
|
||||
(text "{%- endif %}")
|
||||
(a
|
||||
|
|
|
@ -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, allow_anonymous=profile.settings.allow_anonymous_questions) }}"))
|
||||
|
||||
(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, allow_anonymous=profile.settings.allow_anonymous_questions) }}"))
|
||||
|
||||
(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, allow_anonymous=profile.settings.allow_anonymous_questions) }}"))
|
||||
|
||||
(text "{%- endif %} {% if not tag and pinned|length != 0 -%}")
|
||||
(div
|
||||
|
@ -44,9 +44,12 @@
|
|||
("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?user_id={{ profile.id }}&tag={{ tag }}&page=\", Number.parseInt(\"{{ page }}\") - 1]);
|
||||
});"))
|
||||
(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 %}")
|
||||
|
|
|
@ -47,12 +47,24 @@
|
|||
(span
|
||||
(text "{{ text \"auth:action.unfollow\" }}")))
|
||||
(text "{%- endif %} {% if not is_blocking -%}")
|
||||
(button
|
||||
("onclick" "toggle_block_user()")
|
||||
("class" "lowered red")
|
||||
(text "{{ icon \"shield\" }}")
|
||||
(span
|
||||
(text "{{ text \"auth:action.block\" }}")))
|
||||
(div
|
||||
("class" "dropdown")
|
||||
(button
|
||||
("onclick" "trigger('atto::hooks::dropdown', [event])")
|
||||
("exclude" "dropdown")
|
||||
("class" "lowered red")
|
||||
(icon_class (text "chevron-down") (text "dropdown-arrow"))
|
||||
(str (text "auth:action.block")))
|
||||
(div
|
||||
("class" "inner left")
|
||||
(button
|
||||
("onclick" "toggle_block_user()")
|
||||
(icon (text "shield"))
|
||||
(str (text "auth:action.block")))
|
||||
(button
|
||||
("onclick" "ip_block_user()")
|
||||
(icon (text "wifi"))
|
||||
(str (text "auth:action.ip_block")))))
|
||||
(text "{% else %}")
|
||||
(button
|
||||
("onclick" "toggle_block_user()")
|
||||
|
@ -151,6 +163,30 @@
|
|||
res.message,
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
||||
globalThis.ip_block_user = async () => {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(
|
||||
\"/api/v1/auth/user/{{ profile.id }}/block_ip\",
|
||||
{
|
||||
method: \"POST\",
|
||||
},
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
};"))
|
||||
(text "{%- endif %}")
|
||||
(a
|
||||
|
|
|
@ -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, allow_anonymous=profile.settings.allow_anonymous_questions) }}"))
|
||||
|
||||
(text "{%- endif %} {{ macros::profile_nav(selected=\"replies\") }}")
|
||||
(div
|
||||
|
|
55
crates/app/src/public/html/profile/responses.lisp
Normal file
55
crates/app/src/public/html/profile/responses.lisp
Normal file
|
@ -0,0 +1,55 @@
|
|||
(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, drawing_enabled=profile.settings.enable_drawings, allow_anonymous=profile.settings.allow_anonymous_questions) }}"))
|
||||
|
||||
(text "{%- endif %} {% if not tag and pinned|length != 0 -%}")
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small flex gap-2 items-center")
|
||||
(text "{{ icon \"pin\" }}")
|
||||
(span
|
||||
(text "{{ text \"communities:label.pinned\" }}")))
|
||||
(div
|
||||
("class" "card flex flex-col gap-4")
|
||||
(text "{% for post in pinned %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self, poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %}")))
|
||||
|
||||
(text "{%- endif %} {{ macros::profile_nav(selected=\"responses\") }}")
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small flex gap-2 justify-between items-center")
|
||||
(div
|
||||
("class" "flex gap-2 items-center")
|
||||
(text "{% if not tag -%} {{ icon \"clock\" }}")
|
||||
(span
|
||||
(text "{{ text \"auth:label.recent_posts\" }}"))
|
||||
(text "{% else %} {{ icon \"tag\" }}")
|
||||
(span
|
||||
(text "{{ text \"auth:label.recent_with_tag\" }}: ")
|
||||
(b
|
||||
(text "{{ tag }}")))
|
||||
(text "{%- endif %}"))
|
||||
(text "{% if user -%}")
|
||||
(a
|
||||
("href" "/search?profile={{ profile.id }}")
|
||||
("class" "button lowered small")
|
||||
(text "{{ icon \"search\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:link.search\" }}")))
|
||||
(text "{%- endif %}"))
|
||||
(div
|
||||
("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 }}&responses_only=true&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
|
||||
(await ns(\"ui\")).IO_DATA_DISABLE_RELOAD = true;
|
||||
console.log(\"created profile timeline\");
|
||||
}, 1000);"))
|
||||
|
||||
(text "{% endblock %}")
|
|
@ -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,43 @@
|
|||
(div
|
||||
("class" "flex gap-2")
|
||||
(text "{{ components::avatar(username=user.username) }} {{ components::full_username(user=user) }}"))
|
||||
(a
|
||||
("href" "/@{{ user.username }}")
|
||||
("class" "button lowered small")
|
||||
(text "{{ icon \"external-link\" }}")
|
||||
(span
|
||||
(text "{{ text \"requests:action.view_profile\" }}"))))
|
||||
(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")
|
||||
(icon (text "external-link"))
|
||||
(span (str (text "requests:action.view_profile"))))))
|
||||
(text "{% endfor %}")))
|
||||
|
||||
; ip blocks
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card flex items-center gap-2 small")
|
||||
(text "{{ icon \"wifi\" }}")
|
||||
(span
|
||||
(text "{{ text \"settings:label.ips\" }}")))
|
||||
(div
|
||||
("class" "card flex flex-col gap-2")
|
||||
(text "{% for ip in ipblocks %}")
|
||||
(div
|
||||
("class" "card secondary flex flex-wrap gap-2 items-center justify-between")
|
||||
(span
|
||||
(text "Block from: ") (span ("class" "date") (text "{{ ip.created }}")))
|
||||
(div
|
||||
("class" "flex gap-2")
|
||||
(button
|
||||
("onclick" "trigger('me::remove_ip_block', ['{{ ip.id }}'])")
|
||||
("class" "lowered small red")
|
||||
(icon (text "x"))
|
||||
(span (str (text "auth:action.unblock"))))))
|
||||
(text "{% endfor %}")))))
|
||||
(div
|
||||
("class" "w-full flex flex-col gap-2 hidden")
|
||||
|
@ -495,6 +540,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")
|
||||
|
@ -544,64 +671,31 @@
|
|||
("target" "_blank")
|
||||
(text "Manage billing"))
|
||||
(text "{% else %}")
|
||||
(p
|
||||
(text "You're ")
|
||||
(b
|
||||
(text "not "))
|
||||
(text "currently a supporter! No
|
||||
pressure, but it helps us do some pretty cool
|
||||
things! As a supporter, you'll get:"))
|
||||
(ul
|
||||
("style" "margin-bottom: var(--pad-4)")
|
||||
(li
|
||||
(text "Vanity badge on profile"))
|
||||
(li
|
||||
(text "No more supporter ads (duh)"))
|
||||
(li
|
||||
(text "Ability to upload gif avatars/banners"))
|
||||
(li
|
||||
(text "Be an admin/owner of up to 10 communities"))
|
||||
(li
|
||||
(text "Use custom CSS on your profile"))
|
||||
(li
|
||||
(text "Ability to use community emojis outside of
|
||||
their community"))
|
||||
(li
|
||||
(text "Ability to upload and use gif emojis"))
|
||||
(li
|
||||
(text "Create infinite stack timelines"))
|
||||
(li
|
||||
(text "Ability to upload images to posts"))
|
||||
(li
|
||||
(text "Save infinite post drafts"))
|
||||
(li
|
||||
(text "Ability to search through all posts"))
|
||||
(li
|
||||
(text "Ability to create forges"))
|
||||
(li
|
||||
(text "Ability to create more than 1 app"))
|
||||
(li
|
||||
(text "Create up to 10 stack blocks"))
|
||||
(li
|
||||
(text "Add unlimited users to stacks"))
|
||||
(li
|
||||
(text "Increased proxied image size"))
|
||||
(li
|
||||
(text "Create infinite journals")))
|
||||
(a
|
||||
("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}")
|
||||
("class" "button")
|
||||
("target" "_blank")
|
||||
(text "Become a supporter"))
|
||||
(span
|
||||
("class" "fade")
|
||||
(text "Please use your")
|
||||
(b
|
||||
(text "real email"))
|
||||
(text "when
|
||||
completing payment. It is required to manage
|
||||
your billing settings."))
|
||||
(text "{{ components::become_supporter_button() }}")
|
||||
(text "{%- endif %}")))
|
||||
|
||||
(text "{% if user.was_purchased and user.invite_code == 0 -%}")
|
||||
(form
|
||||
("class" "card w-full lowered flex flex-col gap-2")
|
||||
("onsubmit" "update_invite_code(event)")
|
||||
(p (text "Your account is currently activated without an invite code. If you stop paying for supporter, your account will be locked again until you renew. You can provide an invite code to avoid this if you're planning on cancelling."))
|
||||
|
||||
(div
|
||||
("class" "flex flex-col gap-1")
|
||||
(label
|
||||
("for" "invite_code")
|
||||
(b
|
||||
(text "Invite code")))
|
||||
(input
|
||||
("type" "text")
|
||||
("placeholder" "invite code")
|
||||
("name" "invite_code")
|
||||
("required" "")
|
||||
("id" "invite_code")))
|
||||
|
||||
(button
|
||||
(text "Submit")))
|
||||
(text "{%- endif %}")
|
||||
(text "{%- endif %}")))))
|
||||
(div
|
||||
("class" "w-full hidden flex flex-col gap-2")
|
||||
|
@ -663,7 +757,29 @@
|
|||
(text "{{ icon \"check\" }}")))
|
||||
(span
|
||||
("class" "fade")
|
||||
(text "Use an image of 1100x350px for the best results.")))))
|
||||
(text "Use an image of 1100x350px for the best results."))))
|
||||
(div
|
||||
("class" "card-nest")
|
||||
("ui_ident" "default_profile_page")
|
||||
(div
|
||||
("class" "card small")
|
||||
(b
|
||||
(text "Default profile tab")))
|
||||
(div
|
||||
("class" "card")
|
||||
(select
|
||||
("onchange" "window.SETTING_SET_FUNCTIONS[0]('default_profile_tab', event.target.selectedOptions[0].value)")
|
||||
(option
|
||||
("value" "Posts")
|
||||
("selected" "{% if profile.settings.default_profile_tab == 'Posts' -%}true{% else %}false{%- endif %}")
|
||||
(text "Posts"))
|
||||
(option
|
||||
("value" "Responses")
|
||||
("selected" "{% if profile.settings.default_profile_tab == 'Responses' -%}true{% else %}false{%- endif %}")
|
||||
(text "Responses")))
|
||||
(span
|
||||
("class" "fade")
|
||||
(text "This represents the timeline that is shown on your profile by default.")))))
|
||||
(button
|
||||
("onclick" "save_settings()")
|
||||
("id" "save_button")
|
||||
|
@ -925,8 +1041,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,
|
||||
);
|
||||
|
@ -1062,6 +1178,11 @@
|
|||
globalThis.delete_account = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// {% if user.permissions|has_supporter %}
|
||||
alert(\"Please cancel your membership before deleting your account. You'll have to wait until the next cycle to delete your account after, or you can request support if it is urgent.\");
|
||||
return;
|
||||
// {% endif %}
|
||||
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
|
@ -1245,6 +1366,31 @@
|
|||
});
|
||||
};
|
||||
|
||||
globalThis.update_invite_code = async (e) => {
|
||||
e.preventDefault();
|
||||
await trigger(\"atto::debounce\", [\"invite_codes::try\"]);
|
||||
fetch(\"/api/v1/auth/user/me/invite_code\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
invite_code: e.target.invite_code.value,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const account_settings =
|
||||
document.getElementById(\"account_settings\");
|
||||
const profile_settings =
|
||||
|
@ -1263,6 +1409,7 @@
|
|||
\"supporter_ad\",
|
||||
\"change_avatar\",
|
||||
\"change_banner\",
|
||||
\"default_profile_page\",
|
||||
]);
|
||||
ui.refresh_container(theme_settings, [
|
||||
\"supporter_ad\",
|
||||
|
@ -1295,6 +1442,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,
|
||||
{
|
||||
|
@ -1357,6 +1520,24 @@
|
|||
\"{{ 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\",
|
||||
],
|
||||
[
|
||||
[
|
||||
\"hide_associated_blocked_users\",
|
||||
\"Hide users that you've blocked on your other accounts from timelines\",
|
||||
],
|
||||
\"{{ profile.settings.hide_associated_blocked_users }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
[[], \"Questions\", \"title\"],
|
||||
[
|
||||
[
|
||||
|
@ -1374,6 +1555,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,
|
||||
|
@ -1407,6 +1596,11 @@
|
|||
\"{{ profile.settings.disable_gpa_fun }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
[
|
||||
[\"disable_achievements\", \"Disable achievements\"],
|
||||
\"{{ profile.settings.disable_achievements }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
],
|
||||
settings,
|
||||
);
|
||||
|
@ -1583,6 +1777,35 @@
|
|||
description: \"Hover state for secondary buttons.\",
|
||||
},
|
||||
],
|
||||
// online indicator
|
||||
[[], \"\", \"divider\"],
|
||||
[
|
||||
[\"theme_color_online\", \"Online indicator (online)\"],
|
||||
\"{{ profile.settings.theme_color_online }}\",
|
||||
\"color\",
|
||||
{
|
||||
description:
|
||||
\"The green dot next to the name of online users.\",
|
||||
},
|
||||
],
|
||||
[
|
||||
[\"theme_color_idle\", \"Online indicator (idle)\"],
|
||||
\"{{ profile.settings.theme_color_idle }}\",
|
||||
\"color\",
|
||||
{
|
||||
description:
|
||||
\"The yellow dot next to the name of online users.\",
|
||||
},
|
||||
],
|
||||
[
|
||||
[\"theme_color_offline\", \"Online indicator (offline)\"],
|
||||
\"{{ profile.settings.theme_color_offline }}\",
|
||||
\"color\",
|
||||
{
|
||||
description:
|
||||
\"The grey next to the name of online users.\",
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
if (can_use_custom_css) {
|
||||
|
|
|
@ -21,10 +21,9 @@
|
|||
{%- endif %}")
|
||||
|
||||
(text "<script>
|
||||
globalThis.ns_verbose = false;
|
||||
globalThis.ns_config = {
|
||||
root: \"/js/\",
|
||||
verbose: globalThis.ns_verbose,
|
||||
verbose: false,
|
||||
version: \"tetratto-{{ random_cache_breaker }}\",
|
||||
};
|
||||
|
||||
|
@ -73,7 +72,73 @@
|
|||
(str (text "general:label.account_banned_body"))))))
|
||||
|
||||
; if we aren't banned, just show the page body
|
||||
(text "{% else %} {% block body %}{% endblock %} {%- endif %}")
|
||||
(text "{% elif user and user.awaiting_purchase %}")
|
||||
; account waiting for payment message
|
||||
(article
|
||||
(main
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small flex items-center gap-2 red")
|
||||
(icon (text "frown"))
|
||||
(str (text "general:label.must_activate_account")))
|
||||
|
||||
(div
|
||||
("class" "card no_p_margin flex flex-col gap-2")
|
||||
(p (text "Since you didn't provide an invite code, you'll need to activate your account to use it."))
|
||||
(p (text "Supporter is a recurring membership. If you cancel it, your account will be locked again unless you renew your subscription or provide an invite code."))
|
||||
(div
|
||||
("class" "card w-full lowered flex flex-col gap-2")
|
||||
(text "{{ components::become_supporter_button() }}"))
|
||||
(p (text "Alternatively, you can provide an invite code to activate your account."))
|
||||
(form
|
||||
("class" "card w-full lowered flex flex-col gap-2")
|
||||
("onsubmit" "update_invite_code(event)")
|
||||
(div
|
||||
("class" "flex flex-col gap-1")
|
||||
(label
|
||||
("for" "invite_code")
|
||||
(b
|
||||
(text "Invite code")))
|
||||
(input
|
||||
("type" "text")
|
||||
("placeholder" "invite code")
|
||||
("name" "invite_code")
|
||||
("required" "")
|
||||
("id" "invite_code")))
|
||||
|
||||
(button
|
||||
(text "Submit")))
|
||||
|
||||
(script
|
||||
(text "async function update_invite_code(e) {
|
||||
e.preventDefault();
|
||||
await trigger(\"atto::debounce\", [\"invite_codes::try\"]);
|
||||
fetch(\"/api/v1/auth/user/me/invite_code\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
invite_code: e.target.invite_code.value,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}"))))))
|
||||
(text "{% else %}")
|
||||
; page body
|
||||
(text "{% block body %}{% endblock %}")
|
||||
(text "{%- endif %}")
|
||||
(text "<!-- html_footer_goes_here -->"))
|
||||
|
||||
(text "{% include \"body.html\" %}")))
|
||||
|
|
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 %}")
|
|
@ -83,9 +83,10 @@
|
|||
("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]);
|
||||
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?stack_id={{ stack.id }}&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
|
||||
});"))
|
||||
(text "{%- endif %}"))))
|
||||
|
||||
|
|
|
@ -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\",
|
||||
|
|
|
@ -33,9 +33,10 @@
|
|||
("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]);
|
||||
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=AllPosts&before=$1$&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
|
||||
});"))
|
||||
|
||||
(text "{% endblock %}")
|
||||
|
|
|
@ -11,9 +11,10 @@
|
|||
("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]);
|
||||
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=FollowingPosts&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
|
||||
});"))
|
||||
|
||||
(text "{% endblock %}")
|
||||
|
|
|
@ -31,9 +31,10 @@
|
|||
(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]);
|
||||
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=MyCommunities&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
|
||||
});"))
|
||||
|
||||
(text "{% endblock %}")
|
||||
|
|
|
@ -11,9 +11,10 @@
|
|||
("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]);
|
||||
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 %}"))))
|
||||
|
|
|
@ -1,17 +1,15 @@
|
|||
(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 %}
|
||||
{% 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 %}")
|
||||
(datalist
|
||||
("ui_ident" "list_posts_{{ page }}")
|
||||
(text "{% for post in list -%}")
|
||||
(option ("value" "{{ post[0].id }}"))
|
||||
(option ("value" "{{ post[0].id }}") ("data-created" "{{ post[0].created }}"))
|
||||
(text "{%- endfor %}"))
|
||||
(text "{% if list|length == 0 -%}")
|
||||
(div
|
||||
|
@ -30,3 +28,7 @@
|
|||
(str (text "chats:label.go_back")))
|
||||
(text "{%- endif %}"))
|
||||
(text "{%- endif %}")
|
||||
|
||||
(text "{% if paginated -%}")
|
||||
(text "{{ components::pagination(page=page, items=list|length) }}")
|
||||
(text "{%- endif %}")
|
||||
|
|
|
@ -39,6 +39,7 @@ media_theme_pref();
|
|||
// init
|
||||
use("me", () => {});
|
||||
use("streams", () => {});
|
||||
use("carp", () => {});
|
||||
|
||||
// env
|
||||
self.DEBOUNCE = [];
|
||||
|
@ -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) |
|
||||
|
@ -162,7 +163,7 @@ media_theme_pref();
|
|||
}
|
||||
});
|
||||
|
||||
self.define("clean_poll_date_codes", ({ $ }) => {
|
||||
self.define("clean_poll_date_codes", async ({ $ }) => {
|
||||
for (const element of Array.from(
|
||||
document.querySelectorAll(".poll_date"),
|
||||
)) {
|
||||
|
@ -182,7 +183,7 @@ media_theme_pref();
|
|||
element.setAttribute("title", then.toLocaleString());
|
||||
|
||||
const pretty =
|
||||
$.rel_date(then)
|
||||
(await $.rel_date(then))
|
||||
.replaceAll(" minutes ago", "m")
|
||||
.replaceAll(" minute ago", "m")
|
||||
.replaceAll(" hours ago", "h")
|
||||
|
@ -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) {
|
||||
|
@ -408,9 +409,13 @@ media_theme_pref();
|
|||
}
|
||||
});
|
||||
|
||||
self.define("hooks::long", (_, element, full_text) => {
|
||||
self.define("hooks::long", ({ $ }, element, full_text) => {
|
||||
element.classList.remove("hook:long.hidden_text");
|
||||
element.innerHTML = full_text;
|
||||
|
||||
$.clean_date_codes();
|
||||
$.clean_poll_date_codes();
|
||||
$.link_filter();
|
||||
});
|
||||
|
||||
self.define("hooks::long_text.init", (_) => {
|
||||
|
@ -453,18 +458,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 +477,7 @@ media_theme_pref();
|
|||
]);
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
setInterval(async () => {
|
||||
element.setAttribute(
|
||||
"hook-arg:updated",
|
||||
Number.parseInt(element.getAttribute("hook-arg:updated")) +
|
||||
|
@ -485,10 +490,10 @@ media_theme_pref();
|
|||
1000,
|
||||
);
|
||||
|
||||
render();
|
||||
await render();
|
||||
}, 1000);
|
||||
|
||||
render();
|
||||
await render();
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -504,7 +509,7 @@ media_theme_pref();
|
|||
return now - last_seen <= maximum_time_to_be_considered_idle;
|
||||
});
|
||||
|
||||
self.define("hooks::online_indicator", ({ $ }) => {
|
||||
self.define("hooks::online_indicator", async ({ $ }) => {
|
||||
for (const element of Array.from(
|
||||
document.querySelectorAll("[hook=online_indicator]") || [],
|
||||
)) {
|
||||
|
@ -512,8 +517,8 @@ media_theme_pref();
|
|||
element.getAttribute("hook-arg:last_seen"),
|
||||
);
|
||||
|
||||
const is_online = $.last_seen_just_now(last_seen);
|
||||
const is_idle = $.last_seen_recently(last_seen);
|
||||
const is_online = await $.last_seen_just_now(last_seen);
|
||||
const is_idle = await $.last_seen_recently(last_seen);
|
||||
|
||||
const offline = element.querySelector("[hook_ui_ident=offline]");
|
||||
const online = element.querySelector("[hook_ui_ident=online]");
|
||||
|
@ -618,7 +623,6 @@ media_theme_pref();
|
|||
.catch(() => {
|
||||
// done scrolling, no more pages (http error)
|
||||
wrapper.removeEventListener("scroll", event);
|
||||
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
|
@ -635,7 +639,6 @@ media_theme_pref();
|
|||
return;
|
||||
}
|
||||
|
||||
// biome-ignore lint/style/noParameterAssign: no it isn't
|
||||
page += 1;
|
||||
await load_partial();
|
||||
})
|
||||
|
@ -651,7 +654,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,6 +690,36 @@ 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];
|
||||
|
||||
|
@ -807,7 +840,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);
|
||||
|
@ -850,7 +882,7 @@ media_theme_pref();
|
|||
})();
|
||||
|
||||
// ui ns
|
||||
(() => {
|
||||
(async () => {
|
||||
const self = reg_ns("ui");
|
||||
window.SETTING_SET_FUNCTIONS = [];
|
||||
|
||||
|
@ -888,19 +920,19 @@ media_theme_pref();
|
|||
}
|
||||
|
||||
if (option.input_element_type === "checkbox") {
|
||||
into_element.innerHTML += `<div class="card flex gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
onchange="window.set_setting_field${id_key}('${option.key}', event.target.checked)"
|
||||
placeholder="${option.key}"
|
||||
name="${option.key}"
|
||||
id="${option.key}"
|
||||
${option.value === "true" ? "checked" : ""}
|
||||
class="w-content"
|
||||
/>
|
||||
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)"
|
||||
placeholder="${option.key}"
|
||||
name="${option.key}"
|
||||
id="${option.key}"
|
||||
${option.value === "true" ? "checked" : ""}
|
||||
class="w-content"
|
||||
/>
|
||||
|
||||
<label for="${option.key}"><b>${option.label.replaceAll("_", " ")}</b></label>
|
||||
</div>`;
|
||||
<label for="${option.key}"><b>${option.label.replaceAll("_", " ")}</b></label>
|
||||
</div>`;
|
||||
|
||||
return;
|
||||
}
|
||||
|
@ -1123,51 +1155,99 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
|
|||
});
|
||||
|
||||
// intersection observer infinite scrolling
|
||||
self.IO_DATA_OBSERVER = new IntersectionObserver(
|
||||
async (entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) {
|
||||
continue;
|
||||
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;
|
||||
self.IO_DATA_LOAD_BEFORE = 0;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
await self.io_load_data();
|
||||
break;
|
||||
}
|
||||
},
|
||||
{
|
||||
root: document.body,
|
||||
rootMargin: "0px",
|
||||
threshold: 1,
|
||||
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_data_load", (_, tmpl, page) => {
|
||||
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",
|
||||
);
|
||||
|
||||
self.define("io_load_data", async () => {
|
||||
if (self.IO_DATA_WAITING) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.IO_DATA_TMPL = tmpl;
|
||||
self.IO_DATA_PAGE = page;
|
||||
self.IO_DATA_SEEN_IDS = [];
|
||||
|
||||
self.IO_DATA_OBSERVER.observe(self.IO_DATA_MARKER);
|
||||
});
|
||||
|
||||
self.define("io_load_data", async () => {
|
||||
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);
|
||||
|
||||
|
@ -1177,17 +1257,43 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
|
|||
|
||||
// ...
|
||||
const text = await (
|
||||
await fetch(`${self.IO_DATA_TMPL}${self.IO_DATA_PAGE}`)
|
||||
await fetch(
|
||||
`${self.IO_DATA_TMPL.replace("&before=$1$", `&before=${self.IO_DATA_LOAD_BEFORE}`)}${self.IO_DATA_PAGE}`,
|
||||
)
|
||||
).text();
|
||||
|
||||
self.IO_DATA_ELEMENT.querySelector("[ui_ident=loading_skel]").remove();
|
||||
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} -->`)
|
||||
text.includes(
|
||||
`!<!-- observer_disconnect_${window.BUILD_CODE} -->`,
|
||||
) ||
|
||||
document.documentElement.innerHTML.includes("observer_disconnect")
|
||||
) {
|
||||
console.log("io_data_end; disconnect");
|
||||
self.IO_DATA_OBSERVER.disconnect();
|
||||
self.IO_DATA_ELEMENT.innerHTML += text;
|
||||
|
||||
if (
|
||||
!document.documentElement.innerHTML.includes(
|
||||
"observer_disconnect",
|
||||
)
|
||||
) {
|
||||
self.IO_DATA_ELEMENT.innerHTML += text;
|
||||
}
|
||||
|
||||
self.IO_DATA_DISCONNECTED = true;
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -1199,30 +1305,6 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
|
|||
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 (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(
|
||||
|
@ -1234,11 +1316,13 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
|
|||
if (!self.IO_DATA_SEEN_IDS[v]) {
|
||||
self.IO_DATA_SEEN_IDS.push(v);
|
||||
}
|
||||
|
||||
self.IO_DATA_LOAD_BEFORE = opt.getAttribute("data-created");
|
||||
}
|
||||
}, 150);
|
||||
|
||||
// run hooks
|
||||
const atto = ns("atto");
|
||||
const atto = await ns("atto");
|
||||
|
||||
atto.clean_date_codes();
|
||||
atto.clean_poll_date_codes();
|
||||
|
@ -1290,7 +1374,8 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
|
|||
JSON.stringify(accepted_warnings),
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
setTimeout(async () => {
|
||||
await trigger("me::achievement", ["AcceptProfileWarning"]);
|
||||
window.history.back();
|
||||
}, 100);
|
||||
});
|
||||
|
|
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);
|
||||
}
|
||||
}
|
||||
})();
|
762
crates/app/src/public/js/layout_editor.js
Normal file
762
crates/app/src/public/js/layout_editor.js
Normal file
|
@ -0,0 +1,762 @@
|
|||
/// Copy all the fields from one object to another.
|
||||
function copy_fields(from, to) {
|
||||
for (const field of Object.entries(from)) {
|
||||
to[field[0]] = field[1];
|
||||
}
|
||||
|
||||
return to;
|
||||
}
|
||||
|
||||
/// Simple template components.
|
||||
const COMPONENT_TEMPLATES = {
|
||||
EMPTY_COMPONENT: { component: "empty", options: {}, children: [] },
|
||||
FLEX_DEFAULT: {
|
||||
component: "flex",
|
||||
options: {
|
||||
direction: "row",
|
||||
gap: "2",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
FLEX_SIMPLE_ROW: {
|
||||
component: "flex",
|
||||
options: {
|
||||
direction: "row",
|
||||
gap: "2",
|
||||
width: "full",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
FLEX_SIMPLE_COL: {
|
||||
component: "flex",
|
||||
options: {
|
||||
direction: "col",
|
||||
gap: "2",
|
||||
width: "full",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
FLEX_MOBILE_COL: {
|
||||
component: "flex",
|
||||
options: {
|
||||
collapse: "yes",
|
||||
gap: "2",
|
||||
width: "full",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
MARKDOWN_DEFAULT: {
|
||||
component: "markdown",
|
||||
options: {
|
||||
text: "Hello, world!",
|
||||
},
|
||||
},
|
||||
MARKDOWN_CARD: {
|
||||
component: "markdown",
|
||||
options: {
|
||||
class: "card w-full",
|
||||
text: "Hello, world!",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/// All available components with their label and JSON representation.
|
||||
const COMPONENTS = [
|
||||
[
|
||||
"Markdown block",
|
||||
COMPONENT_TEMPLATES.MARKDOWN_DEFAULT,
|
||||
[["Card", COMPONENT_TEMPLATES.MARKDOWN_CARD]],
|
||||
],
|
||||
[
|
||||
"Flex container",
|
||||
COMPONENT_TEMPLATES.FLEX_DEFAULT,
|
||||
[
|
||||
["Simple rows", COMPONENT_TEMPLATES.FLEX_SIMPLE_ROW],
|
||||
["Simple columns", COMPONENT_TEMPLATES.FLEX_SIMPLE_COL],
|
||||
["Mobile columns", COMPONENT_TEMPLATES.FLEX_MOBILE_COL],
|
||||
],
|
||||
],
|
||||
[
|
||||
"Profile tabs",
|
||||
{
|
||||
component: "tabs",
|
||||
},
|
||||
],
|
||||
[
|
||||
"Profile feeds",
|
||||
{
|
||||
component: "feed",
|
||||
},
|
||||
],
|
||||
[
|
||||
"Profile banner",
|
||||
{
|
||||
component: "banner",
|
||||
},
|
||||
],
|
||||
[
|
||||
"Question box",
|
||||
{
|
||||
component: "ask",
|
||||
},
|
||||
],
|
||||
[
|
||||
"Name & avatar",
|
||||
{
|
||||
component: "name",
|
||||
},
|
||||
],
|
||||
[
|
||||
"About section",
|
||||
{
|
||||
component: "about",
|
||||
},
|
||||
],
|
||||
[
|
||||
"Action buttons",
|
||||
{
|
||||
component: "actions",
|
||||
},
|
||||
],
|
||||
[
|
||||
"CSS stylesheet",
|
||||
{
|
||||
component: "style",
|
||||
options: {
|
||||
data: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
// preload icons
|
||||
trigger("app::icon", ["shapes"]);
|
||||
trigger("app::icon", ["type"]);
|
||||
trigger("app::icon", ["plus"]);
|
||||
trigger("app::icon", ["move-up"]);
|
||||
trigger("app::icon", ["move-down"]);
|
||||
trigger("app::icon", ["trash"]);
|
||||
trigger("app::icon", ["arrow-left"]);
|
||||
trigger("app::icon", ["x"]);
|
||||
|
||||
/// The location of an element as represented by array indexes.
|
||||
class ElementPointer {
|
||||
position = [];
|
||||
|
||||
constructor(element) {
|
||||
if (element) {
|
||||
const pos = [];
|
||||
|
||||
let target = element;
|
||||
while (target.parentElement) {
|
||||
const parent = target.parentElement;
|
||||
|
||||
// push index
|
||||
pos.push(Array.from(parent.children).indexOf(target) || 0);
|
||||
|
||||
// update target
|
||||
if (parent.id === "editor") {
|
||||
break;
|
||||
}
|
||||
|
||||
target = parent;
|
||||
}
|
||||
|
||||
this.position = pos.reverse(); // indexes are added in reverse order because of how we traverse
|
||||
} else {
|
||||
this.position = [];
|
||||
}
|
||||
}
|
||||
|
||||
get() {
|
||||
return this.position;
|
||||
}
|
||||
|
||||
resolve(json, minus = 0) {
|
||||
let out = json;
|
||||
|
||||
if (this.position.length === 1) {
|
||||
// this is the first element (this.position === [0])
|
||||
return out;
|
||||
}
|
||||
|
||||
const pos = this.position.slice(1, this.position.length); // the first one refers to the root element
|
||||
|
||||
for (let i = 0; i < minus; i++) {
|
||||
pos.pop();
|
||||
}
|
||||
|
||||
for (const idx of pos) {
|
||||
const child = ((out || { children: [] }).children || [])[idx];
|
||||
|
||||
if (!child) {
|
||||
break;
|
||||
}
|
||||
|
||||
out = child;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/// The layout editor controller.
|
||||
class LayoutEditor {
|
||||
element;
|
||||
json;
|
||||
tree = "";
|
||||
current = { component: "empty" };
|
||||
pointer = new ElementPointer();
|
||||
|
||||
/// Create a new [`LayoutEditor`].
|
||||
constructor(element, json) {
|
||||
this.element = element;
|
||||
this.json = json;
|
||||
|
||||
if (this.json.json) {
|
||||
delete this.json.json;
|
||||
}
|
||||
|
||||
element.addEventListener("click", (e) => this.click(e, this));
|
||||
element.addEventListener("mouseover", (e) => {
|
||||
e.stopImmediatePropagation();
|
||||
const ptr = new ElementPointer(e.target);
|
||||
|
||||
if (document.getElementById("position")) {
|
||||
document.getElementById(
|
||||
"position",
|
||||
).parentElement.style.display = "flex";
|
||||
|
||||
document.getElementById("position").innerText = ptr
|
||||
.get()
|
||||
.join(".");
|
||||
}
|
||||
});
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
/// Render layout.
|
||||
render() {
|
||||
fetch("/api/v0/auth/render_layout", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
layout: this.json,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((r) => {
|
||||
this.element.innerHTML = r.block;
|
||||
this.tree = r.tree;
|
||||
|
||||
if (this.json.component !== "empty") {
|
||||
// remove all "empty" components (if the root component isn't an empty)
|
||||
for (const element of document.querySelectorAll(
|
||||
'[data-component-name="empty"]',
|
||||
)) {
|
||||
element.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Editor clicked.
|
||||
click(e, self) {
|
||||
e.stopImmediatePropagation();
|
||||
trigger("app::hooks::dropdown.close");
|
||||
|
||||
const ptr = new ElementPointer(e.target);
|
||||
self.current = ptr.resolve(self.json);
|
||||
self.pointer = ptr;
|
||||
|
||||
if (document.getElementById("current_position")) {
|
||||
document.getElementById(
|
||||
"current_position",
|
||||
).parentElement.style.display = "flex";
|
||||
|
||||
document.getElementById("current_position").innerText = ptr
|
||||
.get()
|
||||
.join(".");
|
||||
}
|
||||
|
||||
for (const element of document.querySelectorAll(
|
||||
".layout_editor_block.active",
|
||||
)) {
|
||||
element.classList.remove("active");
|
||||
}
|
||||
|
||||
e.target.classList.add("active");
|
||||
self.screen("element");
|
||||
}
|
||||
|
||||
/// Open sidebar.
|
||||
open() {
|
||||
document.getElementById("editor_sidebar").classList.add("open");
|
||||
document.getElementById("editor").style.transform = "scale(0.8)";
|
||||
}
|
||||
|
||||
/// Close sidebar.
|
||||
close() {
|
||||
document.getElementById("editor_sidebar").style.animation =
|
||||
"0.2s ease-in-out forwards to_left";
|
||||
|
||||
setTimeout(() => {
|
||||
document.getElementById("editor_sidebar").classList.remove("open");
|
||||
document.getElementById("editor_sidebar").style.animation =
|
||||
"0.2s ease-in-out forwards from_right";
|
||||
}, 250);
|
||||
|
||||
document.getElementById("editor").style.transform = "scale(1)";
|
||||
}
|
||||
|
||||
/// Render editor dialog.
|
||||
screen(page = "element", data = {}) {
|
||||
this.current.component = this.current.component.toLowerCase();
|
||||
|
||||
const sidebar = document.getElementById("editor_sidebar");
|
||||
sidebar.innerHTML = "";
|
||||
|
||||
// render page
|
||||
if (
|
||||
page === "add" ||
|
||||
(page === "element" && this.current.component === "empty")
|
||||
) {
|
||||
// add element
|
||||
sidebar.appendChild(
|
||||
(() => {
|
||||
const heading = document.createElement("h3");
|
||||
heading.innerText = data.add_title || "Add component";
|
||||
return heading;
|
||||
})(),
|
||||
);
|
||||
|
||||
sidebar.appendChild(document.createElement("hr"));
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.className = "flex w-full gap-2 flex-wrap";
|
||||
|
||||
for (const component of data.components || COMPONENTS) {
|
||||
container.appendChild(
|
||||
(() => {
|
||||
const button = document.createElement("button");
|
||||
button.classList.add("secondary");
|
||||
|
||||
trigger("app::icon", [
|
||||
data.icon || "shapes",
|
||||
"icon",
|
||||
]).then((icon) => {
|
||||
button.prepend(icon);
|
||||
});
|
||||
|
||||
button.appendChild(
|
||||
(() => {
|
||||
const span = document.createElement("span");
|
||||
span.innerText = `${component[0]}${component[2] ? ` (${component[2].length + 1})` : ""}`;
|
||||
return span;
|
||||
})(),
|
||||
);
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
if (component[2]) {
|
||||
// render presets
|
||||
return this.screen(page, {
|
||||
back: ["add", {}],
|
||||
add_title: "Select preset",
|
||||
components: [
|
||||
["Default", component[1]],
|
||||
...component[2],
|
||||
],
|
||||
icon: "type",
|
||||
});
|
||||
}
|
||||
|
||||
// no presets
|
||||
if (
|
||||
page === "element" &&
|
||||
this.current.component === "empty"
|
||||
) {
|
||||
// replace with component
|
||||
copy_fields(component[1], this.current);
|
||||
} else {
|
||||
// add component to children
|
||||
this.current.children.push(
|
||||
structuredClone(component[1]),
|
||||
);
|
||||
}
|
||||
|
||||
this.render();
|
||||
this.close();
|
||||
});
|
||||
|
||||
return button;
|
||||
})(),
|
||||
);
|
||||
}
|
||||
|
||||
sidebar.appendChild(container);
|
||||
} else if (page === "element") {
|
||||
// edit element
|
||||
const name = document.createElement("div");
|
||||
name.className = "flex flex-col gap-2";
|
||||
|
||||
name.appendChild(
|
||||
(() => {
|
||||
const heading = document.createElement("h3");
|
||||
heading.innerText = `Edit ${this.current.component}`;
|
||||
return heading;
|
||||
})(),
|
||||
);
|
||||
|
||||
name.appendChild(
|
||||
(() => {
|
||||
const pos = document.createElement("div");
|
||||
pos.className = "notification w-content";
|
||||
pos.innerText = this.pointer.get().join(".");
|
||||
return pos;
|
||||
})(),
|
||||
);
|
||||
|
||||
sidebar.appendChild(name);
|
||||
sidebar.appendChild(document.createElement("hr"));
|
||||
|
||||
// options
|
||||
const options = document.createElement("div");
|
||||
options.className = "card flex flex-col gap-2 w-full";
|
||||
|
||||
const add_option = (
|
||||
label_text,
|
||||
name,
|
||||
valid = [],
|
||||
input_element = "input",
|
||||
) => {
|
||||
const card = document.createElement("details");
|
||||
card.className = "w-full";
|
||||
|
||||
const summary = document.createElement("summary");
|
||||
summary.className = "w-full";
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.setAttribute("for", name);
|
||||
label.className = "w-full";
|
||||
label.innerText = label_text;
|
||||
label.style.cursor = "pointer";
|
||||
|
||||
label.addEventListener("click", () => {
|
||||
// bubble to summary click
|
||||
summary.click();
|
||||
});
|
||||
|
||||
const input_box = document.createElement("div");
|
||||
input_box.style.paddingLeft = "1rem";
|
||||
input_box.style.borderLeft =
|
||||
"solid 2px var(--color-super-lowered)";
|
||||
|
||||
const input = document.createElement(input_element);
|
||||
input.id = name;
|
||||
input.setAttribute("name", name);
|
||||
input.setAttribute("type", "text");
|
||||
|
||||
if (input_element === "input") {
|
||||
input.setAttribute(
|
||||
"value",
|
||||
// biome-ignore lint/complexity/useOptionalChain: that's an unsafe change which would possibly break code
|
||||
(this.current.options || {})[name] || "",
|
||||
);
|
||||
} else {
|
||||
// biome-ignore lint/complexity/useOptionalChain: that's an unsafe change which would possibly break code
|
||||
input.innerHTML = (this.current.options || {})[name] || "";
|
||||
}
|
||||
|
||||
// biome-ignore lint/complexity/useOptionalChain: that's an unsafe change which would possibly break code
|
||||
if ((this.current.options || {})[name]) {
|
||||
// open details if a value is set
|
||||
card.setAttribute("open", "");
|
||||
}
|
||||
|
||||
input.addEventListener("change", (e) => {
|
||||
if (
|
||||
valid.length > 0 &&
|
||||
!valid.includes(e.target.value) &&
|
||||
e.target.value.length > 0 // anything can be set to empty
|
||||
) {
|
||||
alert(`Must be one of: ${JSON.stringify(valid)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.current.options) {
|
||||
this.current.options = {};
|
||||
}
|
||||
|
||||
this.current.options[name] =
|
||||
e.target.value === "no" ? "" : e.target.value;
|
||||
});
|
||||
|
||||
summary.appendChild(label);
|
||||
card.appendChild(summary);
|
||||
input_box.appendChild(input);
|
||||
card.appendChild(input_box);
|
||||
options.appendChild(card);
|
||||
};
|
||||
|
||||
sidebar.appendChild(options);
|
||||
|
||||
if (this.current.component === "flex") {
|
||||
add_option("Gap", "gap", ["1", "2", "3", "4"]);
|
||||
add_option("Direction", "direction", ["row", "col"]);
|
||||
add_option("Do collapse", "collapse", ["yes", "no"]);
|
||||
add_option("Width", "width", ["full", "content"]);
|
||||
add_option("Class name", "class");
|
||||
add_option("Unique ID", "id");
|
||||
add_option("Style", "style", [], "textarea");
|
||||
} else if (this.current.component === "markdown") {
|
||||
add_option("Content", "text", [], "textarea");
|
||||
add_option("Class name", "class");
|
||||
} else if (this.current.component === "divider") {
|
||||
add_option("Class name", "class");
|
||||
} else if (this.current.component === "style") {
|
||||
add_option("Style data", "data", [], "textarea");
|
||||
} else {
|
||||
options.remove();
|
||||
}
|
||||
|
||||
// action buttons
|
||||
const buttons = document.createElement("div");
|
||||
buttons.className = "card w-full flex flex-wrap gap-2";
|
||||
|
||||
if (this.current.component === "flex") {
|
||||
buttons.appendChild(
|
||||
(() => {
|
||||
const button = document.createElement("button");
|
||||
|
||||
trigger("app::icon", ["plus", "icon"]).then((icon) => {
|
||||
button.prepend(icon);
|
||||
});
|
||||
|
||||
button.appendChild(
|
||||
(() => {
|
||||
const span = document.createElement("span");
|
||||
span.innerText = "Add child";
|
||||
return span;
|
||||
})(),
|
||||
);
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
this.screen("add");
|
||||
});
|
||||
|
||||
return button;
|
||||
})(),
|
||||
);
|
||||
}
|
||||
|
||||
buttons.appendChild(
|
||||
(() => {
|
||||
const button = document.createElement("button");
|
||||
|
||||
trigger("app::icon", ["move-up", "icon"]).then((icon) => {
|
||||
button.prepend(icon);
|
||||
});
|
||||
|
||||
button.appendChild(
|
||||
(() => {
|
||||
const span = document.createElement("span");
|
||||
span.innerText = "Move up";
|
||||
return span;
|
||||
})(),
|
||||
);
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
const idx = this.pointer.get().pop();
|
||||
const parent_ref = this.pointer.resolve(
|
||||
this.json,
|
||||
).children;
|
||||
|
||||
if (parent_ref[idx - 1] === undefined) {
|
||||
alert("No space to move element.");
|
||||
return;
|
||||
}
|
||||
|
||||
const clone = JSON.parse(JSON.stringify(this.current));
|
||||
const other_clone = JSON.parse(
|
||||
JSON.stringify(parent_ref[idx - 1]),
|
||||
);
|
||||
|
||||
copy_fields(clone, parent_ref[idx - 1]); // move here to here
|
||||
copy_fields(other_clone, parent_ref[idx]); // move there to here
|
||||
|
||||
this.close();
|
||||
this.render();
|
||||
});
|
||||
|
||||
return button;
|
||||
})(),
|
||||
);
|
||||
|
||||
buttons.appendChild(
|
||||
(() => {
|
||||
const button = document.createElement("button");
|
||||
|
||||
trigger("app::icon", ["move-down", "icon"]).then((icon) => {
|
||||
button.prepend(icon);
|
||||
});
|
||||
|
||||
button.appendChild(
|
||||
(() => {
|
||||
const span = document.createElement("span");
|
||||
span.innerText = "Move down";
|
||||
return span;
|
||||
})(),
|
||||
);
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
const idx = this.pointer.get().pop();
|
||||
const parent_ref = this.pointer.resolve(
|
||||
this.json,
|
||||
).children;
|
||||
|
||||
if (parent_ref[idx + 1] === undefined) {
|
||||
alert("No space to move element.");
|
||||
return;
|
||||
}
|
||||
|
||||
const clone = JSON.parse(JSON.stringify(this.current));
|
||||
const other_clone = JSON.parse(
|
||||
JSON.stringify(parent_ref[idx + 1]),
|
||||
);
|
||||
|
||||
copy_fields(clone, parent_ref[idx + 1]); // move here to here
|
||||
copy_fields(other_clone, parent_ref[idx]); // move there to here
|
||||
|
||||
this.close();
|
||||
this.render();
|
||||
});
|
||||
|
||||
return button;
|
||||
})(),
|
||||
);
|
||||
|
||||
buttons.appendChild(
|
||||
(() => {
|
||||
const button = document.createElement("button");
|
||||
button.classList.add("red");
|
||||
|
||||
trigger("app::icon", ["trash", "icon"]).then((icon) => {
|
||||
button.prepend(icon);
|
||||
});
|
||||
|
||||
button.appendChild(
|
||||
(() => {
|
||||
const span = document.createElement("span");
|
||||
span.innerText = "Delete";
|
||||
return span;
|
||||
})(),
|
||||
);
|
||||
|
||||
button.addEventListener("click", async () => {
|
||||
if (
|
||||
!(await trigger("app::confirm", [
|
||||
"Are you sure you would like to do this?",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.json === this.current) {
|
||||
// this is the root element; replace with empty
|
||||
copy_fields(
|
||||
COMPONENT_TEMPLATES.EMPTY_COMPONENT,
|
||||
this.current,
|
||||
);
|
||||
} else {
|
||||
// get parent
|
||||
const idx = this.pointer.get().pop();
|
||||
const ref = this.pointer.resolve(this.json);
|
||||
// remove element
|
||||
ref.children.splice(idx, 1);
|
||||
}
|
||||
|
||||
this.render();
|
||||
this.close();
|
||||
});
|
||||
|
||||
return button;
|
||||
})(),
|
||||
);
|
||||
|
||||
sidebar.appendChild(buttons);
|
||||
} else if (page === "tree") {
|
||||
sidebar.innerHTML = this.tree;
|
||||
}
|
||||
|
||||
sidebar.appendChild(document.createElement("hr"));
|
||||
|
||||
const buttons = document.createElement("div");
|
||||
buttons.className = "flex gap-2 flex-wrap";
|
||||
|
||||
if (data.back) {
|
||||
buttons.appendChild(
|
||||
(() => {
|
||||
const button = document.createElement("button");
|
||||
button.className = "secondary";
|
||||
|
||||
trigger("app::icon", ["arrow-left", "icon"]).then(
|
||||
(icon) => {
|
||||
button.prepend(icon);
|
||||
},
|
||||
);
|
||||
|
||||
button.appendChild(
|
||||
(() => {
|
||||
const span = document.createElement("span");
|
||||
span.innerText = "Back";
|
||||
return span;
|
||||
})(),
|
||||
);
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
this.screen(...data.back);
|
||||
});
|
||||
|
||||
return button;
|
||||
})(),
|
||||
);
|
||||
}
|
||||
|
||||
buttons.appendChild(
|
||||
(() => {
|
||||
const button = document.createElement("button");
|
||||
button.className = "red secondary";
|
||||
|
||||
trigger("app::icon", ["x", "icon"]).then((icon) => {
|
||||
button.prepend(icon);
|
||||
});
|
||||
|
||||
button.appendChild(
|
||||
(() => {
|
||||
const span = document.createElement("span");
|
||||
span.innerText = "Close";
|
||||
return span;
|
||||
})(),
|
||||
);
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
this.render();
|
||||
this.close();
|
||||
});
|
||||
|
||||
return button;
|
||||
})(),
|
||||
);
|
||||
|
||||
sidebar.appendChild(buttons);
|
||||
|
||||
// ...
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
|
||||
define("ElementPointer", ElementPointer);
|
||||
define("LayoutEditor", LayoutEditor);
|
|
@ -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) {
|
||||
return console.error(
|
||||
"namespace does not exist, please use one of the following:",
|
||||
Object.keys(globalThis._app_base.ns_store),
|
||||
);
|
||||
while (!res) {
|
||||
if (tries >= 5) {
|
||||
return console.error(
|
||||
`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",
|
||||
|
@ -300,6 +342,36 @@
|
|||
},
|
||||
);
|
||||
|
||||
self.define("achievement", (_, name) => {
|
||||
return new Promise((resolve) => {
|
||||
fetch("/api/v1/auth/user/me/achievement", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
trigger("atto::toast", [
|
||||
res.ok ? "success" : "error",
|
||||
res.message,
|
||||
]);
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
self.define("achievement_link", async (_, name, href) => {
|
||||
await self.achievement(name);
|
||||
Turbo.visit(href);
|
||||
});
|
||||
|
||||
self.define("report", (_, asset, asset_type) => {
|
||||
window.open(
|
||||
`/mod_panel/file_report?asset=${asset}&asset_type=${asset_type}`,
|
||||
|
@ -360,8 +432,30 @@
|
|||
});
|
||||
});
|
||||
|
||||
self.define("remove_ip_block", async (_, id) => {
|
||||
if (
|
||||
!(await trigger("atto::confirm", [
|
||||
"Are you sure you want to do this?",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/v1/auth/ip/${id}/unblock_ip`, {
|
||||
method: "POST",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger("atto::toast", [
|
||||
res.ok ? "success" : "error",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
self.define("notifications_stream", ({ _, streams }) => {
|
||||
const element = document.getElementById("notifications_span");
|
||||
let current = Number.parseInt(element.innerText || "0");
|
||||
|
||||
streams.subscribe("notifs");
|
||||
streams.event("notifs", "message", (data) => {
|
||||
|
@ -372,13 +466,12 @@
|
|||
|
||||
const inner_data = JSON.parse(data.data);
|
||||
if (data.method.Packet.Crud === "Create") {
|
||||
const current = Number.parseInt(element.innerText || "0");
|
||||
|
||||
if (current <= 0) {
|
||||
element.classList.remove("hidden");
|
||||
}
|
||||
|
||||
element.innerText = current + 1;
|
||||
current += 1;
|
||||
element.innerText = current;
|
||||
|
||||
// check if we're already connected
|
||||
const connected =
|
||||
|
@ -414,16 +507,19 @@
|
|||
console.info("notification created");
|
||||
}
|
||||
} else if (data.method.Packet.Crud === "Delete") {
|
||||
const current = Number.parseInt(element.innerText || "0");
|
||||
|
||||
if (current - 1 <= 0) {
|
||||
element.classList.add("hidden");
|
||||
}
|
||||
|
||||
element.innerText = current - 1;
|
||||
current -= 1;
|
||||
element.innerText = current;
|
||||
} else {
|
||||
console.warn("correct packet type but with wrong data");
|
||||
}
|
||||
|
||||
if (element.innerText !== current) {
|
||||
element.innerText = current;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -489,6 +585,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 +932,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 +1033,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 +1063,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 +1194,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,13 @@
|
|||
},
|
||||
};
|
||||
|
||||
socket.addEventListener("message", (event) => {
|
||||
socket.addEventListener("message", async (event) => {
|
||||
const sock = await $.sock(stream);
|
||||
|
||||
if (!sock) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data === "Ping") {
|
||||
return socket.send("Pong");
|
||||
}
|
||||
|
@ -54,14 +60,14 @@
|
|||
return console.info(`${stream} ${data.data}`);
|
||||
}
|
||||
|
||||
return $.sock(stream).events.message(data);
|
||||
return sock.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 +78,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 +90,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 +103,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;
|
||||
}
|
||||
}
|
||||
|
@ -138,6 +138,15 @@ pub async fn stripe_webhook(
|
|||
return Json(e.into());
|
||||
}
|
||||
|
||||
if data.0.0.security.enable_invite_codes && user.awaiting_purchase {
|
||||
if let Err(e) = data
|
||||
.update_user_awaiting_purchased_status(user.id, false, user.clone(), false)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = data
|
||||
.create_notification(Notification::new(
|
||||
"Welcome new supporter!".to_string(),
|
||||
|
@ -174,6 +183,18 @@ pub async fn stripe_webhook(
|
|||
return Json(e.into());
|
||||
}
|
||||
|
||||
if data.0.0.security.enable_invite_codes && user.was_purchased && user.invite_code == 0
|
||||
{
|
||||
// user doesn't come from an invite code, and is a purchased account
|
||||
// this means their account must be locked if they stop paying
|
||||
if let Err(e) = data
|
||||
.update_user_awaiting_purchased_status(user.id, true, user.clone(), false)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = data
|
||||
.create_notification(Notification::new(
|
||||
"Sorry to see you go... :(".to_string(),
|
||||
|
@ -186,6 +207,58 @@ pub async fn stripe_webhook(
|
|||
return Json(e.into());
|
||||
}
|
||||
}
|
||||
EventType::InvoicePaymentFailed => {
|
||||
// payment failed
|
||||
let subscription = match req.data.object {
|
||||
EventObject::Subscription(c) => c,
|
||||
_ => unreachable!("cannot be this"),
|
||||
};
|
||||
|
||||
let customer_id = subscription.customer.id();
|
||||
|
||||
let user = match data.get_user_by_stripe_id(customer_id.as_str()).await {
|
||||
Ok(ua) => ua,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
"unsubscribe (pay fail) {} (stripe: {})",
|
||||
user.id,
|
||||
customer_id
|
||||
);
|
||||
let new_user_permissions = user.permissions - FinePermission::SUPPORTER;
|
||||
|
||||
if let Err(e) = data
|
||||
.update_user_role(user.id, new_user_permissions, user.clone(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
if data.0.0.security.enable_invite_codes && user.was_purchased && user.invite_code == 0
|
||||
{
|
||||
// user doesn't come from an invite code, and is a purchased account
|
||||
// this means their account must be locked if they stop paying
|
||||
if let Err(e) = data
|
||||
.update_user_awaiting_purchased_status(user.id, true, user.clone(), false)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = data
|
||||
.create_notification(Notification::new(
|
||||
"It seems your recent payment has failed :(".to_string(),
|
||||
"No worries! Your subscription is still active and will be retried. Your supporter status will resume when you have a successful payment."
|
||||
.to_string(),
|
||||
user.id,
|
||||
))
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
}
|
||||
_ => return Json(Error::Unknown.into()),
|
||||
}
|
||||
|
||||
|
|
|
@ -213,10 +213,21 @@ pub async fn upload_avatar_request(
|
|||
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());
|
||||
return Json(Error::FileTooLarge.into());
|
||||
}
|
||||
|
||||
std::fs::write(&path, img.0).unwrap();
|
||||
|
||||
// update user settings
|
||||
auth_user.settings.avatar_mime = "image/gif".to_string();
|
||||
if let Err(e) = data
|
||||
.update_user_settings(auth_user.id, auth_user.settings)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
// ...
|
||||
return Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Avatar uploaded. It might take a bit to update".to_string(),
|
||||
|
@ -226,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
|
||||
|
@ -236,32 +256,12 @@ 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
|
||||
},
|
||||
) {
|
||||
Ok(_) => {
|
||||
// update user settings
|
||||
auth_user.settings.avatar_mime = mime.to_string();
|
||||
if let Err(e) = data
|
||||
.update_user_settings(auth_user.id, auth_user.settings)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
// ...
|
||||
Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Avatar uploaded. It might take a bit to update".to_string(),
|
||||
payload: (),
|
||||
})
|
||||
}
|
||||
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(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(Error::MiscError(e.to_string()).into()),
|
||||
}
|
||||
}
|
||||
|
@ -314,10 +314,21 @@ pub async fn upload_banner_request(
|
|||
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());
|
||||
return Json(Error::FileTooLarge.into());
|
||||
}
|
||||
|
||||
std::fs::write(&path, img.0).unwrap();
|
||||
|
||||
// update user settings
|
||||
auth_user.settings.banner_mime = "image/gif".to_string();
|
||||
if let Err(e) = data
|
||||
.update_user_settings(auth_user.id, auth_user.settings)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
// ...
|
||||
return Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Banner uploaded. It might take a bit to update".to_string(),
|
||||
|
@ -327,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
|
||||
|
@ -337,32 +357,12 @@ 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
|
||||
},
|
||||
) {
|
||||
Ok(_) => {
|
||||
// update user settings
|
||||
auth_user.settings.banner_mime = mime.to_string();
|
||||
if let Err(e) = data
|
||||
.update_user_settings(auth_user.id, auth_user.settings)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
// ...
|
||||
Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Banner uploaded. It might take a bit to update".to_string(),
|
||||
payload: (),
|
||||
})
|
||||
}
|
||||
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(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(Error::MiscError(e.to_string()).into()),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -54,7 +54,7 @@ pub async fn register_request(
|
|||
|
||||
// check for ip ban
|
||||
if data
|
||||
.get_ipban_by_addr(RemoteAddr::from(real_ip.as_str()))
|
||||
.get_ipban_by_addr(&RemoteAddr::from(real_ip.as_str()))
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
|
@ -86,26 +86,86 @@ 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.purchase {
|
||||
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;
|
||||
} else {
|
||||
// this account is being purchased
|
||||
user.awaiting_purchase = true;
|
||||
}
|
||||
}
|
||||
|
||||
// push initial token
|
||||
let (initial_token, t) = User::create_token(&real_ip);
|
||||
user.tokens.push(t);
|
||||
|
||||
// return
|
||||
match data.create_user(user).await {
|
||||
Ok(_) => (
|
||||
Some([(
|
||||
"Set-Cookie",
|
||||
format!(
|
||||
"__Secure-atto-token={}; SameSite=Lax; Secure; Path=/; HostOnly=true; HttpOnly=true; Max-Age={}",
|
||||
initial_token,
|
||||
60 * 60 * 24 * 365
|
||||
),
|
||||
)]),
|
||||
Json(ApiReturn {
|
||||
ok: true,
|
||||
message: initial_token,
|
||||
payload: (),
|
||||
}),
|
||||
),
|
||||
Ok(_) => {
|
||||
// mark invite as used
|
||||
if data.0.0.security.enable_invite_codes && !props.purchase {
|
||||
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!(
|
||||
"__Secure-atto-token={}; SameSite=Lax; Secure; Path=/; HostOnly=true; HttpOnly=true; Max-Age={}",
|
||||
initial_token,
|
||||
60 * 60 * 24 * 365
|
||||
),
|
||||
)]),
|
||||
Json(ApiReturn {
|
||||
ok: true,
|
||||
message: initial_token,
|
||||
payload: (),
|
||||
}),
|
||||
)
|
||||
}
|
||||
Err(e) => (None, Json(e.into())),
|
||||
}
|
||||
}
|
||||
|
@ -134,7 +194,7 @@ pub async fn login_request(
|
|||
|
||||
// check for ip ban
|
||||
if data
|
||||
.get_ipban_by_addr(RemoteAddr::from(real_ip.as_str()))
|
||||
.get_ipban_by_addr(&RemoteAddr::from(real_ip.as_str()))
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
|
|
|
@ -3,8 +3,9 @@ use crate::{
|
|||
get_user_from_token,
|
||||
model::{ApiReturn, Error},
|
||||
routes::api::v1::{
|
||||
AppendAssociations, DeleteUser, DisableTotp, RefreshGrantToken, UpdateUserIsVerified,
|
||||
UpdateUserPassword, UpdateUserRole, UpdateUserUsername,
|
||||
AppendAssociations, AwardAchievement, DeleteUser, DisableTotp, RefreshGrantToken,
|
||||
UpdateSecondaryUserRole, UpdateUserAwaitingPurchase, UpdateUserInviteCode,
|
||||
UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole, UpdateUserUsername,
|
||||
},
|
||||
State,
|
||||
};
|
||||
|
@ -21,7 +22,8 @@ use futures_util::{sink::SinkExt, stream::StreamExt};
|
|||
use tetratto_core::{
|
||||
cache::Cache,
|
||||
model::{
|
||||
auth::{Token, UserSettings},
|
||||
auth::{AchievementName, InviteCode, Token, UserSettings, SELF_SERVE_ACHIEVEMENTS},
|
||||
moderation::AuditLogEntry,
|
||||
oauth,
|
||||
permissions::FinePermission,
|
||||
socket::{PacketType, SocketMessage, SocketMethod},
|
||||
|
@ -30,7 +32,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 +117,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 +152,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(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
// ...
|
||||
match data.update_user_settings(id, req).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
|
@ -185,7 +195,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,
|
||||
|
@ -333,6 +343,34 @@ pub async fn update_user_is_verified_request(
|
|||
}
|
||||
}
|
||||
|
||||
/// Update the verification status of the given user.
|
||||
///
|
||||
/// Does not support third-party grants.
|
||||
pub async fn update_user_awaiting_purchase_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
Json(req): Json<UpdateUserAwaitingPurchase>,
|
||||
) -> 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_awaiting_purchased_status(id, req.awaiting_purchase, user, true)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Awaiting purchase status updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the role of the given user.
|
||||
///
|
||||
/// Does not support third-party grants.
|
||||
|
@ -358,6 +396,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 +459,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
|
||||
|
@ -417,11 +493,20 @@ pub async fn enable_totp_request(
|
|||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data) {
|
||||
let mut user = match get_user_from_token!(jar, data) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
// award achievement
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::Enable2fa.into(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
// ...
|
||||
match data.enable_totp(id, user).await {
|
||||
Ok(x) => Json(ApiReturn {
|
||||
ok: true,
|
||||
|
@ -556,7 +641,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 +754,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 +902,130 @@ 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)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Award an achievement to the current user.
|
||||
/// Only works with specific "self-serve" achievements.
|
||||
pub async fn self_serve_achievement_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Json(req): Json<AwardAchievement>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let mut user = match get_user_from_token!(jar, data) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
if !SELF_SERVE_ACHIEVEMENTS.contains(&req.name) {
|
||||
return Json(Error::MiscError("Cannot grant this achievement manually".to_string()).into());
|
||||
}
|
||||
|
||||
// award achievement
|
||||
match data.add_achievement(&mut user, req.name.into(), true).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Achievement granted".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the verification status of the given user.
|
||||
///
|
||||
/// Does not support third-party grants.
|
||||
pub async fn update_user_invite_code_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Json(req): Json<UpdateUserInviteCode>,
|
||||
) -> 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 req.invite_code.is_empty() {
|
||||
return Json(Error::MiscError("Missing invite code".to_string()).into());
|
||||
}
|
||||
|
||||
let invite_code = match data.get_invite_code_by_code(&req.invite_code).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
if invite_code.is_used {
|
||||
return Json(Error::MiscError("This code has already been used".to_string()).into());
|
||||
}
|
||||
|
||||
if let Err(e) = data.update_invite_code_is_used(invite_code.id, true).await {
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
match data
|
||||
.update_user_invite_code(user.id, invite_code.id as i64)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
match data
|
||||
.update_user_awaiting_purchased_status(user.id, false, user, false)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Invite code updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,7 +11,8 @@ use axum::{
|
|||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use tetratto_core::model::{
|
||||
auth::{FollowResult, IpBlock, Notification, UserBlock, UserFollow},
|
||||
addr::RemoteAddr,
|
||||
auth::{AchievementName, FollowResult, IpBlock, Notification, UserBlock, UserFollow},
|
||||
oauth,
|
||||
};
|
||||
|
||||
|
@ -22,7 +23,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 +41,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 +60,15 @@ pub async fn follow_request(
|
|||
return Json(e.into());
|
||||
};
|
||||
|
||||
// award achievement
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::FollowUser.into(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
// ...
|
||||
Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "User followed".to_string(),
|
||||
|
@ -116,7 +126,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(_) => {
|
||||
|
@ -219,7 +229,10 @@ pub async fn ip_block_request(
|
|||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
if let Ok(ipblock) = data.get_ipblock_by_initiator_receiver(user.id, &ip).await {
|
||||
if let Ok(ipblock) = data
|
||||
.get_ipblock_by_initiator_receiver(user.id, &RemoteAddr::from(ip.as_str()))
|
||||
.await
|
||||
{
|
||||
// delete
|
||||
match data.delete_ipblock(ipblock.id, user).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
|
@ -305,3 +318,64 @@ pub async fn following_request(
|
|||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ip_block_profile_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::UserCreateIpBlock) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
// get other user
|
||||
let other_user = match data.get_user_by_id(id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
for (ip, _, _) in other_user.tokens {
|
||||
// check for an existing ip block
|
||||
if data
|
||||
.get_ipblock_by_initiator_receiver(user.id, &RemoteAddr::from(ip.as_str()))
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// create ip block
|
||||
if let Err(e) = data.create_ipblock(IpBlock::new(user.id, ip)).await {
|
||||
return Json(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "IP(s) blocked".to_string(),
|
||||
payload: (),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn remove_ip_block_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::UserManageBlocks) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.delete_ipblock(id, user).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "IP unblocked".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
|
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(), true)
|
||||
.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) {
|
||||
Some(e) => match e.shortcode() {
|
||||
Some(s) => s.to_string(),
|
||||
None => e.name().replace(" ", "-"),
|
||||
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(),
|
||||
},
|
||||
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()),
|
||||
};
|
||||
|
@ -66,7 +67,7 @@ pub async fn create_request(
|
|||
|
||||
// check for ip ban
|
||||
if data
|
||||
.get_ipban_by_addr(RemoteAddr::from(real_ip.as_str()))
|
||||
.get_ipban_by_addr(&RemoteAddr::from(real_ip.as_str()))
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
|
@ -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(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
if user.post_count >= 49 {
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::Create50Posts.into(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
if user.post_count >= 99 {
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::Create100Posts.into(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
if user.post_count >= 999 {
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::Create1000Posts.into(), true)
|
||||
.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(), true)
|
||||
.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()), props.before)
|
||||
.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);
|
||||
|
@ -42,13 +43,34 @@ pub async fn create_request(
|
|||
|
||||
// check for ip ban
|
||||
if data
|
||||
.get_ipban_by_addr(RemoteAddr::from(real_ip.as_str()))
|
||||
.get_ipban_by_addr(&RemoteAddr::from(real_ip.as_str()))
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
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(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
if drawings.len() > 0 {
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::CreateDrawing.into(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ...
|
||||
let mut props = Question::new(
|
||||
if let Some(ref ua) = user { ua.id } else { 0 },
|
||||
|
@ -70,7 +92,14 @@ pub async fn create_request(
|
|||
}
|
||||
}
|
||||
|
||||
match data.create_question(props).await {
|
||||
if req.mask_owner && !req.is_global {
|
||||
props.context.mask_owner = true;
|
||||
}
|
||||
|
||||
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(),
|
||||
|
@ -120,7 +149,7 @@ pub async fn ip_block_request(
|
|||
|
||||
// check for an existing ip block
|
||||
if data
|
||||
.get_ipblock_by_initiator_receiver(user.id, &question.ip)
|
||||
.get_ipblock_by_initiator_receiver(user.id, &RemoteAddr::from(question.ip.as_str()))
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
|
|
164
crates/app/src/routes/api/v1/domains.rs
Normal file
164
crates/app/src/routes/api/v1/domains.rs
Normal file
|
@ -0,0 +1,164 @@
|
|||
use crate::{
|
||||
get_user_from_token,
|
||||
routes::api::v1::{CreateDomain, UpdateDomainData},
|
||||
State,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query},
|
||||
response::IntoResponse,
|
||||
http::StatusCode,
|
||||
Extension, Json,
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use tetratto_core::model::{littleweb::Domain, oauth, ApiReturn, Error};
|
||||
use serde::Deserialize;
|
||||
|
||||
pub async fn get_request(
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
match data.get_domain_by_id(id).await {
|
||||
Ok(x) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Success".to_string(),
|
||||
payload: Some(x),
|
||||
}),
|
||||
Err(e) => return Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
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::UserReadDomains) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.get_domains_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(req): Json<CreateDomain>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateDomains) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data
|
||||
.create_domain(Domain::new(req.name, req.tld, user.id))
|
||||
.await
|
||||
{
|
||||
Ok(x) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Domain created".to_string(),
|
||||
payload: x.id.to_string(),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_data_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(req): Json<UpdateDomainData>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageDomains) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.update_domain_data(id, &user, req.data).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Domain updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_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::UserManageDomains) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.delete_domain(id, &user).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Domain deleted".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetFileQuery {
|
||||
pub addr: String,
|
||||
}
|
||||
|
||||
pub async fn get_file_request(
|
||||
Extension(data): Extension<State>,
|
||||
Query(props): Query<GetFileQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let (subdomain, domain, tld, path) = Domain::from_str(&props.addr);
|
||||
|
||||
// resolve domain
|
||||
let domain = match data.get_domain_by_name_tld(&domain, &tld).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => {
|
||||
return Err((StatusCode::BAD_REQUEST, e.to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
// resolve service
|
||||
let service = match domain.service(&subdomain) {
|
||||
Some(id) => match data.get_service_by_id(id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => {
|
||||
return Err((StatusCode::BAD_REQUEST, e.to_string()));
|
||||
}
|
||||
},
|
||||
None => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Error::GeneralNotFound("service".to_string()).to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// resolve file
|
||||
match service.file(&path) {
|
||||
Some(f) => Ok((
|
||||
[("Content-Type".to_string(), f.mime.to_string())],
|
||||
f.content,
|
||||
)),
|
||||
None => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Error::GeneralNotFound("file".to_string()).to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,16 +4,23 @@ use axum::{
|
|||
Extension,
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use tetratto_shared::snow::Snowflake;
|
||||
use crate::{
|
||||
get_user_from_token,
|
||||
routes::api::v1::{UpdateJournalPrivacy, CreateJournal, UpdateJournalTitle},
|
||||
routes::api::v1::{
|
||||
AddJournalDir, CreateJournal, RemoveJournalDir, UpdateJournalPrivacy, UpdateJournalTitle,
|
||||
},
|
||||
State,
|
||||
};
|
||||
use tetratto_core::model::{
|
||||
journals::{Journal, JournalPrivacyPermission},
|
||||
oauth,
|
||||
permissions::FinePermission,
|
||||
ApiReturn, Error,
|
||||
use tetratto_core::{
|
||||
database::NAME_REGEX,
|
||||
model::{
|
||||
auth::AchievementName,
|
||||
journals::{Journal, JournalPrivacyPermission},
|
||||
oauth,
|
||||
permissions::FinePermission,
|
||||
ApiReturn, Error,
|
||||
},
|
||||
};
|
||||
|
||||
pub async fn get_request(
|
||||
|
@ -46,6 +53,28 @@ pub async fn get_request(
|
|||
})
|
||||
}
|
||||
|
||||
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) {
|
||||
|
@ -69,7 +98,7 @@ pub async fn create_request(
|
|||
Json(props): Json<CreateJournal>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateJournals) {
|
||||
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateJournals) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
@ -78,11 +107,22 @@ pub async fn create_request(
|
|||
.create_journal(Journal::new(user.id, props.title))
|
||||
.await
|
||||
{
|
||||
Ok(x) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Journal created".to_string(),
|
||||
payload: Some(x.id.to_string()),
|
||||
}),
|
||||
Ok(x) => {
|
||||
// award achievement
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::CreateJournal.into(), true)
|
||||
.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()),
|
||||
}
|
||||
}
|
||||
|
@ -99,7 +139,17 @@ pub async fn update_title_request(
|
|||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
props.title = props.title.replace(" ", "_");
|
||||
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
|
||||
|
@ -163,3 +213,86 @@ pub async fn delete_request(
|
|||
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()),
|
||||
}
|
||||
}
|
||||
|
|
175
crates/app/src/routes/api/v1/layouts.rs
Normal file
175
crates/app/src/routes/api/v1/layouts.rs
Normal file
|
@ -0,0 +1,175 @@
|
|||
use crate::{
|
||||
get_user_from_token,
|
||||
routes::{
|
||||
api::v1::{CreateLayout, UpdateLayoutName, UpdateLayoutPages, UpdateLayoutPrivacy},
|
||||
},
|
||||
State,
|
||||
};
|
||||
use axum::{extract::Path, response::IntoResponse, Extension, Json};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use tetratto_core::{
|
||||
model::{
|
||||
layouts::{Layout, LayoutPrivacy},
|
||||
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::UserReadStacks) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let layout = match data.get_layout_by_id(id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
if layout.privacy == LayoutPrivacy::Public
|
||||
&& user.id != layout.owner
|
||||
&& !user.permissions.check(FinePermission::MANAGE_USERS)
|
||||
{
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Success".to_string(),
|
||||
payload: Some(layout),
|
||||
})
|
||||
}
|
||||
|
||||
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::UserReadLayouts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.get_layouts_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(req): Json<CreateLayout>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateLayouts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data
|
||||
.create_layout(Layout::new(req.name, user.id, req.replaces))
|
||||
.await
|
||||
{
|
||||
Ok(s) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Layout created".to_string(),
|
||||
payload: s.id.to_string(),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_name_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(req): Json<UpdateLayoutName>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLayouts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.update_layout_title(id, &user, &req.name).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Layout updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_privacy_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(req): Json<UpdateLayoutPrivacy>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLayouts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.update_layout_privacy(id, &user, req.privacy).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Layout updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_pages_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(req): Json<UpdateLayoutPages>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLayouts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.update_layout_pages(id, &user, req.pages).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Layout updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_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::UserManageLayouts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.delete_layout(id, &user).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Layout deleted".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
|
@ -2,12 +2,15 @@ pub mod apps;
|
|||
pub mod auth;
|
||||
pub mod channels;
|
||||
pub mod communities;
|
||||
pub mod domains;
|
||||
pub mod journals;
|
||||
pub mod layouts;
|
||||
pub mod notes;
|
||||
pub mod notifications;
|
||||
pub mod reactions;
|
||||
pub mod reports;
|
||||
pub mod requests;
|
||||
pub mod services;
|
||||
pub mod stacks;
|
||||
pub mod uploads;
|
||||
pub mod util;
|
||||
|
@ -19,14 +22,17 @@ use axum::{
|
|||
use serde::Deserialize;
|
||||
use tetratto_core::model::{
|
||||
apps::AppQuota,
|
||||
auth::AchievementName,
|
||||
communities::{
|
||||
CommunityContext, CommunityJoinAccess, CommunityReadAccess, CommunityWriteAccess,
|
||||
PollOption, PostContext,
|
||||
},
|
||||
communities_permissions::CommunityPermission,
|
||||
journals::JournalPrivacyPermission,
|
||||
layouts::{CustomizablePage, LayoutPage, LayoutPrivacy},
|
||||
littleweb::{DomainData, DomainTld, ServiceFsEntry},
|
||||
oauth::AppScope,
|
||||
permissions::FinePermission,
|
||||
permissions::{FinePermission, SecondaryPermission},
|
||||
reactions::AssetType,
|
||||
stacks::{StackMode, StackPrivacy, StackSort},
|
||||
};
|
||||
|
@ -37,10 +43,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}",
|
||||
|
@ -271,6 +294,14 @@ pub fn routes() -> Router {
|
|||
post(auth::social::accept_follow_request),
|
||||
)
|
||||
.route("/auth/user/{id}/block", post(auth::social::block_request))
|
||||
.route(
|
||||
"/auth/user/{id}/block_ip",
|
||||
post(auth::social::ip_block_profile_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/ip/{id}/unblock_ip",
|
||||
post(auth::social::remove_ip_block_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/user/{id}/settings",
|
||||
post(auth::profile::update_user_settings_request),
|
||||
|
@ -279,6 +310,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),
|
||||
|
@ -299,6 +334,10 @@ pub fn routes() -> Router {
|
|||
"/auth/user/{id}/verified",
|
||||
post(auth::profile::update_user_is_verified_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/user/{id}/awaiting_purchase",
|
||||
post(auth::profile::update_user_awaiting_purchase_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/user/{id}/totp",
|
||||
post(auth::profile::enable_totp_request),
|
||||
|
@ -358,6 +397,14 @@ pub fn routes() -> Router {
|
|||
"/auth/user/{id}/grants/{app}/refresh",
|
||||
post(auth::profile::refresh_grant_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/user/me/achievement",
|
||||
post(auth::profile::self_serve_achievement_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/user/me/invite_code",
|
||||
post(auth::profile::update_user_invite_code_request),
|
||||
)
|
||||
// apps
|
||||
.route("/apps", post(apps::create_request))
|
||||
.route("/apps/{id}/title", post(apps::update_title_request))
|
||||
|
@ -551,22 +598,60 @@ pub fn routes() -> Router {
|
|||
.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))
|
||||
// layouts
|
||||
.route("/layouts", get(layouts::list_request))
|
||||
.route("/layouts", post(layouts::create_request))
|
||||
.route("/layouts/{id}", get(layouts::get_request))
|
||||
.route("/layouts/{id}", delete(layouts::delete_request))
|
||||
.route("/layouts/{id}/title", post(layouts::update_name_request))
|
||||
.route(
|
||||
"/layouts/{id}/privacy",
|
||||
post(layouts::update_privacy_request),
|
||||
)
|
||||
.route("/layouts/{id}/pages", post(layouts::update_pages_request))
|
||||
// services
|
||||
.route("/services", get(services::list_request))
|
||||
.route("/services", post(services::create_request))
|
||||
.route("/services/{id}", get(services::get_request))
|
||||
.route("/services/{id}", delete(services::delete_request))
|
||||
.route("/services/{id}/files", post(services::update_files_request))
|
||||
// domains
|
||||
.route("/domains", get(domains::list_request))
|
||||
.route("/domains", post(domains::create_request))
|
||||
.route("/domains/{id}", get(domains::get_request))
|
||||
.route("/domains/{id}", delete(domains::delete_request))
|
||||
.route("/domains/{id}/data", post(domains::update_data_request))
|
||||
}
|
||||
|
||||
pub fn lw_routes() -> Router {
|
||||
Router::new().route("/file", get(domains::get_file_request))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
@ -583,6 +668,14 @@ pub struct RegisterProps {
|
|||
pub password: String,
|
||||
pub policy_consent: bool,
|
||||
pub captcha_response: String,
|
||||
#[serde(default)]
|
||||
pub invite_code: String,
|
||||
/// If this is true, invite_code should be empty.
|
||||
///
|
||||
/// If invite codes are enabled, but purchase is false, the invite_code MUST
|
||||
/// be checked and MUST be valid.
|
||||
#[serde(default)]
|
||||
pub purchase: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
@ -690,6 +783,11 @@ pub struct UpdateUserIsVerified {
|
|||
pub is_verified: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateUserAwaitingPurchase {
|
||||
pub awaiting_purchase: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateNotificationRead {
|
||||
pub read: bool,
|
||||
|
@ -710,6 +808,16 @@ pub struct UpdateUserRole {
|
|||
pub role: FinePermission,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateSecondaryUserRole {
|
||||
pub role: SecondaryPermission,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateUserInviteCode {
|
||||
pub invite_code: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteUser {
|
||||
pub password: String,
|
||||
|
@ -738,6 +846,8 @@ pub struct CreateQuestion {
|
|||
pub receiver: String,
|
||||
#[serde(default)]
|
||||
pub community: String,
|
||||
#[serde(default)]
|
||||
pub mask_owner: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
@ -906,3 +1016,78 @@ pub struct UpdateNoteContent {
|
|||
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>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AwardAchievement {
|
||||
pub name: AchievementName,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateLayout {
|
||||
pub name: String,
|
||||
pub replaces: CustomizablePage,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateLayoutName {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateLayoutPrivacy {
|
||||
pub privacy: LayoutPrivacy,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateLayoutPages {
|
||||
pub pages: Vec<LayoutPage>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateService {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateServiceFiles {
|
||||
pub files: Vec<ServiceFsEntry>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateDomain {
|
||||
pub name: String,
|
||||
pub tld: DomainTld,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateDomainData {
|
||||
pub data: Vec<(String, DomainData)>,
|
||||
}
|
||||
|
|
|
@ -7,15 +7,22 @@ use axum_extra::extract::CookieJar;
|
|||
use tetratto_shared::unix_epoch_timestamp;
|
||||
use crate::{
|
||||
get_user_from_token,
|
||||
routes::api::v1::{CreateNote, RenderMarkdown, UpdateNoteContent, UpdateNoteTitle},
|
||||
routes::api::v1::{
|
||||
CreateNote, RenderMarkdown, UpdateNoteContent, UpdateNoteDir, UpdateNoteTags,
|
||||
UpdateNoteTitle,
|
||||
},
|
||||
State,
|
||||
};
|
||||
use tetratto_core::model::{
|
||||
journals::{JournalPrivacyPermission, Note},
|
||||
oauth,
|
||||
permissions::FinePermission,
|
||||
uploads::CustomEmoji,
|
||||
ApiReturn, Error,
|
||||
use tetratto_core::{
|
||||
database::NAME_REGEX,
|
||||
model::{
|
||||
auth::AchievementName,
|
||||
journals::{JournalPrivacyPermission, Note},
|
||||
oauth,
|
||||
permissions::FinePermission,
|
||||
uploads::CustomEmoji,
|
||||
ApiReturn, Error,
|
||||
},
|
||||
};
|
||||
|
||||
pub async fn get_request(
|
||||
|
@ -135,7 +142,17 @@ pub async fn update_title_request(
|
|||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
props.title = props.title.replace(" ", "_");
|
||||
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
|
||||
|
@ -148,11 +165,21 @@ pub async fn update_title_request(
|
|||
|
||||
// ...
|
||||
match data.update_note_title(id, &user, &props.title).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Note updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
@ -164,11 +191,20 @@ pub async fn update_content_request(
|
|||
Json(props): Json<UpdateNoteContent>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
|
||||
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
// award achievement
|
||||
if let Err(e) = data
|
||||
.add_achievement(&mut user, AchievementName::EditNote.into(), true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
// ...
|
||||
match data.update_note_content(id, &user, &props.content).await {
|
||||
Ok(_) => {
|
||||
if let Err(e) = data
|
||||
|
@ -209,8 +245,185 @@ pub async fn delete_request(
|
|||
}
|
||||
}
|
||||
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
use crate::{State, get_user_from_token, routes::api::v1::CreateReaction};
|
||||
use axum::{Extension, Json, extract::Path, response::IntoResponse};
|
||||
use axum::{
|
||||
extract::Path,
|
||||
http::{HeaderMap, HeaderValue},
|
||||
response::IntoResponse,
|
||||
Extension, Json,
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use tetratto_core::model::{oauth, ApiReturn, Error, reactions::Reaction};
|
||||
use tetratto_core::model::{addr::RemoteAddr, oauth, reactions::Reaction, ApiReturn, Error};
|
||||
|
||||
pub async fn get_request(
|
||||
jar: CookieJar,
|
||||
|
@ -26,6 +31,7 @@ pub async fn get_request(
|
|||
|
||||
pub async fn create_request(
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Extension(data): Extension<State>,
|
||||
Json(req): Json<CreateReaction>,
|
||||
) -> impl IntoResponse {
|
||||
|
@ -40,6 +46,20 @@ pub async fn create_request(
|
|||
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
|
||||
};
|
||||
|
||||
// get real ip
|
||||
let real_ip = headers
|
||||
.get(data.0.0.security.real_ip_header.to_owned())
|
||||
.unwrap_or(&HeaderValue::from_static(""))
|
||||
.to_str()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// check for ip ban
|
||||
let addr = RemoteAddr::from(real_ip.as_str());
|
||||
if data.get_ipban_by_addr(&addr).await.is_ok() {
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
// check for existing reaction
|
||||
if let Ok(r) = data.get_reaction_by_owner_asset(user.id, asset_id).await {
|
||||
match data.delete_reaction(r.id, &user).await {
|
||||
|
@ -63,6 +83,7 @@ pub async fn create_request(
|
|||
.create_reaction(
|
||||
Reaction::new(user.id, asset_id, req.asset_type, req.is_like),
|
||||
&user,
|
||||
&addr,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
|
104
crates/app/src/routes/api/v1/services.rs
Normal file
104
crates/app/src/routes/api/v1/services.rs
Normal file
|
@ -0,0 +1,104 @@
|
|||
use crate::{
|
||||
get_user_from_token,
|
||||
routes::api::v1::{UpdateServiceFiles, CreateService},
|
||||
State,
|
||||
};
|
||||
use axum::{extract::Path, response::IntoResponse, Extension, Json};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use tetratto_core::model::{littleweb::Service, oauth, ApiReturn, Error};
|
||||
|
||||
pub async fn get_request(
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
match data.get_service_by_id(id).await {
|
||||
Ok(x) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Success".to_string(),
|
||||
payload: Some(x),
|
||||
}),
|
||||
Err(e) => return Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
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::UserReadServices) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.get_services_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(req): Json<CreateService>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateServices) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.create_service(Service::new(req.name, user.id)).await {
|
||||
Ok(x) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Service created".to_string(),
|
||||
payload: x.id.to_string(),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_files_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(req): Json<UpdateServiceFiles>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageServices) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.update_service_files(id, &user, req.files).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Service updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_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::UserManageServices) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.delete_service(id, &user).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Service deleted".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
|
@ -211,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>,
|
||||
|
@ -39,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(
|
||||
|
|
|
@ -18,3 +18,5 @@ 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"));
|
||||
serve_asset!(layout_editor_js_request: LAYOUT_EDITOR_JS("text/javascript"));
|
||||
|
|
|
@ -19,10 +19,19 @@ pub fn routes(config: &Config) -> Router {
|
|||
.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))
|
||||
.route(
|
||||
"/js/layout_editor.js",
|
||||
get(assets::layout_editor_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)),
|
||||
|
@ -37,3 +46,14 @@ pub fn routes(config: &Config) -> Router {
|
|||
// pages
|
||||
.merge(pages::routes())
|
||||
}
|
||||
|
||||
/// These routes are only used when you provide the `LITTLEWEB` environment variable.
|
||||
///
|
||||
/// These routes are NOT for editing. These routes are only for viewing littleweb sites.
|
||||
pub fn lw_routes() -> Router {
|
||||
Router::new()
|
||||
// api
|
||||
.nest("/api/v1", api::v1::lw_routes())
|
||||
// pages
|
||||
.merge(pages::lw_routes())
|
||||
}
|
||||
|
|
|
@ -3,14 +3,16 @@ use crate::{
|
|||
assets::initial_context, check_user_blocked_or_private, get_lang, get_user_from_token, State,
|
||||
};
|
||||
use axum::{
|
||||
Extension,
|
||||
extract::{Path, Query},
|
||||
http::{HeaderMap, HeaderValue},
|
||||
response::{Html, IntoResponse},
|
||||
Extension,
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use serde::Deserialize;
|
||||
use tera::Context;
|
||||
use tetratto_core::model::{
|
||||
addr::RemoteAddr,
|
||||
auth::User,
|
||||
communities::Community,
|
||||
communities_permissions::CommunityPermission,
|
||||
|
@ -417,7 +419,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 {
|
||||
|
@ -642,6 +644,7 @@ pub async fn settings_request(
|
|||
/// `/post/{id}`
|
||||
pub async fn post_request(
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<usize>,
|
||||
Query(props): Query<PaginatedQuery>,
|
||||
Extension(data): Extension<State>,
|
||||
|
@ -751,6 +754,46 @@ pub async fn post_request(
|
|||
check_user_blocked_or_private!(user, owner, data, jar);
|
||||
}
|
||||
|
||||
// get real ip
|
||||
let real_ip = headers
|
||||
.get(data.0.0.0.security.real_ip_header.to_owned())
|
||||
.unwrap_or(&HeaderValue::from_static(""))
|
||||
.to_str()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// check for ip ban
|
||||
let addr = RemoteAddr::from(real_ip.as_str());
|
||||
if data.0.get_ipban_by_addr(&addr).await.is_ok() {
|
||||
return Err(Html(
|
||||
render_error(
|
||||
Error::GeneralNotFound("post".to_string()),
|
||||
&jar,
|
||||
&data,
|
||||
&user,
|
||||
)
|
||||
.await,
|
||||
));
|
||||
}
|
||||
|
||||
// check for ip block
|
||||
if data
|
||||
.0
|
||||
.get_ipblock_by_initiator_receiver(post.owner, &addr)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return Err(Html(
|
||||
render_error(
|
||||
Error::GeneralNotFound("post".to_string()),
|
||||
&jar,
|
||||
&data,
|
||||
&user,
|
||||
)
|
||||
.await,
|
||||
));
|
||||
}
|
||||
|
||||
// check repost
|
||||
let (_, reposting) = data.0.get_post_reposting(&post, &ignore_users, &user).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 {
|
||||
|
|
|
@ -81,7 +81,7 @@ pub async fn app_request(
|
|||
};
|
||||
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
|
||||
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);
|
||||
|
@ -89,6 +89,7 @@ pub async fn app_request(
|
|||
context.insert("journal", &journal);
|
||||
context.insert("note", ¬e);
|
||||
|
||||
context.insert("owner", &user);
|
||||
context.insert("journals", &journals);
|
||||
context.insert("notes", ¬es);
|
||||
|
||||
|
@ -116,7 +117,7 @@ pub async fn view_request(
|
|||
}
|
||||
|
||||
// if we don't have a selected journal, we shouldn't be here probably
|
||||
if selected_journal.is_empty() {
|
||||
if selected_journal.is_empty() | (selected_note == "journal.css") {
|
||||
return Err(Html(
|
||||
render_error(Error::NotAllowed, &jar, &data, &user).await,
|
||||
));
|
||||
|
@ -158,14 +159,6 @@ pub async fn view_request(
|
|||
}
|
||||
}
|
||||
|
||||
// ...
|
||||
let notes = 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));
|
||||
}
|
||||
};
|
||||
|
||||
// ...
|
||||
let note = if !selected_note.is_empty() {
|
||||
match data
|
||||
|
@ -193,13 +186,17 @@ pub async fn view_request(
|
|||
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("notes", ¬es);
|
||||
context.insert::<[i8; 0], &str>("notes", &[]);
|
||||
|
||||
context.insert("view_mode", &true);
|
||||
context.insert("is_editor", &false);
|
||||
|
@ -207,3 +204,169 @@ pub async fn view_request(
|
|||
// 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((
|
||||
[(
|
||||
"content-security-policy",
|
||||
"default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' *.cloudflare.com; frame-ancestors *",
|
||||
)],
|
||||
Html(data.1.render("journals/app.html", &context).unwrap()),
|
||||
))
|
||||
}
|
||||
|
|
|
@ -10,7 +10,10 @@ use axum::{
|
|||
use axum_extra::extract::CookieJar;
|
||||
use serde::Deserialize;
|
||||
use tetratto_core::model::{
|
||||
auth::DefaultTimelineChoice, permissions::FinePermission, requests::ActionType, Error,
|
||||
auth::{AchievementName, DefaultTimelineChoice, ACHIEVEMENTS},
|
||||
permissions::FinePermission,
|
||||
requests::ActionType,
|
||||
Error,
|
||||
};
|
||||
use std::fs::read_to_string;
|
||||
use pathbufd::PathBufD;
|
||||
|
@ -331,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;
|
||||
|
||||
|
@ -433,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(), true)
|
||||
.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,
|
||||
|
@ -576,6 +629,12 @@ pub struct TimelineQuery {
|
|||
pub user_id: usize,
|
||||
#[serde(default)]
|
||||
pub tag: String,
|
||||
#[serde(default)]
|
||||
pub paginated: bool,
|
||||
#[serde(default)]
|
||||
pub before: usize,
|
||||
#[serde(default)]
|
||||
pub responses_only: bool,
|
||||
}
|
||||
|
||||
/// `/_swiss_army_timeline`
|
||||
|
@ -623,18 +682,32 @@ pub async fn swiss_army_timeline_request(
|
|||
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, &user)
|
||||
.await
|
||||
if req.responses_only {
|
||||
data.0
|
||||
.get_responses_by_user(req.user_id, 12, req.page)
|
||||
.await
|
||||
} else {
|
||||
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, &user)
|
||||
.await
|
||||
if req.responses_only {
|
||||
data.0
|
||||
.get_responses_by_user_tag(req.user_id, &req.tag, 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).await,
|
||||
DefaultTimelineChoice::AllPosts => {
|
||||
data.0
|
||||
.get_latest_posts(12, req.page, &user, req.before)
|
||||
.await
|
||||
}
|
||||
DefaultTimelineChoice::PopularPosts => {
|
||||
data.0.get_popular_posts(12, req.page, 604_800_000).await
|
||||
}
|
||||
|
@ -697,6 +770,7 @@ pub async fn swiss_army_timeline_request(
|
|||
|
||||
context.insert("list", &list);
|
||||
context.insert("page", &req.page);
|
||||
context.insert("paginated", &req.paginated);
|
||||
Ok(Html(
|
||||
data.1
|
||||
.render("timelines/swiss_army.html", &context)
|
||||
|
|
|
@ -45,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
|
||||
|
@ -131,11 +132,17 @@ 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::view_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 fn lw_routes() -> Router {
|
||||
Router::new()
|
||||
}
|
||||
|
||||
pub async fn render_error(
|
||||
|
@ -154,6 +161,8 @@ pub async fn render_error(
|
|||
pub struct PaginatedQuery {
|
||||
#[serde(default)]
|
||||
pub page: usize,
|
||||
#[serde(default)]
|
||||
pub before: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
@ -174,6 +183,10 @@ pub struct ProfileQuery {
|
|||
pub warning: bool,
|
||||
#[serde(default)]
|
||||
pub tag: String,
|
||||
#[serde(default, alias = "r")]
|
||||
pub responses_only: bool,
|
||||
#[serde(default, alias = "f")]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
@ -196,4 +209,6 @@ pub struct RepostsQuery {
|
|||
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()))
|
||||
}
|
||||
|
|
|
@ -11,7 +11,12 @@ use axum::{
|
|||
use axum_extra::extract::CookieJar;
|
||||
use serde::Deserialize;
|
||||
use tera::Context;
|
||||
use tetratto_core::model::{auth::User, communities::Community, permissions::FinePermission, Error};
|
||||
use tetratto_core::model::{
|
||||
auth::{DefaultProfileTabChoice, User},
|
||||
communities::Community,
|
||||
permissions::FinePermission,
|
||||
Error,
|
||||
};
|
||||
use tetratto_shared::hash::hash;
|
||||
use contrasted::{Color, MINIMUM_CONTRAST_THRESHOLD};
|
||||
|
||||
|
@ -94,6 +99,11 @@ pub async fn settings_request(
|
|||
out
|
||||
};
|
||||
|
||||
let ipblocks = match data.0.get_ipblocks_by_initiator(profile.id).await {
|
||||
Ok(l) => l,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)),
|
||||
};
|
||||
|
||||
let uploads = match data.0.get_uploads_by_owner(profile.id, 12, req.page).await {
|
||||
Ok(ua) => ua,
|
||||
Err(e) => {
|
||||
|
@ -101,6 +111,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);
|
||||
|
@ -113,6 +139,8 @@ pub async fn settings_request(
|
|||
context.insert("following", &following);
|
||||
context.insert("blocks", &blocks);
|
||||
context.insert("stackblocks", &stackblocks);
|
||||
context.insert("ipblocks", &ipblocks);
|
||||
context.insert("invites", &invites);
|
||||
context.insert(
|
||||
"user_tokens_serde",
|
||||
&serde_json::to_string(&tokens)
|
||||
|
@ -229,6 +257,10 @@ pub async fn posts_request(
|
|||
|
||||
check_user_blocked_or_private!(user, other_user, data, jar);
|
||||
|
||||
let responses_only = props.responses_only
|
||||
| (other_user.settings.default_profile_tab == DefaultProfileTabChoice::Responses
|
||||
&& !props.force);
|
||||
|
||||
// check for warning
|
||||
if props.warning {
|
||||
let lang = get_lang!(jar, data.0);
|
||||
|
@ -333,7 +365,13 @@ pub async fn posts_request(
|
|||
);
|
||||
|
||||
// return
|
||||
Ok(Html(data.1.render("profile/posts.html", &context).unwrap()))
|
||||
if responses_only {
|
||||
Ok(Html(
|
||||
data.1.render("profile/responses.html", &context).unwrap(),
|
||||
))
|
||||
} else {
|
||||
Ok(Html(data.1.render("profile/posts.html", &context).unwrap()))
|
||||
}
|
||||
}
|
||||
|
||||
/// `/@{username}/replies`
|
||||
|
|
|
@ -157,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 = "9.0.0"
|
||||
version = "10.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
@ -17,6 +17,6 @@ async-recursion = "1.1.1"
|
|||
md-5 = "0.10.6"
|
||||
base16ct = { version = "0.2.0", features = ["alloc"] }
|
||||
base64 = "0.22.1"
|
||||
emojis = "0.6.4"
|
||||
emojis = "0.7.0"
|
||||
regex = "1.11.1"
|
||||
oiseau = { version = "0.1.2", default-features = false, features = ["postgres", "redis"] }
|
||||
|
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -184,6 +192,8 @@ pub struct StripeConfig {
|
|||
///
|
||||
/// <https://docs.stripe.com/no-code/customer-portal>
|
||||
pub billing_portal_url: String,
|
||||
/// The text representation of the price of supporter. (like `$4 USD`)
|
||||
pub supporter_price_text: String,
|
||||
}
|
||||
|
||||
/// Manuals config (search help, etc)
|
||||
|
@ -242,6 +252,10 @@ pub struct Config {
|
|||
/// so this host should be included in there as well.
|
||||
#[serde(default = "default_host")]
|
||||
pub host: String,
|
||||
/// The main public host of the littleweb server. **Not** used to check against banned hosts,
|
||||
/// so this host should be included in there as well.
|
||||
#[serde(default = "default_lw_host")]
|
||||
pub lw_host: String,
|
||||
/// Database security.
|
||||
#[serde(default = "default_security")]
|
||||
pub security: SecurityConfig,
|
||||
|
@ -309,6 +323,10 @@ fn default_host() -> String {
|
|||
String::new()
|
||||
}
|
||||
|
||||
fn default_lw_host() -> String {
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn default_security() -> SecurityConfig {
|
||||
SecurityConfig::default()
|
||||
}
|
||||
|
@ -341,6 +359,8 @@ fn default_banned_usernames() -> Vec<String> {
|
|||
"stacks".to_string(),
|
||||
"stack".to_string(),
|
||||
"search".to_string(),
|
||||
"journals".to_string(),
|
||||
"links".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
|
@ -373,6 +393,7 @@ impl Default for Config {
|
|||
port: default_port(),
|
||||
banned_hosts: default_banned_hosts(),
|
||||
host: default_host(),
|
||||
lw_host: default_lw_host(),
|
||||
database: default_database(),
|
||||
security: default_security(),
|
||||
dirs: default_dirs(),
|
||||
|
|
|
@ -136,11 +136,11 @@ impl DataManager {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
auto_method!(update_app_title(&str)@get_app_by_id:MANAGE_APPS -> "UPDATE apps SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}");
|
||||
auto_method!(update_app_homepage(&str)@get_app_by_id:MANAGE_APPS -> "UPDATE apps SET homepage = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}");
|
||||
auto_method!(update_app_redirect(&str)@get_app_by_id:MANAGE_APPS -> "UPDATE apps SET redirect = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}");
|
||||
auto_method!(update_app_title(&str)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}");
|
||||
auto_method!(update_app_homepage(&str)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET homepage = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}");
|
||||
auto_method!(update_app_redirect(&str)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET redirect = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}");
|
||||
auto_method!(update_app_quota_status(AppQuota) -> "UPDATE apps SET quota_status = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.app:{}");
|
||||
auto_method!(update_app_scopes(Vec<AppScope>)@get_app_by_id:MANAGE_APPS -> "UPDATE apps SET scopes = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.app:{}");
|
||||
auto_method!(update_app_scopes(Vec<AppScope>)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET scopes = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.app:{}");
|
||||
|
||||
auto_method!(incr_app_grants() -> "UPDATE apps SET grants = grants + 1 WHERE id = $1" --cache-key-tmpl="atto.app:{}" --incr);
|
||||
auto_method!(decr_app_grants()@get_app_by_id -> "UPDATE apps SET grants = grants - 1 WHERE id = $1" --cache-key-tmpl="atto.app:{}" --decr=grants);
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
use super::common::NAME_REGEX;
|
||||
use oiseau::cache::Cache;
|
||||
use crate::model::auth::UserConnections;
|
||||
use crate::model::auth::{
|
||||
Achievement, AchievementName, AchievementRarity, Notification, UserConnections, ACHIEVEMENTS,
|
||||
};
|
||||
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 +18,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 +111,11 @@ 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(),
|
||||
awaiting_purchase: get!(x->24(i32)) as i8 == 1,
|
||||
was_purchased: get!(x->25(i32)) as i8 == 1,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -200,7 +271,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, $25, $26)",
|
||||
params![
|
||||
&(data.id as i64),
|
||||
&(data.created as i64),
|
||||
|
@ -210,19 +281,24 @@ impl DataManager {
|
|||
&serde_json::to_string(&data.settings).unwrap(),
|
||||
&serde_json::to_string(&data.tokens).unwrap(),
|
||||
&(FinePermission::DEFAULT.bits() as i32),
|
||||
&(if data.is_verified { 1_i32 } else { 0_i32 }),
|
||||
&if data.is_verified { 1_i32 } else { 0_i32 },
|
||||
&0_i32,
|
||||
&0_i32,
|
||||
&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(),
|
||||
&if data.awaiting_purchase { 1_i32 } else { 0_i32 },
|
||||
&if data.was_purchased { 1_i32 } else { 0_i32 },
|
||||
]
|
||||
);
|
||||
|
||||
|
@ -389,6 +465,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 +564,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,35 +694,21 @@ impl DataManager {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_user_role(
|
||||
pub async fn update_user_awaiting_purchased_status(
|
||||
&self,
|
||||
id: usize,
|
||||
role: FinePermission,
|
||||
x: bool,
|
||||
user: User,
|
||||
force: bool,
|
||||
require_permission: bool,
|
||||
) -> Result<()> {
|
||||
let other_user = self.get_user_by_id(id).await?;
|
||||
|
||||
if !force {
|
||||
// check permission
|
||||
if (user.id != id) | require_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 other_user = self.get_user_by_id(id).await?;
|
||||
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
|
@ -593,8 +716,8 @@ impl DataManager {
|
|||
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"UPDATE users SET permissions = $1 WHERE id = $2",
|
||||
params![&(role.bits() as i32), &(id as i64)]
|
||||
"UPDATE users SET awaiting_purchase = $1 WHERE id = $2",
|
||||
params![&{ if x { 1 } else { 0 } }, &(id as i64)]
|
||||
);
|
||||
|
||||
if let Err(e) = res {
|
||||
|
@ -604,15 +727,16 @@ impl DataManager {
|
|||
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?;
|
||||
if user.id != other_user.id {
|
||||
self.create_audit_log_entry(AuditLogEntry::new(
|
||||
user.id,
|
||||
format!(
|
||||
"invoked `update_user_purchased_status` with x value `{}` and y value `{}`",
|
||||
other_user.id, x
|
||||
),
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
|
||||
// ...
|
||||
Ok(())
|
||||
|
@ -639,6 +763,84 @@ impl DataManager {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Add an achievement to a user.
|
||||
///
|
||||
/// Still returns `Ok` if the user already has the achievement.
|
||||
#[async_recursion::async_recursion]
|
||||
pub async fn add_achievement(
|
||||
&self,
|
||||
user: &mut User,
|
||||
achievement: Achievement,
|
||||
check_for_final: bool,
|
||||
) -> 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?;
|
||||
|
||||
// check for final
|
||||
if check_for_final {
|
||||
if user.achievements.len() + 1 == ACHIEVEMENTS {
|
||||
self.add_achievement(user, AchievementName::GetAllOtherAchievements.into(), false)
|
||||
.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 +979,20 @@ 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!(update_user_invite_code(i64)@get_user_by_id -> "UPDATE users SET invite_code = $1 WHERE id = $2" --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 +1013,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);
|
||||
}
|
||||
|
|
|
@ -317,10 +317,10 @@ impl DataManager {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
auto_method!(update_channel_title(&str)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}");
|
||||
auto_method!(update_channel_position(i32)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET position = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}");
|
||||
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_title(&str)@get_channel_by_id:FinePermission::MANAGE_CHANNELS; -> "UPDATE channels SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}");
|
||||
auto_method!(update_channel_position(i32)@get_channel_by_id:FinePermission::MANAGE_CHANNELS; -> "UPDATE channels SET position = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}");
|
||||
auto_method!(update_channel_minimum_role_read(i32)@get_channel_by_id:FinePermission::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:FinePermission::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:FinePermission::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_\-\.,!]+";
|
||||
|
||||
|
@ -38,6 +38,11 @@ impl DataManager {
|
|||
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();
|
||||
execute!(&conn, common::CREATE_TABLE_LAYOUTS).unwrap();
|
||||
execute!(&conn, common::CREATE_TABLE_DOMAINS).unwrap();
|
||||
execute!(&conn, common::CREATE_TABLE_SERVICES).unwrap();
|
||||
|
||||
self.0
|
||||
.1
|
||||
|
@ -50,6 +55,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]
|
||||
|
@ -114,7 +139,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()));
|
||||
|
@ -203,12 +229,12 @@ macro_rules! auto_method {
|
|||
}
|
||||
};
|
||||
|
||||
($name:ident()@$select_fn:ident:$permission:ident -> $query:literal) => {
|
||||
($name:ident()@$select_fn:ident:$permission:expr; -> $query:literal) => {
|
||||
pub async fn $name(&self, id: usize, user: &User) -> Result<()> {
|
||||
let y = self.$select_fn(id).await?;
|
||||
|
||||
if user.id != y.owner {
|
||||
if !user.permissions.check(FinePermission::$permission) {
|
||||
if !user.permissions.check($permission) {
|
||||
return Err(Error::NotAllowed);
|
||||
} else {
|
||||
self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new(
|
||||
|
@ -233,12 +259,12 @@ macro_rules! auto_method {
|
|||
}
|
||||
};
|
||||
|
||||
($name:ident()@$select_fn:ident:$permission:ident -> $query:literal --cache-key-tmpl=$cache_key_tmpl:literal) => {
|
||||
($name:ident()@$select_fn:ident:$permission:expr; -> $query:literal --cache-key-tmpl=$cache_key_tmpl:literal) => {
|
||||
pub async fn $name(&self, id: usize, user: &User) -> Result<()> {
|
||||
let y = self.$select_fn(id).await?;
|
||||
|
||||
if user.id != y.owner {
|
||||
if !user.permissions.check(FinePermission::$permission) {
|
||||
if !user.permissions.check($permission) {
|
||||
return Err(Error::NotAllowed);
|
||||
} else {
|
||||
self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new(
|
||||
|
@ -265,12 +291,12 @@ macro_rules! auto_method {
|
|||
}
|
||||
};
|
||||
|
||||
($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal) => {
|
||||
($name:ident($x:ty)@$select_fn:ident:$permission:expr; -> $query:literal) => {
|
||||
pub async fn $name(&self, id: usize, user: &User, x: $x) -> Result<()> {
|
||||
let y = self.$select_fn(id).await?;
|
||||
|
||||
if user.id != y.owner {
|
||||
if !user.permissions.check(FinePermission::$permission) {
|
||||
if !user.permissions.check($permission) {
|
||||
return Err(Error::NotAllowed);
|
||||
} else {
|
||||
self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new(
|
||||
|
@ -296,12 +322,12 @@ macro_rules! auto_method {
|
|||
}
|
||||
};
|
||||
|
||||
($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal --cache-key-tmpl=$cache_key_tmpl:literal) => {
|
||||
($name:ident($x:ty)@$select_fn:ident:$permission:expr; -> $query:literal --cache-key-tmpl=$cache_key_tmpl:literal) => {
|
||||
pub async fn $name(&self, id: usize, user: &User, x: $x) -> Result<()> {
|
||||
let y = self.$select_fn(id).await?;
|
||||
|
||||
if user.id != y.owner {
|
||||
if !user.permissions.check(FinePermission::$permission) {
|
||||
if !user.permissions.check($permission) {
|
||||
return Err(Error::NotAllowed);
|
||||
} else {
|
||||
self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new(
|
||||
|
@ -329,12 +355,12 @@ macro_rules! auto_method {
|
|||
}
|
||||
};
|
||||
|
||||
($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal --serde) => {
|
||||
($name:ident($x:ty)@$select_fn:ident:$permission:expr; -> $query:literal --serde) => {
|
||||
pub async fn $name(&self, id: usize, user: &User, x: $x) -> Result<()> {
|
||||
let y = self.$select_fn(id).await?;
|
||||
|
||||
if user.id != y.owner {
|
||||
if !user.permissions.check(FinePermission::$permission) {
|
||||
if !user.permissions.check($permission) {
|
||||
return Err(Error::NotAllowed);
|
||||
} else {
|
||||
self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new(
|
||||
|
@ -364,20 +390,17 @@ macro_rules! auto_method {
|
|||
}
|
||||
};
|
||||
|
||||
($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal --serde --cache-key-tmpl=$cache_key_tmpl:literal) => {
|
||||
($name:ident($x:ty)@$select_fn:ident:$permission:expr; -> $query:literal --serde --cache-key-tmpl=$cache_key_tmpl:literal) => {
|
||||
pub async fn $name(&self, id: usize, user: &User, x: $x) -> Result<()> {
|
||||
let y = self.$select_fn(id).await?;
|
||||
|
||||
if user.id != y.owner {
|
||||
if !user.permissions.check(FinePermission::$permission) {
|
||||
if !user.permissions.check($permission) {
|
||||
return Err(Error::NotAllowed);
|
||||
} else {
|
||||
self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new(
|
||||
user.id,
|
||||
format!(
|
||||
"invoked `{}` with x value `{id}` and y value `{x:?}`",
|
||||
stringify!($name)
|
||||
),
|
||||
format!("invoked `{}` with x value `{id}`", stringify!($name)),
|
||||
))
|
||||
.await?
|
||||
}
|
||||
|
@ -547,12 +570,12 @@ macro_rules! auto_method {
|
|||
}
|
||||
};
|
||||
|
||||
($name:ident()@$select_fn:ident:$permission:ident -> $query:literal --cache-key-tmpl=$cache_key_tmpl:ident) => {
|
||||
($name:ident()@$select_fn:ident:$permission:expr; -> $query:literal --cache-key-tmpl=$cache_key_tmpl:ident) => {
|
||||
pub async fn $name(&self, id: usize, user: &User) -> Result<()> {
|
||||
let y = self.$select_fn(id).await?;
|
||||
|
||||
if user.id != y.owner {
|
||||
if !user.permissions.check(FinePermission::$permission) {
|
||||
if !user.permissions.check($permission) {
|
||||
return Err(Error::NotAllowed);
|
||||
} else {
|
||||
self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new(
|
||||
|
@ -580,12 +603,12 @@ macro_rules! auto_method {
|
|||
}
|
||||
};
|
||||
|
||||
($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal --cache-key-tmpl=$cache_key_tmpl:ident) => {
|
||||
($name:ident($x:ty)@$select_fn:ident:$permission:expr; -> $query:literal --cache-key-tmpl=$cache_key_tmpl:ident) => {
|
||||
pub async fn $name(&self, id: usize, user: &User, x: $x) -> Result<()> {
|
||||
let y = self.$select_fn(id).await?;
|
||||
|
||||
if user.id != y.owner {
|
||||
if !user.permissions.check(FinePermission::$permission) {
|
||||
if !user.permissions.check($permission) {
|
||||
return Err(Error::NotAllowed);
|
||||
} else {
|
||||
self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new(
|
||||
|
@ -659,12 +682,12 @@ macro_rules! auto_method {
|
|||
}
|
||||
};
|
||||
|
||||
($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal --serde --cache-key-tmpl=$cache_key_tmpl:ident) => {
|
||||
($name:ident($x:ty)@$select_fn:ident:$permission:expr; -> $query:literal --serde --cache-key-tmpl=$cache_key_tmpl:ident) => {
|
||||
pub async fn $name(&self, id: usize, user: &User, x: $x) -> Result<()> {
|
||||
let y = self.$select_fn(id).await?;
|
||||
|
||||
if user.id != y.owner {
|
||||
if !user.permissions.check(FinePermission::$permission) {
|
||||
if !user.permissions.check($permission) {
|
||||
return Err(Error::NotAllowed);
|
||||
} else {
|
||||
self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new(
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
@ -522,10 +521,10 @@ impl DataManager {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
auto_method!(update_community_context(CommunityContext)@get_community_by_id_no_void:MANAGE_COMMUNITIES -> "UPDATE communities SET context = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
|
||||
auto_method!(update_community_read_access(CommunityReadAccess)@get_community_by_id_no_void:MANAGE_COMMUNITIES -> "UPDATE communities SET read_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
|
||||
auto_method!(update_community_write_access(CommunityWriteAccess)@get_community_by_id_no_void:MANAGE_COMMUNITIES -> "UPDATE communities SET write_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
|
||||
auto_method!(update_community_join_access(CommunityJoinAccess)@get_community_by_id_no_void:MANAGE_COMMUNITIES -> "UPDATE communities SET join_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
|
||||
auto_method!(update_community_context(CommunityContext)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET context = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
|
||||
auto_method!(update_community_read_access(CommunityReadAccess)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET read_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
|
||||
auto_method!(update_community_write_access(CommunityWriteAccess)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET write_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
|
||||
auto_method!(update_community_join_access(CommunityJoinAccess)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET join_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
|
||||
|
||||
auto_method!(incr_community_likes()@get_community_by_id_no_void -> "UPDATE communities SET likes = likes + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr);
|
||||
auto_method!(incr_community_dislikes()@get_community_by_id_no_void -> "UPDATE communities SET dislikes = dislikes + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr);
|
||||
|
|
153
crates/core/src/database/domains.rs
Normal file
153
crates/core/src/database/domains.rs
Normal file
|
@ -0,0 +1,153 @@
|
|||
use crate::model::{
|
||||
auth::User,
|
||||
littleweb::{Domain, DomainData, DomainTld},
|
||||
permissions::{FinePermission, SecondaryPermission},
|
||||
Error, Result,
|
||||
};
|
||||
use crate::{auto_method, DataManager};
|
||||
use oiseau::{cache::Cache, execute, get, params, query_row, query_rows, PostgresRow};
|
||||
|
||||
impl DataManager {
|
||||
/// Get a [`Domain`] from an SQL row.
|
||||
pub(crate) fn get_domain_from_row(x: &PostgresRow) -> Domain {
|
||||
Domain {
|
||||
id: get!(x->0(i64)) as usize,
|
||||
created: get!(x->1(i64)) as usize,
|
||||
owner: get!(x->2(i64)) as usize,
|
||||
name: get!(x->3(String)),
|
||||
tld: (get!(x->4(String)).as_str()).into(),
|
||||
data: serde_json::from_str(&get!(x->5(String))).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
auto_method!(get_domain_by_id(usize as i64)@get_domain_from_row -> "SELECT * FROM domains WHERE id = $1" --name="domain" --returns=Domain --cache-key-tmpl="atto.domain:{}");
|
||||
|
||||
/// Get a domain given its name and TLD.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `name`
|
||||
/// * `tld`
|
||||
pub async fn get_domain_by_name_tld(&self, name: &str, tld: &DomainTld) -> Result<Domain> {
|
||||
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 domains WHERE name = $1 AND tld = $2",
|
||||
&[&name, &tld.to_string()],
|
||||
|x| { Ok(Self::get_domain_from_row(x)) }
|
||||
);
|
||||
|
||||
if res.is_err() {
|
||||
return Err(Error::GeneralNotFound("domain".to_string()));
|
||||
}
|
||||
|
||||
Ok(res.unwrap())
|
||||
}
|
||||
|
||||
/// Get all domains by user.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `id` - the ID of the user to fetch domains for
|
||||
pub async fn get_domains_by_user(&self, id: usize) -> Result<Vec<Domain>> {
|
||||
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 domains WHERE owner = $1 ORDER BY created DESC",
|
||||
&[&(id as i64)],
|
||||
|x| { Self::get_domain_from_row(x) }
|
||||
);
|
||||
|
||||
if res.is_err() {
|
||||
return Err(Error::GeneralNotFound("domain".to_string()));
|
||||
}
|
||||
|
||||
Ok(res.unwrap())
|
||||
}
|
||||
|
||||
/// Create a new domain in the database.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - a mock [`Domain`] object to insert
|
||||
pub async fn create_domain(&self, data: Domain) -> Result<Domain> {
|
||||
// check values
|
||||
if data.name.len() < 2 {
|
||||
return Err(Error::DataTooShort("name".to_string()));
|
||||
} else if data.name.len() > 128 {
|
||||
return Err(Error::DataTooLong("name".to_string()));
|
||||
}
|
||||
|
||||
// check for existing
|
||||
if self
|
||||
.get_domain_by_name_tld(&data.name, &data.tld)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return Err(Error::MiscError(
|
||||
"Domain + TLD already in use. Maybe try another TLD!".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 domains VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
params![
|
||||
&(data.id as i64),
|
||||
&(data.created as i64),
|
||||
&(data.owner as i64),
|
||||
&data.name,
|
||||
&data.tld.to_string(),
|
||||
&serde_json::to_string(&data.data).unwrap(),
|
||||
]
|
||||
);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
pub async fn delete_domain(&self, id: usize, user: &User) -> Result<()> {
|
||||
let domain = self.get_domain_by_id(id).await?;
|
||||
|
||||
// check user permission
|
||||
if user.id != domain.owner
|
||||
&& !user
|
||||
.secondary_permissions
|
||||
.check(SecondaryPermission::MANAGE_DOMAINS)
|
||||
{
|
||||
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 domains WHERE id = $1", &[&(id as i64)]);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
// ...
|
||||
self.0.1.remove(format!("atto.domain:{}", id)).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
auto_method!(update_domain_data(Vec<(String, DomainData)>)@get_domain_by_id:FinePermission::MANAGE_USERS; -> "UPDATE domains SET data = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.domain:{}");
|
||||
}
|
|
@ -25,3 +25,8 @@ 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");
|
||||
pub const CREATE_TABLE_LAYOUTS: &str = include_str!("./sql/create_layouts.sql");
|
||||
pub const CREATE_TABLE_DOMAINS: &str = include_str!("./sql/create_domains.sql");
|
||||
pub const CREATE_TABLE_SERVICES: &str = include_str!("./sql/create_services.sql");
|
||||
|
|
8
crates/core/src/database/drivers/sql/create_domains.sql
Normal file
8
crates/core/src/database/drivers/sql/create_domains.sql
Normal file
|
@ -0,0 +1,8 @@
|
|||
CREATE TABLE IF NOT EXISTS domains (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
created BIGINT NOT NULL,
|
||||
owner BIGINT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
tld TEXT NOT NULL,
|
||||
data TEXT 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
|
||||
)
|
|
@ -3,5 +3,6 @@ CREATE TABLE IF NOT EXISTS journals (
|
|||
created BIGINT NOT NULL,
|
||||
owner BIGINT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
privacy TEXT NOT NULL
|
||||
privacy TEXT NOT NULL,
|
||||
dirs TEXT NOT NUll
|
||||
)
|
||||
|
|
9
crates/core/src/database/drivers/sql/create_layouts.sql
Normal file
9
crates/core/src/database/drivers/sql/create_layouts.sql
Normal file
|
@ -0,0 +1,9 @@
|
|||
CREATE TABLE IF NOT EXISTS layouts (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
created BIGINT NOT NULL,
|
||||
owner BIGINT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
privacy TEXT NOT NULL,
|
||||
pages TEXT NOT NULL,
|
||||
replaces 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
|
||||
)
|
||||
|
|
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