From 3738a5cd1f729a57d9843dbd53cc3be305c9e880 Mon Sep 17 00:00:00 2001 From: trisua Date: Mon, 4 Aug 2025 12:57:00 -0400 Subject: [PATCH 01/38] fix: forum signatures --- crates/app/src/public/html/communities/create_post.lisp | 7 ++++++- crates/app/src/public/html/communities/topic.lisp | 2 +- crates/app/src/public/html/profile/settings.lisp | 2 +- crates/app/src/routes/api/v1/communities/communities.rs | 1 - crates/app/src/routes/pages/communities.rs | 3 +++ 5 files changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/app/src/public/html/communities/create_post.lisp b/crates/app/src/public/html/communities/create_post.lisp index 09b55f8..b2eae97 100644 --- a/crates/app/src/public/html/communities/create_post.lisp +++ b/crates/app/src/public/html/communities/create_post.lisp @@ -134,7 +134,12 @@ ("placeholder" "content") ("minlength" "2") ("maxlength" "4096") - (text "{% if draft -%}{{ draft.content }}{%- endif %}"))) + (text "{% if draft -%}{{ draft.content }}{%- endif %}") + (text "{%- if use_signature %}") + (text " + +{{ user.settings.forum_signature }}") + (text "{%- endif %}"))) (div ("id" "files_list") ("class" "flex gap_2 flex_wrap")) diff --git a/crates/app/src/public/html/communities/topic.lisp b/crates/app/src/public/html/communities/topic.lisp index 817c1b0..ce14857 100644 --- a/crates/app/src/public/html/communities/topic.lisp +++ b/crates/app/src/public/html/communities/topic.lisp @@ -10,7 +10,7 @@ (div ("class" "flex gap_2") (a - ("href" "/communities/intents/post?community={{ community.id }}&topic={{ topic_id }}") + ("href" "/communities/intents/post?community={{ community.id }}&topic={{ topic_id }}&sig=true") ("class" "button small lowered") ("data-turbo" "false") (icon (text "plus")) diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 52d41d8..0b7abad 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -1954,7 +1954,7 @@ \"textarea\", ], [ - [\"forum_signature\", \"Forum signature (coming soon)\"], + [\"forum_signature\", \"Forum signature\"], settings.forum_signature, \"textarea\", ], diff --git a/crates/app/src/routes/api/v1/communities/communities.rs b/crates/app/src/routes/api/v1/communities/communities.rs index 52d83da..39bfea5 100644 --- a/crates/app/src/routes/api/v1/communities/communities.rs +++ b/crates/app/src/routes/api/v1/communities/communities.rs @@ -12,7 +12,6 @@ use tetratto_core::model::{ permissions::FinePermission, ApiReturn, Error, }; - use crate::{ get_user_from_token, routes::api::v1::{ diff --git a/crates/app/src/routes/pages/communities.rs b/crates/app/src/routes/pages/communities.rs index 94590f8..246667d 100644 --- a/crates/app/src/routes/pages/communities.rs +++ b/crates/app/src/routes/pages/communities.rs @@ -266,6 +266,8 @@ pub struct CreatePostProps { pub from_draft: usize, #[serde(default)] pub quote: usize, + #[serde(default, alias = "sig")] + pub use_signature: bool, } /// `/communities/intents/post` @@ -359,6 +361,7 @@ pub async fn create_post_request( context.insert("communities", &communities); context.insert("selected_stack", &props.stack); context.insert("selected_community", &props.community); + context.insert("use_signature", &props.use_signature); // return Ok(Html( From 8c779b2f2ef232a54a09d1bc459e7df837346de9 Mon Sep 17 00:00:00 2001 From: trisua Date: Mon, 4 Aug 2025 13:23:27 -0400 Subject: [PATCH 02/38] add: individual topic write permissions --- .../src/public/html/communities/settings.lisp | 33 +++++++++++++++---- .../src/public/html/communities/topic.lisp | 2 ++ .../routes/api/v1/communities/communities.rs | 9 ++++- crates/app/src/routes/api/v1/mod.rs | 2 ++ crates/core/src/database/posts.rs | 32 +++++++++++++++++- crates/core/src/model/communities.rs | 11 ++++++- 6 files changed, 80 insertions(+), 9 deletions(-) diff --git a/crates/app/src/public/html/communities/settings.lisp b/crates/app/src/public/html/communities/settings.lisp index 239d3cc..29b171e 100644 --- a/crates/app/src/public/html/communities/settings.lisp +++ b/crates/app/src/public/html/communities/settings.lisp @@ -686,12 +686,12 @@ (div ("class" "flex flex_col gap_1") (label - ("for" "description") + ("for" "{{ id }}-description") (str (text "communities:label.description"))) (input ("type" "text") ("name" "description") - ("id" "description") + ("id" "{{ id }}-description") ("placeholder" "description") ("value" "{{ topic.description }}") ("required" "") @@ -700,12 +700,12 @@ (div ("class" "flex flex_col gap_1") (label - ("for" "color") + ("for" "{{ id }}-color") (str (text "communities:label.color"))) (input ("type" "color") ("name" "color") - ("id" "color") + ("id" "{{ id }}-color") ("placeholder" "color") ("required" "") ("value" "{{ topic.color }}") @@ -713,17 +713,37 @@ (div ("class" "flex flex_col gap_1") (label - ("for" "position") + ("for" "{{ id }}-position") (str (text "communities:label.position"))) (input ("type" "number") ("name" "position") - ("id" "position") + ("id" "{{ id }}-position") ("placeholder" "position") ("required" "") ("value" "{{ topic.position }}") ("min" "0") ("max" "256"))) + (div + ("class" "flex flex_col gap_1") + (label + ("for" "{{ id }}-write_access") + (text "Post permission")) + (select + ("name" "write_access") + ("id" "{{ id }}-write_access") + (option + ("value" "Everybody") + ("selected" "{% if topic.write_access == 'Everybody' -%}true{% else %}false{%- endif %}") + (text "Everybody")) + (option + ("value" "Joined") + ("selected" "{% if topic.write_access == 'Joined' -%}true{% else %}false{%- endif %}") + (text "Joined")) + (option + ("value" "Owner") + ("selected" "{% if topic.write_access == 'Owner' -%}true{% else %}false{%- endif %}") + (text "Owner only")))) (button (icon (text "check")) (str (text "general:action.save"))))))) @@ -794,6 +814,7 @@ description: e.target.description.value, color: e.target.color.value, position: Number.parseInt(e.target.position.value), + write_access: e.target.write_access.selectedOptions[0].value, }), }) .then((res) => res.json()) diff --git a/crates/app/src/public/html/communities/topic.lisp b/crates/app/src/public/html/communities/topic.lisp index ce14857..08ed014 100644 --- a/crates/app/src/public/html/communities/topic.lisp +++ b/crates/app/src/public/html/communities/topic.lisp @@ -9,6 +9,7 @@ (text "{{ components::topic_display(id=topic_id, topic=topic, community=community, show_description=false) }}") (div ("class" "flex gap_2") + (text "{% if can_post -%}") (a ("href" "/communities/intents/post?community={{ community.id }}&topic={{ topic_id }}&sig=true") ("class" "button small lowered") @@ -16,6 +17,7 @@ (icon (text "plus")) (span (str (text "general:action.post")))) + (text "{%- endif %}") (a ("href" "/community/{{ community.title }}") ("class" "button lowered small") diff --git a/crates/app/src/routes/api/v1/communities/communities.rs b/crates/app/src/routes/api/v1/communities/communities.rs index 39bfea5..066c8a5 100644 --- a/crates/app/src/routes/api/v1/communities/communities.rs +++ b/crates/app/src/routes/api/v1/communities/communities.rs @@ -560,7 +560,13 @@ pub async fn add_topic_request( } // ... - let (topic_id, topic) = ForumTopic::new(req.title, req.description, req.color, req.position); + let (topic_id, topic) = ForumTopic::new( + req.title, + req.description, + req.color, + req.position, + community.write_access, + ); community.topics.insert(topic_id, topic); match data @@ -616,6 +622,7 @@ pub async fn update_topic_request( description: req.description, color: req.color, position: req.position, + write_access: req.write_access, }; community.topics.insert(topic_id, topic); diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 5f553a6..2b148df 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -797,6 +797,8 @@ pub struct AddTopic { pub color: String, #[serde(default)] pub position: i32, + #[serde(default)] + pub write_access: CommunityWriteAccess, } #[derive(Deserialize)] diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index a3e7336..bd326f9 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -1783,6 +1783,28 @@ impl DataManager { } } + /// Check if the given `uid` can post in the given `community` with the given `access`. + pub async fn check_can_post_with_access( + &self, + community: &Community, + access: &CommunityWriteAccess, + uid: usize, + ) -> bool { + match *access { + CommunityWriteAccess::Owner => uid == community.owner, + CommunityWriteAccess::Joined => { + match self + .get_membership_by_owner_community(uid, community.id) + .await + { + Ok(m) => m.role.check_member(), + Err(_) => false, + } + } + _ => true, + } + } + /// Create a new post in the database. /// /// # Arguments @@ -1840,7 +1862,15 @@ impl DataManager { )); } - if community.topics.get(&data.topic).is_none() { + if let Some(topic) = community.topics.get(&data.topic) { + // check permission + if !self + .check_can_post_with_access(&community, &topic.write_access, data.owner) + .await + { + return Err(Error::NotAllowed); + } + } else { return Err(Error::GeneralNotFound("topic".to_string())); } } diff --git a/crates/core/src/model/communities.rs b/crates/core/src/model/communities.rs index 2108847..5b5a869 100644 --- a/crates/core/src/model/communities.rs +++ b/crates/core/src/model/communities.rs @@ -541,6 +541,8 @@ pub struct ForumTopic { pub description: String, pub color: String, pub position: i32, + #[serde(default)] + pub write_access: CommunityWriteAccess, } impl ForumTopic { @@ -549,7 +551,13 @@ impl ForumTopic { /// # Returns /// * ID for [`Community`] hashmap /// * [`ForumTopic`] - pub fn new(title: String, description: String, color: String, position: i32) -> (usize, Self) { + pub fn new( + title: String, + description: String, + color: String, + position: i32, + write_access: CommunityWriteAccess, + ) -> (usize, Self) { ( Snowflake::new().to_string().parse::().unwrap(), Self { @@ -557,6 +565,7 @@ impl ForumTopic { description, color, position, + write_access, }, ) } From d4ff681310e2ff933725c054957fcb2b923ff203 Mon Sep 17 00:00:00 2001 From: trisua Date: Mon, 4 Aug 2025 14:24:25 -0400 Subject: [PATCH 03/38] add: forum posts timeline --- crates/app/src/assets.rs | 3 + crates/app/src/langs/en-US.toml | 1 + crates/app/src/public/html/components.lisp | 12 +++- crates/app/src/public/html/macros.lisp | 16 +++-- crates/app/src/public/html/timelines/all.lisp | 5 +- .../html/timelines/all_forum_posts.lisp | 24 ++++++++ .../public/html/timelines/all_questions.lisp | 4 +- crates/app/src/routes/pages/misc.rs | 58 +++++++++++++++++-- crates/app/src/routes/pages/mod.rs | 2 + crates/core/src/database/posts.rs | 51 ++++++++++++++++ 10 files changed, 159 insertions(+), 17 deletions(-) create mode 100644 crates/app/src/public/html/timelines/all_forum_posts.lisp diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 3ab9d32..6f9addf 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -103,6 +103,8 @@ pub const TIMELINES_ALL_QUESTIONS: &str = include_str!("./public/html/timelines/all_questions.lisp"); pub const TIMELINES_SEARCH: &str = include_str!("./public/html/timelines/search.lisp"); pub const TIMELINES_SWISS_ARMY: &str = include_str!("./public/html/timelines/swiss_army.lisp"); +pub const TIMELINES_ALL_FORUM_POSTS: &str = + include_str!("./public/html/timelines/all_forum_posts.lisp"); pub const MOD_AUDIT_LOG: &str = include_str!("./public/html/mod/audit_log.lisp"); pub const MOD_REPORTS: &str = include_str!("./public/html/mod/reports.lisp"); @@ -336,6 +338,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"timelines/all_questions.html"(crate::assets::TIMELINES_ALL_QUESTIONS) --config=config --lisp plugins); write_template!(html_path->"timelines/search.html"(crate::assets::TIMELINES_SEARCH) --config=config --lisp plugins); write_template!(html_path->"timelines/swiss_army.html"(crate::assets::TIMELINES_SWISS_ARMY) --config=config --lisp plugins); + write_template!(html_path->"timelines/all_forum_posts.html"(crate::assets::TIMELINES_ALL_FORUM_POSTS) --config=config --lisp plugins); write_template!(html_path->"mod/audit_log.html"(crate::assets::MOD_AUDIT_LOG) -d "mod" --config=config --lisp plugins); write_template!(html_path->"mod/reports.html"(crate::assets::MOD_REPORTS) --config=config --lisp plugins); diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index fdd6ffc..e70e78b 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -116,6 +116,7 @@ version = "1.0.0" "communities:label.content" = "Content" "communities:label.title" = "Title" "communities:label.posts" = "Posts" +"communities:label.forum_posts" = "Forum posts" "communities:label.topics" = "Topics" "communities:label.questions" = "Questions" "communities:label.not_allowed_to_read" = "You're not allowed to view this community's posts" diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 55d3bf1..d25fec0 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -2636,8 +2636,18 @@ (text "{%- endif %}") (text "{%- endmacro %}") -(text "{% macro topic_post_display(post, owner, is_pinned=false) -%}") +(text "{% macro topic_post_display(post, owner, is_pinned=false, community=false) -%}") (tr + (text "{% if community %}") + (td + (a + ("href" "/community/{{ community.title }}/topic/{{ post.topic }}") + ("class" "flex gap_1 items_center") + (text "{{ self::community_avatar(id=post.community, community=community) }}") + (span + (text "{% if community.context.display_name -%} {{ community.context.display_name }} {% else %} {{ community.title }} {%- endif %}")))) + (text "{%- endif %}") + (td ("class" "flex gap_1") (a diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp index 2a49ecf..6efc6eb 100644 --- a/crates/app/src/public/html/macros.lisp +++ b/crates/app/src/public/html/macros.lisp @@ -165,7 +165,7 @@ (text "{%- endif %}"))) (text "{%- endmacro %}") -(text "{% macro timelines_nav(selected=\"\", posts=\"\", questions=\"\", secondary_selected=\"posts\") -%}") +(text "{% macro timelines_nav(selected=\"\", posts=\"\", questions=\"\", secondary_selected=\"posts\", forum_posts=\"\") -%}") (div ("class" "mobile_nav mobile") ; primary nav @@ -184,7 +184,7 @@ (text "{% if posts and questions -%}") ; secondary nav - (text "{{ macros::timelines_secondary_nav(posts=posts, questions=questions, selected=secondary_selected) }}") + (text "{{ macros::timelines_secondary_nav(posts=posts, questions=questions, selected=secondary_selected, forum_posts=forum_posts) }}") (text "{%- endif %}")) (div @@ -194,7 +194,7 @@ ; secondary nav desktop only (text "{% if posts and questions -%}") - (text "{{ macros::timelines_secondary_nav(posts=posts, questions=questions, selected=secondary_selected) }}") + (text "{{ macros::timelines_secondary_nav(posts=posts, questions=questions, selected=secondary_selected, forum_posts=forum_posts) }}") (text "{%- endif %}")) (text "{%- endmacro %}") @@ -252,7 +252,7 @@ (text "{%- endif %}"))) (text "{%- endmacro %}") -(text "{% macro timelines_secondary_nav(posts=\"\", questions=\"\", selected=\"posts\") -%} {% if user -%}") +(text "{% macro timelines_secondary_nav(posts=\"\", questions=\"\", selected=\"posts\", forum_posts=\"\") -%} {% if user -%}") (div ("class" "pillmenu w_full") (a @@ -261,6 +261,14 @@ (icon (text "newspaper")) (span (str (text "communities:label.posts")))) + (text "{% if forum_posts|length > 0 -%}") + (a + ("href" "{{ forum_posts }}") + ("class" "{% if selected == 'forum_posts' -%}active{%- endif %}") + (icon (text "list-tree")) + (span (str (text "communities:label.forum_posts")))) + (text "{%- endif %}") + (a ("href" "{{ questions }}") ("class" "{% if selected == 'questions' -%}active{%- endif %}") diff --git a/crates/app/src/public/html/timelines/all.lisp b/crates/app/src/public/html/timelines/all.lisp index 4609e25..b3235b9 100644 --- a/crates/app/src/public/html/timelines/all.lisp +++ b/crates/app/src/public/html/timelines/all.lisp @@ -1,11 +1,10 @@ (text "{% extends \"root.html\" %} {% block head %}") (title (text "Latest posts - {{ config.name }}")) - (text "{% endblock %} {% block body %} {{ macros::nav() }}") (main ("class" "flex flex_col gap_2") - (text "{{ macros::timelines_nav(selected=\"all\", posts=\"/all\", questions=\"/all/questions\") }} {% if not user -%}") + (text "{{ macros::timelines_nav(selected=\"all\", posts=\"/all\", questions=\"/all/questions\", forum_posts=\"/all/forum_posts\") }} {% if not user -%}") (div ("class" "card_nest") (div @@ -32,11 +31,9 @@ ("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(() => { trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=AllPosts&before=$1$&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]); });")) - (text "{% endblock %}") diff --git a/crates/app/src/public/html/timelines/all_forum_posts.lisp b/crates/app/src/public/html/timelines/all_forum_posts.lisp new file mode 100644 index 0000000..62ba0cf --- /dev/null +++ b/crates/app/src/public/html/timelines/all_forum_posts.lisp @@ -0,0 +1,24 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "Latest forum posts - {{ config.name }}")) +(text "{% endblock %} {% block body %} {{ macros::nav() }}") +(main + ("class" "flex flex_col gap_2") + (text "{{ macros::timelines_nav(selected=\"all\", posts=\"/all\", questions=\"/all/questions\", secondary_selected=\"forum_posts\", forum_posts=\"/all/forum_posts\") }}") + (div + ("class" "card w_full flex flex_col gap_2") + (div + ("class" "w_full") + ("style" "overflow: auto") + (table + ("class" "w_full") + (thead + (th (text "In")) + (th (text "Title")) + (th (text "Replies")) + (th (text "Score")) + (th (text "Created"))) + (tbody + (text "{% for post in feed %} {{ components::topic_post_display(post=post[0], owner=post[1], community=post[2]) }} {% endfor %}")))) + (text "{{ components::pagination(page=page, items=feed|length) }}"))) +(text "{% endblock %}") diff --git a/crates/app/src/public/html/timelines/all_questions.lisp b/crates/app/src/public/html/timelines/all_questions.lisp index 70243ee..00626de 100644 --- a/crates/app/src/public/html/timelines/all_questions.lisp +++ b/crates/app/src/public/html/timelines/all_questions.lisp @@ -1,13 +1,11 @@ (text "{% extends \"root.html\" %} {% block head %}") (title (text "Latest questions - {{ config.name }}")) - (text "{% endblock %} {% block body %} {{ macros::nav() }}") (main ("class" "flex flex_col gap_2") - (text "{{ macros::timelines_nav(selected=\"all\", posts=\"/all\", questions=\"/all/questions\", secondary_selected=\"questions\") }}") + (text "{{ macros::timelines_nav(selected=\"all\", posts=\"/all\", questions=\"/all/questions\", secondary_selected=\"questions\", forum_posts=\"/all/forum_posts\") }}") (div ("class" "card w_full flex flex_col gap_2") (text "{% for question in list %} {{ components::global_question(question=question, can_manage_questions=false, secondary=true) }} {% endfor %} {{ components::pagination(page=page, items=list|length) }}"))) - (text "{% endblock %}") diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs index 7ee2f72..a4d6ad5 100644 --- a/crates/app/src/routes/pages/misc.rs +++ b/crates/app/src/routes/pages/misc.rs @@ -47,8 +47,8 @@ pub async fn index_request( // i'm only changing this for stripe let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0.0, lang, &None).await; - context.insert("page", &req.page); + Html(data.1.render("timelines/all.html", &context).unwrap()) }; } @@ -79,6 +79,7 @@ pub async fn index_request( context.insert("list", &list); context.insert("page", &req.page); + Html(data.1.render("timelines/home.html", &context).unwrap()) } @@ -93,8 +94,8 @@ pub async fn popular_request( let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0.0, lang, &user).await; - context.insert("page", &req.page); + Html(data.1.render("timelines/popular.html", &context).unwrap()) } @@ -116,8 +117,8 @@ pub async fn following_request( let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; - context.insert("page", &req.page); + Ok(Html( data.1.render("timelines/following.html", &context).unwrap(), )) @@ -134,8 +135,8 @@ pub async fn all_request( let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0.0, lang, &user).await; - context.insert("page", &req.page); + Html(data.1.render("timelines/all.html", &context).unwrap()) } @@ -172,6 +173,7 @@ pub async fn index_questions_request( context.insert("list", &list); context.insert("page", &req.page); + Html( data.1 .render("timelines/home_questions.html", &context) @@ -212,6 +214,7 @@ pub async fn popular_questions_request( context.insert("list", &list); context.insert("page", &req.page); + Html( data.1 .render("timelines/popular_questions.html", &context) @@ -254,6 +257,7 @@ pub async fn following_questions_request( context.insert("list", &list); context.insert("page", &req.page); + Ok(Html( data.1 .render("timelines/following_questions.html", &context) @@ -271,7 +275,6 @@ pub async fn all_questions_request( let user = get_user_from_token!(jar, data.0); let ignore_users = crate::ignore_users_gen!(user, data); - let list = match data.0.get_latest_global_questions(12, req.page).await { Ok(l) => match data.0.fill_questions(l, &ignore_users).await { Ok(l) => l, @@ -285,6 +288,7 @@ pub async fn all_questions_request( context.insert("list", &list); context.insert("page", &req.page); + Html( data.1 .render("timelines/all_questions.html", &context) @@ -292,6 +296,50 @@ pub async fn all_questions_request( ) } +/// `/all/forum_posts` +pub async fn all_forum_posts_request( + jar: CookieJar, + Extension(data): Extension, + Query(req): Query, +) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => { + return Html(render_error(Error::NotAllowed, &jar, &data, &None).await); + } + }; + + let ignore_users = crate::ignore_users_gen!(user!, data); + let list = match data + .0 + .get_latest_forum_posts(48, req.page, &Some(user.clone()), req.before) + .await + { + Ok(l) => match data + .0 + .fill_posts_with_community(l, user.id, &ignore_users, &Some(user.clone())) + .await + { + Ok(l) => l, + Err(e) => return Html(render_error(e, &jar, &data, &Some(user)).await), + }, + Err(e) => return 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("page", &req.page); + context.insert("feed", &list); + + Html( + data.1 + .render("timelines/all_forum_posts.html", &context) + .unwrap(), + ) +} + #[derive(Deserialize)] pub struct NotificationsProps { #[serde(default)] diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index ec993fc..b077d1c 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -41,6 +41,8 @@ pub fn routes() -> Router { get(misc::following_questions_request), ) .route("/all/questions", get(misc::all_questions_request)) + // forum post timelines + .route("/all/forum_posts", get(misc::all_forum_posts_request)) // misc .route("/notifs", get(misc::notifications_request)) .route("/requests", get(misc::requests_request)) diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index bd326f9..1e856dd 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -1606,6 +1606,57 @@ impl DataManager { Ok(res.unwrap()) } + /// Get forum posts from all communities, sorted by creation. + /// + /// # Arguments + /// * `batch` - the limit of posts in each page + /// * `page` - the page number + pub async fn get_latest_forum_posts( + &self, + batch: usize, + page: usize, + as_user: &Option, + before_time: usize, + ) -> Result> { + // check if we should hide nsfw posts + let mut hide_nsfw: bool = true; + + if let Some(ua) = as_user { + hide_nsfw = !ua.settings.show_nsfw; + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + &format!( + "SELECT * FROM posts WHERE replying_to = 0{}{} AND NOT context LIKE '%\"full_unlist\":true%' AND NOT topic = 0 ORDER BY created DESC LIMIT $1 OFFSET $2", + if before_time > 0 { + format!(" AND created < {before_time}") + } else { + String::new() + }, + if hide_nsfw { + " AND NOT context LIKE '%\"is_nsfw\":true%'" + } else { + "" + } + ), + &[&(batch as i64), &((page * batch) as i64)], + |x| { Self::get_post_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("post".to_string())); + } + + Ok(res.unwrap()) + } + /// Get posts from all communities the given user is in. /// /// # Arguments From 548a6dcf4e111ae1e9b224f651839d7f87ad57fb Mon Sep 17 00:00:00 2001 From: trisua Date: Mon, 4 Aug 2025 14:58:36 -0400 Subject: [PATCH 04/38] add: town square forum config --- crates/app/src/langs/en-US.toml | 1 + .../public/html/communities/create_post.lisp | 22 ++++++++++++++++--- .../src/public/html/communities/topic.lisp | 2 +- crates/app/src/public/html/components.lisp | 1 + .../html/timelines/all_forum_posts.lisp | 10 +++++++++ .../routes/api/v1/communities/communities.rs | 12 +++++++++- crates/app/src/routes/pages/communities.rs | 21 ++++++++++++++++++ crates/core/src/config.rs | 9 ++++++++ 8 files changed, 73 insertions(+), 5 deletions(-) diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index e70e78b..1949204 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -118,6 +118,7 @@ version = "1.0.0" "communities:label.posts" = "Posts" "communities:label.forum_posts" = "Forum posts" "communities:label.topics" = "Topics" +"communities:label.topic" = "Topic" "communities:label.questions" = "Questions" "communities:label.not_allowed_to_read" = "You're not allowed to view this community's posts" "communities:label.might_need_to_join" = "You might need to join this community in order to interact with it!" diff --git a/crates/app/src/public/html/communities/create_post.lisp b/crates/app/src/public/html/communities/create_post.lisp index b2eae97..6f83326 100644 --- a/crates/app/src/public/html/communities/create_post.lisp +++ b/crates/app/src/public/html/communities/create_post.lisp @@ -109,6 +109,23 @@ ("class" "card flex flex_col gap_2") ("id" "create_form") ("onsubmit" "create_post_from_form(event)") + (text "{% if show_topics -%}") + (div + ("class" "flex flex_col gap_1") + (label + ("for" "topic") + (str (text "communities:label.topic"))) + (select + ("id" "topic") + ("name" "topic") + (text "{% for id, topic in topics %}") + (option + ("value" "{{ id }}") + ("selected" "{% if selected_topic|int == id|int -%}true{% else %}false{%- endif %}") + (text "{{ topic.title }}")) + (text "{% endfor %}"))) + (text "{%- endif %}") + (div ("class" "flex flex_col gap_1 hidden") ("id" "title_field") @@ -168,8 +185,7 @@ (text "{{ text \"communities:action.create\" }}")))))) (text "{% if not quoting -%}") (script - (text "globalThis.SEARCH_PARAMS = new URLSearchParams(window.location.search); - async function create_post_from_form(e) { + (text "async function create_post_from_form(e) { e.preventDefault(); await trigger(\"atto::debounce\", [\"posts::create\"]); @@ -210,7 +226,7 @@ content: e.target.content.value, community: !is_selected_stack ? selected_community : \"0\", stack: is_selected_stack ? selected_community : \"0\", - topic: !is_selected_stack ? SEARCH_PARAMS.get(\"topic\") || \"0\" : \"0\", + topic: e.target.topic.selectedOptions[0].value, poll: poll_data[1], title: e.target.title.value, }), diff --git a/crates/app/src/public/html/communities/topic.lisp b/crates/app/src/public/html/communities/topic.lisp index 08ed014..dba3c24 100644 --- a/crates/app/src/public/html/communities/topic.lisp +++ b/crates/app/src/public/html/communities/topic.lisp @@ -11,7 +11,7 @@ ("class" "flex gap_2") (text "{% if can_post -%}") (a - ("href" "/communities/intents/post?community={{ community.id }}&topic={{ topic_id }}&sig=true") + ("href" "/communities/intents/post?community={{ community.id }}&topic={{ topic_id }}&sig=true&topics=true") ("class" "button small lowered") ("data-turbo" "false") (icon (text "plus")) diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index d25fec0..505901a 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -574,6 +574,7 @@ (div ("class" "flex flex_col gap_2") ("style" "flex: 1 0 auto") + (b ("class" "no_p_margin") (text "{{ post.title|markdown|safe }}")) (span ("class" "no_p_margin") (text "{{ post.content|markdown|safe }}")) (text "{{ self::post_media(upload_ids=post.uploads) }}") (text "{% if poll -%} {{ self::poll(post=post, poll=poll) }} {%- endif %}")) diff --git a/crates/app/src/public/html/timelines/all_forum_posts.lisp b/crates/app/src/public/html/timelines/all_forum_posts.lisp index 62ba0cf..7a171fb 100644 --- a/crates/app/src/public/html/timelines/all_forum_posts.lisp +++ b/crates/app/src/public/html/timelines/all_forum_posts.lisp @@ -7,6 +7,16 @@ (text "{{ macros::timelines_nav(selected=\"all\", posts=\"/all\", questions=\"/all/questions\", secondary_selected=\"forum_posts\", forum_posts=\"/all/forum_posts\") }}") (div ("class" "card w_full flex flex_col gap_2") + (text "{% if config.town_square_forum != 0 -%}") + (a + ("href" "/communities/intents/post?community={{ config.town_square_forum }}&topic={{ config.town_square_forum_topic }}&sig=true&topics=true") + ("class" "button small lowered") + ("data-turbo" "false") + (icon (text "plus")) + (span + (str (text "general:action.post")))) + (text "{%- endif %}") + (div ("class" "w_full") ("style" "overflow: auto") diff --git a/crates/app/src/routes/api/v1/communities/communities.rs b/crates/app/src/routes/api/v1/communities/communities.rs index 066c8a5..afa9481 100644 --- a/crates/app/src/routes/api/v1/communities/communities.rs +++ b/crates/app/src/routes/api/v1/communities/communities.rs @@ -119,7 +119,7 @@ pub async fn update_context_request( jar: CookieJar, Extension(data): Extension, Path(id): Path, - Json(req): Json, + Json(mut req): Json, ) -> impl IntoResponse { let data = &(data.read().await).0; let user = match get_user_from_token!(jar, data, oauth::AppScope::CommunityManage) { @@ -127,6 +127,16 @@ pub async fn update_context_request( None => return Json(Error::NotAllowed.into()), }; + let community = match data.get_community_by_id_no_void(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if community.is_forge || community.is_forum { + req.context.enable_titles = true; + req.context.require_titles = true; + } + // check lengths if req.context.display_name.len() > 32 { return Json(Error::DataTooLong("display name".to_string()).into()); diff --git a/crates/app/src/routes/pages/communities.rs b/crates/app/src/routes/pages/communities.rs index 246667d..2f0d03c 100644 --- a/crates/app/src/routes/pages/communities.rs +++ b/crates/app/src/routes/pages/communities.rs @@ -266,8 +266,12 @@ pub struct CreatePostProps { pub from_draft: usize, #[serde(default)] pub quote: usize, + #[serde(default)] + pub topic: usize, #[serde(default, alias = "sig")] pub use_signature: bool, + #[serde(default, alias = "topics")] + pub show_topics: bool, } /// `/communities/intents/post` @@ -350,6 +354,20 @@ pub async fn create_post_request( None }; + // fetch topics + let topics = if props.show_topics { + if props.community != 0 { + match data.0.get_community_by_id_no_void(props.community).await { + Ok(x) => x.topics, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + } + } else { + HashMap::new() + } + } else { + HashMap::new() + }; + // ... let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; @@ -361,7 +379,10 @@ pub async fn create_post_request( context.insert("communities", &communities); context.insert("selected_stack", &props.stack); context.insert("selected_community", &props.community); + context.insert("selected_topic", &props.topic); context.insert("use_signature", &props.use_signature); + context.insert("topics", &topics); + context.insert("show_topics", &props.show_topics); // return Ok(Html( diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index aa845f6..4924714 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -308,6 +308,13 @@ pub struct Config { /// This community **must** have open write access. #[serde(default)] pub town_square: usize, + /// The ID of the town square forum community. + #[serde(default)] + pub town_square_forum: usize, + /// The ID of the topic within the town square forum community that users are prompted + /// to post in by default. This should be some sort of "general" topic. + #[serde(default)] + pub town_square_forum_topic: usize, #[serde(default)] pub connections: ConnectionsConfig, /// The path to the HTML footer file. The contents of this file are embedded @@ -430,6 +437,8 @@ impl Default for Config { policies: default_policies(), turnstile: default_turnstile(), town_square: 0, + town_square_forum: 0, + town_square_forum_topic: 0, connections: default_connections(), html_footer_path: String::new(), stripe: None, From 80a39e74893c42dbdfc703c5359d570914a04225 Mon Sep 17 00:00:00 2001 From: trisua Date: Mon, 4 Aug 2025 15:06:08 -0400 Subject: [PATCH 05/38] fix: posts without topic --- crates/app/src/public/html/communities/create_post.lisp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/app/src/public/html/communities/create_post.lisp b/crates/app/src/public/html/communities/create_post.lisp index 6f83326..3d1b965 100644 --- a/crates/app/src/public/html/communities/create_post.lisp +++ b/crates/app/src/public/html/communities/create_post.lisp @@ -226,7 +226,7 @@ content: e.target.content.value, community: !is_selected_stack ? selected_community : \"0\", stack: is_selected_stack ? selected_community : \"0\", - topic: e.target.topic.selectedOptions[0].value, + topic: (e.target.topic || { selectedOptions: [{ value: \"0\" }] }).selectedOptions[0].value, poll: poll_data[1], title: e.target.title.value, }), From 9650c0177e9ec2739d2dc8dfd76d3818b7ab5e1b Mon Sep 17 00:00:00 2001 From: trisua Date: Mon, 4 Aug 2025 23:29:24 -0400 Subject: [PATCH 06/38] fix: trim forum post titles --- .../public/html/communities/create_post.lisp | 19 ++++++++++++------- crates/app/src/public/html/components.lisp | 2 +- .../routes/api/v1/communities/communities.rs | 4 ++-- crates/core/src/database/apps.rs | 2 +- crates/core/src/database/communities.rs | 2 +- crates/core/src/database/domains.rs | 2 +- crates/core/src/database/posts.rs | 2 +- crates/core/src/database/products.rs | 2 +- crates/core/src/database/services.rs | 2 +- crates/core/src/database/stacks.rs | 2 +- 10 files changed, 22 insertions(+), 17 deletions(-) diff --git a/crates/app/src/public/html/communities/create_post.lisp b/crates/app/src/public/html/communities/create_post.lisp index 3d1b965..454a868 100644 --- a/crates/app/src/public/html/communities/create_post.lisp +++ b/crates/app/src/public/html/communities/create_post.lisp @@ -473,14 +473,19 @@ } setTimeout(() => { - update_community_avatar({ - target: document.getElementById(\"community_to_post_to\"), - }); + const fake_select = { + parentElement: document.getElementById(\"community_to_post_to\").parentElement, + selectedOptions: [{ + value: \"{{ selected_community }}\", + getAttribute() { + return \"{{ selected_stack != 0 }}\"; + } + }], + }; - check_community_supports_title({ - target: document.getElementById(\"community_to_post_to\"), - }); - }, 250); + update_community_avatar({ target: fake_select }); + check_community_supports_title({ target: fake_select }); + }, 150); window.cancel_create_post = async () => { if ( diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 505901a..8e5f11d 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -2659,5 +2659,5 @@ (text "{{ self::full_username(user=owner) }}")) (td (text "{{ post.comment_count }}")) (td (text "{{ ((post.likes + 1) / (post.dislikes + 1))|round(method=\"ceil\", precision=2) }}")) - (td (span ("class" "date") (text "{{ post.created }}")))) + (td (span ("class" "date short") (text "{{ post.created }}")))) (text "{%- endmacro %}") diff --git a/crates/app/src/routes/api/v1/communities/communities.rs b/crates/app/src/routes/api/v1/communities/communities.rs index afa9481..0d6d703 100644 --- a/crates/app/src/routes/api/v1/communities/communities.rs +++ b/crates/app/src/routes/api/v1/communities/communities.rs @@ -561,7 +561,7 @@ pub async fn add_topic_request( return Json(Error::DataTooLong("title".to_string()).into()); } - if req.title.len() < 2 { + if req.title.trim().len() < 2 { return Json(Error::DataTooShort("title".to_string()).into()); } @@ -618,7 +618,7 @@ pub async fn update_topic_request( return Json(Error::DataTooLong("title".to_string()).into()); } - if req.title.len() < 2 { + if req.title.trim().len() < 2 { return Json(Error::DataTooShort("title".to_string()).into()); } diff --git a/crates/core/src/database/apps.rs b/crates/core/src/database/apps.rs index 72334a8..b8555bd 100644 --- a/crates/core/src/database/apps.rs +++ b/crates/core/src/database/apps.rs @@ -64,7 +64,7 @@ impl DataManager { /// * `data` - a mock [`ThirdPartyApp`] object to insert pub async fn create_app(&self, data: ThirdPartyApp) -> Result { // check values - if data.title.len() < 2 { + if data.title.trim().len() < 2 { return Err(Error::DataTooShort("title".to_string())); } else if data.title.len() > 32 { return Err(Error::DataTooLong("title".to_string())); diff --git a/crates/core/src/database/communities.rs b/crates/core/src/database/communities.rs index e535d40..3b22917 100644 --- a/crates/core/src/database/communities.rs +++ b/crates/core/src/database/communities.rs @@ -207,7 +207,7 @@ impl DataManager { /// * `data` - a mock [`Community`] to insert pub async fn create_community(&self, data: Community) -> Result { // check values - if data.title.len() < 2 { + if data.title.trim().len() < 2 { return Err(Error::DataTooShort("title".to_string())); } else if data.title.len() > 32 { return Err(Error::DataTooLong("title".to_string())); diff --git a/crates/core/src/database/domains.rs b/crates/core/src/database/domains.rs index 737bd5f..2e20e0d 100644 --- a/crates/core/src/database/domains.rs +++ b/crates/core/src/database/domains.rs @@ -82,7 +82,7 @@ impl DataManager { /// * `data` - a mock [`Domain`] object to insert pub async fn create_domain(&self, data: Domain) -> Result { // check values - if data.name.len() < 2 { + if data.name.trim().len() < 2 { return Err(Error::DataTooShort("name".to_string())); } else if data.name.len() > 128 { return Err(Error::DataTooLong("name".to_string())); diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index 1e856dd..a98250f 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -1951,7 +1951,7 @@ impl DataManager { )); } } else if data.replying_to.is_none() { - if data.title.len() < 2 && community.context.require_titles { + if data.title.trim().len() < 2 && community.context.require_titles { return Err(Error::DataTooShort("title".to_string())); } else if data.title.len() > 128 { return Err(Error::DataTooLong("title".to_string())); diff --git a/crates/core/src/database/products.rs b/crates/core/src/database/products.rs index 0eab9aa..b5e32a5 100644 --- a/crates/core/src/database/products.rs +++ b/crates/core/src/database/products.rs @@ -89,7 +89,7 @@ impl DataManager { /// * `data` - a mock [`Product`] object to insert pub async fn create_product(&self, data: Product) -> Result { // check values - if data.name.len() < 2 { + if data.name.trim().len() < 2 { return Err(Error::DataTooShort("name".to_string())); } else if data.name.len() > 128 { return Err(Error::DataTooLong("name".to_string())); diff --git a/crates/core/src/database/services.rs b/crates/core/src/database/services.rs index adc9bc6..7c0e7bb 100644 --- a/crates/core/src/database/services.rs +++ b/crates/core/src/database/services.rs @@ -54,7 +54,7 @@ impl DataManager { /// * `data` - a mock [`Service`] object to insert pub async fn create_service(&self, data: Service) -> Result { // check values - if data.name.len() < 2 { + if data.name.trim().len() < 2 { return Err(Error::DataTooShort("name".to_string())); } else if data.name.len() > 128 { return Err(Error::DataTooLong("name".to_string())); diff --git a/crates/core/src/database/stacks.rs b/crates/core/src/database/stacks.rs index cea2be9..604c415 100644 --- a/crates/core/src/database/stacks.rs +++ b/crates/core/src/database/stacks.rs @@ -155,7 +155,7 @@ impl DataManager { /// * `data` - a mock [`UserStack`] object to insert pub async fn create_stack(&self, data: UserStack) -> Result { // check values - if data.name.len() < 2 { + if data.name.trim().len() < 2 { return Err(Error::DataTooShort("title".to_string())); } else if data.name.len() > 32 { return Err(Error::DataTooLong("title".to_string())); From 155fe34c6e0084f9204bc80f77af79880deabed0 Mon Sep 17 00:00:00 2001 From: trisua Date: Tue, 5 Aug 2025 13:39:01 -0400 Subject: [PATCH 07/38] add: temporary bans --- crates/app/src/langs/en-US.toml | 1 + crates/app/src/macros.rs | 27 +++++++++++-- crates/app/src/public/html/mod/profile.lisp | 33 +++++++++++++++- crates/app/src/public/html/root.lisp | 12 +++++- .../routes/api/v1/auth/connections/stripe.rs | 12 +++--- crates/app/src/routes/api/v1/auth/profile.rs | 39 ++++++++++++++++--- crates/app/src/routes/api/v1/mod.rs | 9 +++++ crates/core/src/database/auth.rs | 7 +++- .../src/database/drivers/sql/create_users.sql | 3 +- .../drivers/sql/version_migrations.sql | 4 ++ crates/core/src/model/auth.rs | 4 ++ 11 files changed, 132 insertions(+), 19 deletions(-) diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 1949204..c9e0b93 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -214,6 +214,7 @@ version = "1.0.0" "mod_panel:label.invited_by" = "Invited by" "mod_panel:label.send_debug_payload" = "Send debug payload" "mod_panel:label.ban_reason" = "Ban reason" +"mod_panel:label.ban_expiration" = "Ban expiration" "requests:label.requests" = "Requests" "requests:label.community_join_request" = "Community join request" diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index 9fbda85..c50c68d 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -87,10 +87,31 @@ macro_rules! get_user_from_token { { Ok(ua) => { if ua.permissions.check_banned() { - let mut banned_user = tetratto_core::model::auth::User::banned(); - banned_user.ban_reason = ua.ban_reason; + // check expiration + let now = tetratto_shared::unix_epoch_timestamp(); + let expired = ua.ban_expire <= now; - Some(banned_user) + if expired && ua.ban_expire != 0 { + $db.update_user_role( + ua.id, + ua.permissions + - tetratto_core::model::permissions::FinePermission::BANNED, + &ua, + true, + ) + .await + .expect("failed to auto unban user"); + + Some(ua) + } else { + // banned + let mut banned_user = tetratto_core::model::auth::User::banned(); + + banned_user.ban_reason = ua.ban_reason; + banned_user.ban_expire = ua.ban_expire; + + Some(banned_user) + } } else { Some(ua) } diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp index 3201178..8dcf616 100644 --- a/crates/app/src/public/html/mod/profile.lisp +++ b/crates/app/src/public/html/mod/profile.lisp @@ -298,7 +298,7 @@ (div ("class" "flex flex_col gap_1") (label - ("for" "title") + ("for" "reason") (str (text "mod_panel:label.ban_reason"))) (textarea ("type" "text") @@ -309,6 +309,37 @@ (text "{{ profile.ban_reason|remove_script_tags|safe }}"))) (button (str (text "general:action.save"))))) + (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 "scale")) + (span + (str (text "mod_panel:label.ban_expiration"))))) + (form + ("class" "card flex flex_col gap_2") + ("onsubmit" "event.preventDefault(); profile_request(false, 'ban_expire', { expire: new Date(event.target.expire.value).getTime() || 0 })") + (div + ("class" "flex flex_col gap_1") + (label + ("for" "expire") + (str (text "mod_panel:label.ban_expiration"))) + (input + ("type" "datetime-local") + ("name" "expire") + ("id" "expire") + ("value" "{{ profile.ban_expire }}"))) + (div + ("class" "flex gap_2") + (button + (str (text "general:action.save"))) + (button + ("type" "button") + ("class" "lowered red") + ("onclick" "profile_request(false, 'ban_expire', { expire: 0 })") + (str (text "notifs:action.clear")))))) (div ("class" "card_nest w_full") (div diff --git a/crates/app/src/public/html/root.lisp b/crates/app/src/public/html/root.lisp index 76b38d9..9a253fe 100644 --- a/crates/app/src/public/html/root.lisp +++ b/crates/app/src/public/html/root.lisp @@ -76,7 +76,17 @@ (span ("class" "fade") (text "The following reason was provided by a moderator:")) (div ("class" "card lowered w_full") - (text "{{ user.ban_reason|markdown|safe }}")))))) + (text "{{ user.ban_reason|markdown|safe }}")) + (text "{% if user.ban_expire != 0 -%}") + (hr) + (span + (text "Your ban will expire on: ") + (span ("id" "ban_expire"))) + (script + (text "document.getElementById(\"ban_expire\").innerText = new Date({{ user.ban_expire }}).toLocaleString();")) + (text "{% else %}") + (span (text "This ban is marked as permanent.")) + (text "{%- endif %}"))))) ; if we aren't banned, just show the page body (text "{% elif user and user.awaiting_purchase %}") ; account waiting for payment message diff --git a/crates/app/src/routes/api/v1/auth/connections/stripe.rs b/crates/app/src/routes/api/v1/auth/connections/stripe.rs index 2110924..837d2d3 100644 --- a/crates/app/src/routes/api/v1/auth/connections/stripe.rs +++ b/crates/app/src/routes/api/v1/auth/connections/stripe.rs @@ -179,7 +179,7 @@ pub async fn stripe_webhook( let new_user_permissions = user.permissions | FinePermission::SUPPORTER; if let Err(e) = data - .update_user_role(user.id, new_user_permissions, user.clone(), true) + .update_user_role(user.id, new_user_permissions, &user, true) .await { return Json(e.into()); @@ -225,7 +225,7 @@ pub async fn stripe_webhook( user.secondary_permissions | SecondaryPermission::DEVELOPER_PASS; if let Err(e) = data - .update_user_secondary_role(user.id, new_user_permissions, user.clone(), true) + .update_user_secondary_role(user.id, new_user_permissions, &user, true) .await { return Json(e.into()); @@ -284,7 +284,7 @@ pub async fn stripe_webhook( let new_user_permissions = user.permissions - FinePermission::SUPPORTER; if let Err(e) = data - .update_user_role(user.id, new_user_permissions, user.clone(), true) + .update_user_role(user.id, new_user_permissions, &user, true) .await { return Json(e.into()); @@ -310,7 +310,7 @@ pub async fn stripe_webhook( user.secondary_permissions - SecondaryPermission::DEVELOPER_PASS; if let Err(e) = data - .update_user_secondary_role(user.id, new_user_permissions, user.clone(), true) + .update_user_secondary_role(user.id, new_user_permissions, &user, true) .await { return Json(e.into()); @@ -396,7 +396,7 @@ pub async fn stripe_webhook( let new_user_permissions = user.permissions - FinePermission::SUPPORTER; if let Err(e) = data - .update_user_role(user.id, new_user_permissions, user.clone(), true) + .update_user_role(user.id, new_user_permissions, &user, true) .await { return Json(e.into()); @@ -437,7 +437,7 @@ pub async fn stripe_webhook( user.secondary_permissions - SecondaryPermission::DEVELOPER_PASS; if let Err(e) = data - .update_user_secondary_role(user.id, new_user_permissions, user.clone(), true) + .update_user_secondary_role(user.id, new_user_permissions, &user, true) .await { return Json(e.into()); diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index c5bbcc1..a4ec514 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -4,9 +4,9 @@ use crate::{ model::{ApiReturn, Error}, routes::api::v1::{ AppendAssociations, AwardAchievement, DeleteUser, DisableTotp, RefreshGrantToken, - UpdateSecondaryUserRole, UpdateUserAwaitingPurchase, UpdateUserBanReason, - UpdateUserInviteCode, UpdateUserIsDeactivated, UpdateUserIsVerified, UpdateUserPassword, - UpdateUserRole, UpdateUserUsername, + UpdateSecondaryUserRole, UpdateUserAwaitingPurchase, UpdateUserBanExpire, + UpdateUserBanReason, UpdateUserInviteCode, UpdateUserIsDeactivated, UpdateUserIsVerified, + UpdateUserPassword, UpdateUserRole, UpdateUserUsername, }, State, }; @@ -423,7 +423,7 @@ pub async fn update_user_role_request( None => return Json(Error::NotAllowed.into()), }; - match data.update_user_role(id, req.role, user, false).await { + match data.update_user_role(id, req.role, &user, false).await { Ok(_) => Json(ApiReturn { ok: true, message: "User updated".to_string(), @@ -449,7 +449,7 @@ pub async fn update_user_secondary_role_request( }; match data - .update_user_secondary_role(id, req.role, user, false) + .update_user_secondary_role(id, req.role, &user, false) .await { Ok(_) => Json(ApiReturn { @@ -490,6 +490,35 @@ pub async fn update_user_ban_reason_request( } } +/// Update the ban expiration date of the given user. +/// +/// Does not support third-party grants. +pub async fn update_user_ban_expire_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + Json(req): Json, +) -> 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 !user.permissions.check(FinePermission::MANAGE_USERS) { + return Json(Error::NotAllowed.into()); + } + + match data.update_user_ban_expire(id, req.expire as i64).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) -> impl IntoResponse { let data = &(data.read().await).0; diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 2b148df..fa6b913 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -340,6 +340,10 @@ pub fn routes() -> Router { "/auth/user/{id}/ban_reason", post(auth::profile::update_user_ban_reason_request), ) + .route( + "/auth/user/{id}/ban_expire", + post(auth::profile::update_user_ban_expire_request), + ) .route( "/auth/user/{id}", delete(auth::profile::delete_user_request), @@ -916,6 +920,11 @@ pub struct UpdateUserBanReason { pub reason: String, } +#[derive(Deserialize)] +pub struct UpdateUserBanExpire { + pub expire: usize, +} + #[derive(Deserialize)] pub struct UpdateUserInviteCode { pub invite_code: String, diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 4ced643..e0aef74 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -27,7 +27,7 @@ macro_rules! update_role_fn { &self, id: usize, role: $role_ty, - user: User, + user: &User, force: bool, ) -> Result<()> { let other_user = self.get_user_by_id(id).await?; @@ -129,6 +129,7 @@ impl DataManager { ban_reason: get!(x->28(String)), channel_mutes: serde_json::from_str(&get!(x->29(String)).to_string()).unwrap(), is_deactivated: get!(x->30(i32)) as i8 == 1, + ban_expire: get!(x->31(i64)) as usize, } } @@ -285,7 +286,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, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31)", + "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, $27, $28, $29, $30, $31, $32)", params![ &(data.id as i64), &(data.created as i64), @@ -318,6 +319,7 @@ impl DataManager { &data.ban_reason, &serde_json::to_string(&data.channel_mutes).unwrap(), &if data.is_deactivated { 1_i32 } else { 0_i32 }, + &(data.ban_expire as i64), ] ); @@ -1059,6 +1061,7 @@ impl DataManager { auto_method!(update_user_seller_data(StripeSellerData)@get_user_by_id -> "UPDATE users SET seller_data = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(update_user_ban_reason(&str)@get_user_by_id -> "UPDATE users SET ban_reason = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); auto_method!(update_user_channel_mutes(Vec)@get_user_by_id -> "UPDATE users SET channel_mutes = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); + auto_method!(update_user_ban_expire(i64)@get_user_by_id -> "UPDATE users SET ban_expire = $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); diff --git a/crates/core/src/database/drivers/sql/create_users.sql b/crates/core/src/database/drivers/sql/create_users.sql index 6a939e5..06e86ef 100644 --- a/crates/core/src/database/drivers/sql/create_users.sql +++ b/crates/core/src/database/drivers/sql/create_users.sql @@ -29,5 +29,6 @@ CREATE TABLE IF NOT EXISTS users ( seller_data TEXT NOT NULL, ban_reason TEXT NOT NULL, channel_mutes TEXT NOT NULL, - is_deactivated INT NOT NULL + is_deactivated INT NOT NULL, + ban_expire BIGINT NOT NULL ) diff --git a/crates/core/src/database/drivers/sql/version_migrations.sql b/crates/core/src/database/drivers/sql/version_migrations.sql index cd48530..5c6c51e 100644 --- a/crates/core/src/database/drivers/sql/version_migrations.sql +++ b/crates/core/src/database/drivers/sql/version_migrations.sql @@ -25,3 +25,7 @@ ADD COLUMN IF NOT EXISTS topics TEXT DEFAULT '{}'; -- posts topic ALTER TABLE posts ADD COLUMN IF NOT EXISTS topic BIGINT DEFAULT 0; + +-- users ban_expire +ALTER TABLE users +ADD COLUMN IF NOT EXISTS ban_expire BIGINT DEFAULT 0; diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index de205cb..7622f1a 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -92,6 +92,9 @@ pub struct User { /// users, but their data is not wiped. #[serde(default)] pub is_deactivated: bool, + /// The time at which the user's ban will automatically expire. + #[serde(default)] + pub ban_expire: usize, } pub type UserConnections = @@ -408,6 +411,7 @@ impl User { ban_reason: String::new(), channel_mutes: Vec::new(), is_deactivated: false, + ban_expire: 0, } } From 3958d5eaefdebb1019ad8704ed9d75c68828273f Mon Sep 17 00:00:00 2001 From: trisua Date: Tue, 5 Aug 2025 16:33:53 -0400 Subject: [PATCH 08/38] add: forum threads ui --- crates/app/src/assets.rs | 3 + crates/app/src/langs/en-US.toml | 2 + crates/app/src/public/css/style.css | 67 +++++++++- crates/app/src/public/html/components.lisp | 33 ++++- .../public/html/post/forum_quick_replies.lisp | 24 ++++ crates/app/src/public/html/post/post.lisp | 21 ++- crates/app/src/routes/pages/communities.rs | 121 +++++++++++++++++- crates/app/src/routes/pages/mod.rs | 4 + 8 files changed, 260 insertions(+), 15 deletions(-) create mode 100644 crates/app/src/public/html/post/forum_quick_replies.lisp diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 6f9addf..092889f 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -88,6 +88,8 @@ pub const POST_POST: &str = include_str!("./public/html/post/post.lisp"); pub const POST_REPOSTS: &str = include_str!("./public/html/post/reposts.lisp"); pub const POST_QUOTES: &str = include_str!("./public/html/post/quotes.lisp"); pub const POST_LIKES: &str = include_str!("./public/html/post/likes.lisp"); +pub const POST_FORUM_QUICK_REPLIES: &str = + include_str!("./public/html/post/forum_quick_replies.lisp"); pub const TIMELINES_HOME: &str = include_str!("./public/html/timelines/home.lisp"); pub const TIMELINES_POPULAR: &str = include_str!("./public/html/timelines/popular.lisp"); @@ -327,6 +329,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"post/reposts.html"(crate::assets::POST_REPOSTS) --config=config --lisp plugins); write_template!(html_path->"post/quotes.html"(crate::assets::POST_QUOTES) --config=config --lisp plugins); write_template!(html_path->"post/likes.html"(crate::assets::POST_LIKES) --config=config --lisp plugins); + write_template!(html_path->"post/forum_quick_replies.html"(crate::assets::POST_FORUM_QUICK_REPLIES) --config=config --lisp plugins); write_template!(html_path->"timelines/home.html"(crate::assets::TIMELINES_HOME) -d "timelines" --config=config --lisp plugins); write_template!(html_path->"timelines/popular.html"(crate::assets::TIMELINES_POPULAR) --config=config --lisp plugins); diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index c9e0b93..f995deb 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -50,6 +50,8 @@ version = "1.0.0" "general:label.loading" = "Working on it!" "general:label.send_anonymously" = "Send anonymously" "general:label.must_activate_account" = "You need to activate your account!" +"general:action.load_more" = "Load more" +"general:action.show_thread" = "Show thread" "general:label.supporter_motivation" = "Become a supporter!" "general:action.become_supporter" = "Become supporter" diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index 88a1864..900afd0 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -1,5 +1,6 @@ @import url("root.css"); +/* media gallery */ .media_gallery { display: grid; grid-auto-columns: 1fr 1fr; @@ -312,7 +313,7 @@ button, transition: background 0.15s; width: max-content; height: 32px; - padding: var(--pad-1) var(--pad-4); + padding: 0 var(--pad-4); border-radius: var(--radius); cursor: pointer; display: flex; @@ -439,7 +440,6 @@ textarea, select { padding: 0.35rem var(--pad-3); border-radius: var(--radius); - border: solid 1px var(--color-super-lowered); outline: none; transition: background 0.15s; resize: vertical; @@ -447,8 +447,10 @@ select { font-family: inherit; font-size: 16px; /* personality */ - background: transparent; - color: inherit; + --background: var(--color-lowered); + border: solid 1px var(--background); + background: var(--background); + color: var(--color-text-lowered); } textarea { @@ -458,8 +460,7 @@ textarea { input:focus, textarea:focus, select:focus { - background: var(--color-super-raised); - color: var(--color-text-raised); + border-color: var(--color-super-lowered); } .poll_bar { @@ -902,7 +903,7 @@ dialog::backdrop { display: none; position: absolute; background: var(--color-raised); - border: solid 1px var(--color-super-lowered); + /* border: solid 1px var(--color-super-lowered); */ z-index: 2; border-radius: var(--radius); top: calc(100% + 5px); @@ -1404,3 +1405,55 @@ details.accordion .inner { background-color: var(--color-primary) !important; color: var(--color-text-primary) !important; } + +/* threads */ +.squig { + --color: var(--color-super-lowered); + --background: var(--color-raised); + --size: 10px; + position: relative; + width: 100%; + height: 20px; + display: block; +} + +.squig::before, +.squig::after { + content: ""; + position: absolute; + width: inherit; + height: inherit; + background-size: var(--size) 100%; +} + +.squig::before { + top: -2px; + background-image: + linear-gradient(45deg, var(--color) 35%, transparent 0), + linear-gradient(-45deg, var(--color) 35%, transparent 0); +} + +.squig::after { + top: 0px; + background-image: + linear-gradient(45deg, var(--background) 35%, transparent 0), + linear-gradient(-45deg, var(--background) 35%, transparent 0); +} + +.thread { + --pad: 15px; + --squig-height: 20px; + position: relative; + padding-left: var(--pad); +} + +.thread::before { + content: ""; + position: absolute; + background: var(--color-super-lowered); + height: calc(100% - var(--squig-height)); + width: 5px; + left: 0; + top: 0; + border-radius: var(--radius); +} diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 8e5f11d..d2b2bf6 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -201,7 +201,7 @@ (text "{%- endif %}") (text "{%- endmacro %}") -(text "{% macro post_buttons_box(post, community, owner, can_manage_post) -%}") +(text "{% macro post_buttons_box(post, community, owner, can_manage_post, show_comments=true) -%}") (div ("class" "flex justify_between items_center gap_2 w_full") (text "{% if user -%}") @@ -220,7 +220,7 @@ (text "{%- endif %}") (div ("class" "flex gap_1 buttons_box") - (text "{% if post.context.comments_enabled %}") + (text "{% if show_comments and post.context.comments_enabled %}") (a ("href" "/post/{{ post.id }}") ("class" "button camo small") @@ -526,7 +526,7 @@ (text "{{ text \"general:action.delete\" }}")))))) (text "{%- endmacro %}") -(text "{% macro forum_post(post, owner, community, can_manage_post, poll=false) -%}") +(text "{% macro forum_post(post, owner, community, can_manage_post, poll=false, show_show_thread=true) -%}") (div ("class" "card_nest_horizontal_wrapper post post:{{ post.id }}") ("data-community" "{{ post.community }}") @@ -580,7 +580,32 @@ (text "{% if poll -%} {{ self::poll(post=post, poll=poll) }} {%- endif %}")) (hr ("class" "margin_top")) - (text "{{ self::post_buttons_box(post=post, community=community, owner=owner, can_manage_post=can_manage_post) }}")))) + (text "{{ self::post_buttons_box(post=post, community=community, owner=owner, can_manage_post=can_manage_post, show_comments=false) }}")))) + +; show thread +(text "{% if show_show_thread and post.comment_count > 0 -%}") +(div + ("class" "flex gap_2") + (text "{% if post.context.comments_enabled %}") + (a + ("href" "/post/{{ post.id }}") + ("class" "button lowered") + (icon (text "message-circle")) + (span + (text "{{ post.comment_count }}"))) + (text "{% endif %}") + + (button + ("class" "lowered") + ("onclick" "globalThis.continue_thread(event.target, '{{ post.id }}', 'replies_{{ post.id }}', 0)") + (icon (text "chevron-down")) + (str (text "general:action.show_thread")))) +(text "{%- endif %}") + +; replies +(div + ("class" "flex flex_col gap_2 hidden thread") + ("id" "replies_{{ post.id }}")) (text "{%- endmacro %}") (text "{% macro user_card(user) -%}") diff --git a/crates/app/src/public/html/post/forum_quick_replies.lisp b/crates/app/src/public/html/post/forum_quick_replies.lisp new file mode 100644 index 0000000..383d5ba --- /dev/null +++ b/crates/app/src/public/html/post/forum_quick_replies.lisp @@ -0,0 +1,24 @@ +(text "{%- import \"components.html\" as components -%} {%- import \"macros.html\" as macros -%}") + +(div + ("class" "flex flex_col gap_2") + ("id" "replies_{{ post.id }}_{{ page }}") + ; replies + (text "{% for post in replies -%}") + (div + ("style" "display: contents") + (text "{{ components::forum_post(post=post[0], owner=post[1], community=community, can_manage_post=can_manage_posts, poll=post[4]) }}")) + (text "{%- endfor %}") + + ; load more button + (text "{% set len = replies|length %}") + (text "{% if len != 0 and (page * 12) + len != post.comment_count -%}") + (div + (button + ("class" "lowered") + ("onclick" "globalThis.continue_thread(event.target, '{{ post.id }}', 'replies_{{ post.id }}', {{ page + 1 }})") + (icon (text "chevron-down")) + (str (text "general:action.load_more")))) + (text "{% else %}") + (div ("class" "squig")) + (text "{%- endif %}")) diff --git a/crates/app/src/public/html/post/post.lisp b/crates/app/src/public/html/post/post.lisp index c645ae0..1eae1ab 100644 --- a/crates/app/src/public/html/post/post.lisp +++ b/crates/app/src/public/html/post/post.lisp @@ -56,7 +56,7 @@ (text "{% else %}") (div ("style" "display: contents") - (text "{{ components::forum_post(post=post, owner=owner, community=community, can_manage_post=can_manage_posts, poll=poll) }}")) + (text "{{ components::forum_post(post=post, owner=owner, community=community, can_manage_post=can_manage_posts, poll=poll, show_show_thread=false) }}")) (text "{%- endif %}") ; ... (text "{% if user and post.context.comments_enabled -%}") @@ -332,7 +332,24 @@ (text "{%- endif %} {% endfor %} {{ components::pagination(page=page, items=replies|length) }}")))) (script - (text "async function create_reply_from_form(e) { + (text "globalThis.continue_thread = async (target, post_id, id, page = 0) => { + const btn_id = `tmp_${window.crypto.randomUUID()}`; + target.setAttribute(\"disabled\", \"true\"); + target.id = btn_id; + + document.getElementById(id).innerHTML += + await (await fetch(`/post/${post_id}/_quick_replies?page=${page}`)).text(); + document.getElementById(id).classList.remove(\"hidden\"); + + await trigger(\"atto::clean_date_codes\"); + await trigger(\"atto::link_filter\"); + await trigger(\"atto::hooks::check_reactions\"); + await trigger(\"atto::hooks::online_indicator\"); + + document.getElementById(btn_id).parentElement.remove(); + } + + async function create_reply_from_form(e) { e.preventDefault(); await trigger(\"atto::debounce\", [\"posts::create\"]); diff --git a/crates/app/src/routes/pages/communities.rs b/crates/app/src/routes/pages/communities.rs index 2f0d03c..1e2ce88 100644 --- a/crates/app/src/routes/pages/communities.rs +++ b/crates/app/src/routes/pages/communities.rs @@ -1011,8 +1011,6 @@ pub async fn post_request( } // ... - let ignore_users = crate::ignore_users_gen!(user, data); - let feed = match data .0 .get_replies_by_post( @@ -1085,6 +1083,125 @@ pub async fn post_request( Ok(Html(data.1.render("post/post.html", &context).unwrap())) } +/// `/post/{id}/_quick_replies` +pub async fn forum_quick_replies_request( + jar: CookieJar, + Path(id): Path, + Query(props): Query, + Extension(data): Extension, +) -> impl IntoResponse { + let data = data.read().await; + let user = get_user_from_token!(jar, data.0); + + let post = match data.0.get_post_by_id(id).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + + if post.is_deleted { + // act like the post doesn't exist (if missing MANAGE_POSTS) + if let Some(ref ua) = user { + if !ua.permissions.check(FinePermission::MANAGE_POSTS) { + return Err(Html( + render_error( + Error::GeneralNotFound("post".to_string()), + &jar, + &data, + &user, + ) + .await, + )); + } + } else { + return Err(Html( + render_error( + Error::GeneralNotFound("post".to_string()), + &jar, + &data, + &user, + ) + .await, + )); + } + } + + // ... + let community = match data.0.get_community_by_id(post.community).await { + Ok(c) => c, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + + // check permissions + let (can_read, can_manage_pins) = check_community_permissions!(community, jar, data, user); + + if !can_read { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + + // ... + let ignore_users = crate::ignore_users_gen!(user, data); + + let feed = match data + .0 + .get_replies_by_post( + post.id, + 12, + props.page, + if community.is_forum { "ASC" } else { "DESC" }, + ) + .await + { + Ok(p) => match data.0.fill_posts(p, &ignore_users, &user).await { + Ok(p) => p, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + + // init context + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &user).await; + + let ( + is_owner, + is_joined, + is_pending, + can_post, + can_manage_posts, + can_manage_community, + can_manage_roles, + can_manage_questions, + ) = community_context_bools!(data, user, community); + + context.insert("post", &post); + context.insert("replies", &feed); + context.insert("page", &props.page); + context.insert("can_manage_pins", &can_manage_pins); + + community_context( + &mut context, + &community, + is_owner, + is_joined, + is_pending, + can_post, + can_read, + can_manage_posts, + can_manage_community, + can_manage_roles, + can_manage_questions, + ); + + // return + Ok(Html( + data.1 + .render("post/forum_quick_replies.html", &context) + .unwrap(), + )) +} + /// `/post/{id}/reposts` pub async fn reposts_request( jar: CookieJar, diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index b077d1c..01f24d6 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -115,6 +115,10 @@ pub fn routes() -> Router { get(communities::members_request), ) .route("/post/{id}", get(communities::post_request)) + .route( + "/post/{id}/_quick_replies", + get(communities::forum_quick_replies_request), + ) .route("/post/{id}/reposts", get(communities::reposts_request)) .route("/post/{id}/likes", get(communities::likes_request)) .route("/question/{id}", get(communities::question_request)) From 2407e6b213bdb0fcb4643713d658750fbea7a788 Mon Sep 17 00:00:00 2001 From: trisua Date: Tue, 5 Aug 2025 22:36:25 -0400 Subject: [PATCH 09/38] add: ability to convert existing communities into forums --- crates/app/src/public/css/root.css | 36 ++++++++++------- crates/app/src/public/css/style.css | 26 +++++++++--- crates/app/src/public/html/body.lisp | 5 ++- .../app/src/public/html/communities/list.lisp | 2 +- .../src/public/html/communities/settings.lisp | 40 ++++++++++++++++++- .../routes/api/v1/communities/communities.rs | 35 ++++++++++++++++ crates/app/src/routes/api/v1/mod.rs | 4 ++ crates/core/src/database/communities.rs | 1 + 8 files changed, 125 insertions(+), 24 deletions(-) diff --git a/crates/app/src/public/css/root.css b/crates/app/src/public/css/root.css index a0c2a18..08437da 100644 --- a/crates/app/src/public/css/root.css +++ b/crates/app/src/public/css/root.css @@ -5,22 +5,26 @@ --hue: 16; --sat: 6%; --lit: 0%; - --color-surface: hsl(var(--hue), var(--sat), calc(97% - var(--lit))); - --color-lowered: hsl(var(--hue), var(--sat), calc(94% - var(--lit))); - --color-raised: hsl(var(--hue), var(--sat), calc(99% - var(--lit))); + --color-surface: hsl(var(--hue), var(--sat), calc(94% - var(--lit))); + --color-lowered: hsl(var(--hue), var(--sat), calc(90% - var(--lit))); + --color-raised: hsl(var(--hue), var(--sat), calc(97% - var(--lit))); --color-super-lowered: hsl(var(--hue), var(--sat), calc(85% - var(--lit))); - --color-super-raised: hsl(var(--hue), var(--sat), calc(100% - var(--lit))); - --color-text: hsl(0, 0%, 0%); + --color-super-raised: hsl(var(--hue), var(--sat), calc(99% - var(--lit))); + --color-text: hsl(0, 0%, 9%); --color-text-raised: var(--color-text); --color-text-lowered: var(--color-text); --color-primary: hsl(330, 18%, 26%); --color-primary-lowered: hsl(330, 18%, 21%); - --color-text-primary: hsl(0, 0%, 100%); + --color-text-primary: hsl(0, 0%, 91%); - --color-secondary: hsl(6, 18%, 66%); - --color-secondary-lowered: hsl(6, 18%, 61%); - --color-text-secondary: hsl(0, 0%, 0%); + --color-secondary: hsl(277, 27%, 70%); + --color-secondary-lowered: hsl(277, 27%, 65%); + --color-text-secondary: hsl(0, 0%, 9%); + + --color-accent: hsl(237, 27%, 28%); + --color-accent-lowered: hsl(237, 27%, 23%); + --color-text-accent: hsl(0, 0%, 91%); --color-link: #2949b2; --color-shadow: rgba(0, 0, 0, 0.08); @@ -54,15 +58,19 @@ --color-raised: hsl(var(--hue), var(--sat), calc(2% + var(--lit))); --color-super-lowered: hsl(var(--hue), var(--sat), calc(12% + var(--lit))); --color-super-raised: hsl(var(--hue), var(--sat), calc(4% + var(--lit))); - --color-text: hsl(0, 0%, 95%); + --color-text: hsl(0, 0%, 91%); --color-primary: hsl(331, 18%, 74%); --color-primary-lowered: hsl(331, 18%, 69%); - --color-text-primary: hsl(0, 0%, 0%); + --color-text-primary: hsl(0, 0%, 9%); - --color-secondary: hsl(6, 18%, 34%); - --color-secondary-lowered: hsl(6, 18%, 29%); - --color-text-secondary: hsl(0, 0%, 100%); + --color-secondary: hsl(277, 27%, 30%); + --color-secondary-lowered: hsl(277, 27%, 25%); + --color-text-secondary: hsl(0, 0%, 91%); + + --color-accent: hsl(237, 27%, 72%); + --color-accent-lowered: hsl(237, 27%, 67%); + --color-text-accent: hsl(0, 0%, 9%); --color-link: #93c5fd; --color-red: hsl(0, 94%, 82%); diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index 900afd0..d02e7b0 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -370,7 +370,17 @@ button.secondary, .button.secondary { background: var(--color-secondary); color: var(--color-text-secondary); - font-weight: 500; +} + +button.accent:hover, +.button.accent:hover { + background: var(--color-accent-lowered); +} + +button.accent, +.button.accent { + background: var(--color-accent); + color: var(--color-text-accent); } button.secondary:hover, @@ -861,18 +871,19 @@ dialog { display: none; background: var(--color-surface); border: solid 1px var(--color-super-lowered) !important; - border-radius: var(--radius); - max-width: 100%; + border-radius: calc(var(--radius) * 2); + max-width: 95%; border-style: none; margin: auto; color: var(--color-text); - animation: popin ease-in-out 1 0.1s forwards running; + animation: popin ease-in-out 1 0.15s forwards running; } dialog .inner { padding: var(--pad-4); width: 25rem; max-width: 100%; + font-weight: 500; } dialog .inner hr:not(.flipped):last-of-type { @@ -891,6 +902,11 @@ dialog[open] { dialog::backdrop { background: hsla(0, 0%, 0%, 50%); backdrop-filter: blur(5px); + animation: fadein ease-in-out 1 0.1s forwards running; +} + +dialog:is(.dark *)::backdrop { + background: hsla(0, 0%, 100%, 15%); } /* dropdown */ @@ -905,7 +921,7 @@ dialog::backdrop { background: var(--color-raised); /* border: solid 1px var(--color-super-lowered); */ z-index: 2; - border-radius: var(--radius); + border-radius: calc(var(--radius) * 2); top: calc(100% + 5px); right: 0; width: max-content; diff --git a/crates/app/src/public/html/body.lisp b/crates/app/src/public/html/body.lisp index 00dcfd8..89b6d53 100644 --- a/crates/app/src/public/html/body.lisp +++ b/crates/app/src/public/html/body.lisp @@ -311,7 +311,7 @@ (div ("id" "tokens") ("style" "display: contents")) (div - ("class" "flex justify_between") + ("class" "flex justify_right gap_2") (a ("href" "/auth/login") ("class" "button") @@ -323,7 +323,8 @@ ("class" "lowered") ("onclick" "document.getElementById('tokens_dialog').close()") ("type" "button") - (icon (text "check"))))))) + (icon (text "check")) + (str (text "dialog:action.okay"))))))) ; user scripts (text "{%- endif %} {% if user and use_user_theme -%} {{ components::theme(user=user, theme_preference=user.settings.theme_preference) }} diff --git a/crates/app/src/public/html/communities/list.lisp b/crates/app/src/public/html/communities/list.lisp index 5c0abac..bb243c8 100644 --- a/crates/app/src/public/html/communities/list.lisp +++ b/crates/app/src/public/html/communities/list.lisp @@ -37,7 +37,7 @@ ("name" "is_forum") ("class" "w_content")) (span - (text "Is forum"))) + (text "Make this a forum community"))) (button (text "{{ text \"communities:action.create\" }}")))) (text "{% if list|length >= 4 -%} {{ components::supporter_ad(body=\"Become a supporter to create up to 10 communities!\") }} {%- endif %} {%- endif %}") diff --git a/crates/app/src/public/html/communities/settings.lisp b/crates/app/src/public/html/communities/settings.lisp index 29b171e..d55a324 100644 --- a/crates/app/src/public/html/communities/settings.lisp +++ b/crates/app/src/public/html/communities/settings.lisp @@ -37,14 +37,14 @@ (text "{{ icon \"rss\" }}") (span (text "{{ text \"communities:tab.channels\" }}"))) - (text "{%- endif %} {% if community.is_forum -%}") + (text "{%- endif %}") (a ("href" "#/topics") ("data-tab-button" "topics") (icon (text "list")) (span (str (text "communities:tab.topics")))) - (text "{%- endif %} {% if can_manage_emojis -%}") + (text "{% if can_manage_emojis -%}") (a ("href" "#/emojis") ("data-tab-button" "emojis") @@ -825,6 +825,42 @@ ]); }); }")) + (text "{% else %}") + (div + ("class" "card lowered w_full hidden flex flex_col gap_2") + ("data-tab" "topics") + (p (text "You can only manage topics for forum communities. You can convert this community into a forum, but you will not be able to go back.")) + (p (text "This will permanently change your community. Currently existing posts will no longer be visible on the community.")) + (button + ("onclick" "convert_to_forum()") + (icon (text "circle-fading-arrow-up")) + (text "Switch to forum"))) + + (script + (text "globalThis.convert_to_forum = async () => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this? It cannot be undone.\", + ])) + ) { + return; + } + + fetch(\"/api/v1/communities/{{ community.id }}/is_forum\", { + method: \"POST\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + window.location.reload(); + } + }); + }")) (text "{%- endif %}")) (script diff --git a/crates/app/src/routes/api/v1/communities/communities.rs b/crates/app/src/routes/api/v1/communities/communities.rs index 0d6d703..27ec0fb 100644 --- a/crates/app/src/routes/api/v1/communities/communities.rs +++ b/crates/app/src/routes/api/v1/communities/communities.rs @@ -264,6 +264,41 @@ pub async fn update_owner_request( } } +pub async fn update_is_forum_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::CommunityManage) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let mut community = match data.get_community_by_id_no_void(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + community.context.enable_titles = true; + community.context.require_titles = true; + + match data.update_community_is_forum(id, &user, 1).await { + Ok(_) => match data + .update_community_context(id, &user, community.context) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Community updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + }, + Err(e) => Json(e.into()), + } +} + pub async fn get_membership( jar: CookieJar, Extension(data): Extension, diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index fa6b913..694ad29 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -95,6 +95,10 @@ pub fn routes() -> Router { "/communities/{id}/context", post(communities::communities::update_context_request), ) + .route( + "/communities/{id}/is_forum", + post(communities::communities::update_is_forum_request), + ) .route( "/communities/{id}/access/read", post(communities::communities::update_read_access_request), diff --git a/crates/core/src/database/communities.rs b/crates/core/src/database/communities.rs index 3b22917..1290bdd 100644 --- a/crates/core/src/database/communities.rs +++ b/crates/core/src/database/communities.rs @@ -556,6 +556,7 @@ impl DataManager { 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!(update_community_topics(HashMap)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET topics = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); + auto_method!(update_community_is_forum(i32)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET is_forum = $1 WHERE id = $2" --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); From b5f841a990d8524d9b6228f265a1bb076a7bea51 Mon Sep 17 00:00:00 2001 From: trisua Date: Tue, 5 Aug 2025 23:50:45 -0400 Subject: [PATCH 10/38] remove: marketplace --- crates/app/src/assets.rs | 6 - crates/app/src/langs/en-US.toml | 6 - crates/app/src/public/html/auth/base.lisp | 2 +- crates/app/src/public/html/auth/login.lisp | 3 +- crates/app/src/public/html/auth/register.lisp | 3 +- .../public/html/auth/seller_connection.lisp | 25 -- crates/app/src/public/html/components.lisp | 2 +- crates/app/src/public/html/macros.lisp | 14 -- .../src/public/html/marketplace/seller.lisp | 79 ------ crates/app/src/public/js/me.js | 57 ----- .../routes/api/v1/auth/connections/stripe.rs | 148 +---------- crates/app/src/routes/api/v1/auth/profile.rs | 29 +-- crates/app/src/routes/api/v1/mod.rs | 48 ---- crates/app/src/routes/api/v1/products.rs | 234 ------------------ crates/app/src/routes/pages/marketplace.rs | 107 -------- crates/app/src/routes/pages/mod.rs | 14 -- crates/core/src/database/auth.rs | 16 +- crates/core/src/database/common.rs | 1 - crates/core/src/database/drivers/common.rs | 1 - .../database/drivers/sql/create_products.sql | 12 - .../src/database/drivers/sql/create_users.sql | 1 - .../drivers/sql/version_migrations.sql | 4 + crates/core/src/database/mod.rs | 1 - crates/core/src/database/products.rs | 175 ------------- crates/core/src/model/auth.rs | 12 - crates/core/src/model/mod.rs | 1 - crates/core/src/model/products.rs | 88 ------- 27 files changed, 22 insertions(+), 1067 deletions(-) delete mode 100644 crates/app/src/public/html/auth/seller_connection.lisp delete mode 100644 crates/app/src/public/html/marketplace/seller.lisp delete mode 100644 crates/app/src/routes/api/v1/products.rs delete mode 100644 crates/app/src/routes/pages/marketplace.rs delete mode 100644 crates/core/src/database/drivers/sql/create_products.sql delete mode 100644 crates/core/src/database/products.rs delete mode 100644 crates/core/src/model/products.rs diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 092889f..c8db3be 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -55,7 +55,6 @@ pub const AUTH_BASE: &str = include_str!("./public/html/auth/base.lisp"); pub const AUTH_LOGIN: &str = include_str!("./public/html/auth/login.lisp"); pub const AUTH_REGISTER: &str = include_str!("./public/html/auth/register.lisp"); pub const AUTH_CONNECTION: &str = include_str!("./public/html/auth/connection.lisp"); -pub const AUTH_SELLER_CONNECTION: &str = include_str!("./public/html/auth/seller_connection.lisp"); pub const PROFILE_BASE: &str = include_str!("./public/html/profile/base.lisp"); pub const PROFILE_POSTS: &str = include_str!("./public/html/profile/posts.lisp"); @@ -143,8 +142,6 @@ pub const LITTLEWEB_SERVICE: &str = include_str!("./public/html/littleweb/servic pub const LITTLEWEB_DOMAIN: &str = include_str!("./public/html/littleweb/domain.lisp"); pub const LITTLEWEB_BROWSER: &str = include_str!("./public/html/littleweb/browser.lisp"); -pub const MARKETPLACE_SELLER: &str = include_str!("./public/html/marketplace/seller.lisp"); - pub const MAIL_RECEIVED: &str = include_str!("./public/html/mail/received.lisp"); pub const MAIL_SENT: &str = include_str!("./public/html/mail/sent.lisp"); pub const MAIL_COMPOSE: &str = include_str!("./public/html/mail/compose.lisp"); @@ -297,7 +294,6 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"auth/login.html"(crate::assets::AUTH_LOGIN) --config=config --lisp plugins); write_template!(html_path->"auth/register.html"(crate::assets::AUTH_REGISTER) --config=config --lisp plugins); write_template!(html_path->"auth/connection.html"(crate::assets::AUTH_CONNECTION) --config=config --lisp plugins); - write_template!(html_path->"auth/seller_connection.html"(crate::assets::AUTH_SELLER_CONNECTION) --config=config --lisp plugins); write_template!(html_path->"profile/base.html"(crate::assets::PROFILE_BASE) -d "profile" --config=config --lisp plugins); write_template!(html_path->"profile/posts.html"(crate::assets::PROFILE_POSTS) --config=config --lisp plugins); @@ -378,8 +374,6 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"littleweb/domain.html"(crate::assets::LITTLEWEB_DOMAIN) --config=config --lisp plugins); write_template!(html_path->"littleweb/browser.html"(crate::assets::LITTLEWEB_BROWSER) --config=config --lisp plugins); - write_template!(html_path->"marketplace/seller.html"(crate::assets::MARKETPLACE_SELLER) -d "marketplace" --config=config --lisp plugins); - write_template!(html_path->"mail/received.html"(crate::assets::MAIL_RECEIVED) -d "mail" --config=config --lisp plugins); write_template!(html_path->"mail/sent.html"(crate::assets::MAIL_SENT) --config=config --lisp plugins); write_template!(html_path->"mail/compose.html"(crate::assets::MAIL_COMPOSE) --config=config --lisp plugins); diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index f995deb..523d7f7 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -316,12 +316,6 @@ version = "1.0.0" "littleweb:action.rename" = "Rename" "littleweb:action.add" = "Add" -"marketplace:label.products" = "Products" -"marketplace:label.status" = "Status" -"marketplace:action.get_started" = "Get started" -"marketplace:action.finsh_setting_up_account" = "Finish setting up my account" -"marketplace:action.open_seller_dashboard" = "Open seller dashboard" - "mail:label.received" = "Received" "mail:label.sent" = "Sent" "mail:label.compose" = "Compose" diff --git a/crates/app/src/public/html/auth/base.lisp b/crates/app/src/public/html/auth/base.lisp index 4940089..e9a8b9b 100644 --- a/crates/app/src/public/html/auth/base.lisp +++ b/crates/app/src/public/html/auth/base.lisp @@ -3,7 +3,7 @@ ("class" "flex flex_col gap_2") ("style" "max-width: 48ch") (h2 - ("class" "w_full text-center") + ("class" "w_full text_center") ; block for title (text "{% block title %}{% endblock %}")) (div diff --git a/crates/app/src/public/html/auth/login.lisp b/crates/app/src/public/html/auth/login.lisp index 9c1e2e4..c2e3cf0 100644 --- a/crates/app/src/public/html/auth/login.lisp +++ b/crates/app/src/public/html/auth/login.lisp @@ -113,10 +113,9 @@ (text "{% endblock %} {% block footer %}") (span - ("class" "small w_full text-center") + ("class" "small w_full text_center") (text "Or, ") (a ("href" "/auth/register") (text "register"))) - (text "{% endblock %}") diff --git a/crates/app/src/public/html/auth/register.lisp b/crates/app/src/public/html/auth/register.lisp index 4beb2fb..0fd9ae9 100644 --- a/crates/app/src/public/html/auth/register.lisp +++ b/crates/app/src/public/html/auth/register.lisp @@ -170,10 +170,9 @@ (text "{% endblock %} {% block footer %}") (span - ("class" "small w_full text-center") + ("class" "small w_full text_center") (text "Or, ") (a ("href" "/auth/login") (text "login"))) - (text "{% endblock %}") diff --git a/crates/app/src/public/html/auth/seller_connection.lisp b/crates/app/src/public/html/auth/seller_connection.lisp deleted file mode 100644 index c13b498..0000000 --- a/crates/app/src/public/html/auth/seller_connection.lisp +++ /dev/null @@ -1,25 +0,0 @@ -(text "{% extends \"auth/base.html\" %} {% block head %}") -(title - (text "Connection")) - -(text "{% endblock %} {% block title %}Connection{% endblock %} {% block content %}") -(div - ("class" "w_full flex_col gap_2") - ("id" "status") - (b - (text "Working..."))) - -(text "{% if connection_type == \"refresh\" %}") -(script - ("defer" "true") - (text "setTimeout(async () => { - trigger(\"seller::onboarding\"); - }, 1000);")) -(text "{% elif connection_type == \"return\" %}") -(script - ("defer" "true") - (text "setTimeout(async () => { - document.getElementById(\"status\").innerHTML = - `Account updated. You can now close this tab.`; - }, 1000);")) -(text "{%- endif %} {% endblock %}") diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index d2b2bf6..3870cc4 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -2668,7 +2668,7 @@ (td (a ("href" "/community/{{ community.title }}/topic/{{ post.topic }}") - ("class" "flex gap_1 items_center") + ("class" "flex gap_1 items_center w_content") (text "{{ self::community_avatar(id=post.community, community=community) }}") (span (text "{% if community.context.display_name -%} {{ community.context.display_name }} {% else %} {{ community.title }} {%- endif %}")))) diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp index 6efc6eb..cd35481 100644 --- a/crates/app/src/public/html/macros.lisp +++ b/crates/app/src/public/html/macros.lisp @@ -376,17 +376,3 @@ (span (text "{{ text \"settings:tab.connections\" }}"))) (text "{%- endmacro %}") - -(text "{% macro seller_settings_nav_options() -%}") -(a - ("data-tab-button" "account") - ("class" "active") - ("href" "#/account") - (icon (text "smile")) - (span (str (text "settings:tab.account")))) -(a - ("data-tab-button" "products") - ("href" "#/products") - (icon (text "package")) - (span (str (text "marketplace:label.products")))) -(text "{%- endmacro %}") diff --git a/crates/app/src/public/html/marketplace/seller.lisp b/crates/app/src/public/html/marketplace/seller.lisp deleted file mode 100644 index c16bb70..0000000 --- a/crates/app/src/public/html/marketplace/seller.lisp +++ /dev/null @@ -1,79 +0,0 @@ -(text "{% extends \"root.html\" %} {% block head %}") -(title - (text "Seller settings - {{ config.name }}")) -(text "{% endblock %} {% block body %} {{ macros::nav() }}") -(main - ("class" "flex flex_col gap_2") - - ; nav - (div - ("class" "mobile_nav mobile") - ; primary nav - (div - ("class" "dropdown") - ("style" "width: max-content") - (button - ("class" "raised small") - ("onclick" "trigger('atto::hooks::dropdown', [event])") - ("exclude" "dropdown") - (icon (text "sliders-horizontal")) - (span ("class" "current_tab_text") (text "account"))) - (div - ("class" "inner left") - (text "{{ macros::seller_settings_nav_options() }}")))) - - ; nav desktop - (div - ("class" "desktop pillmenu") - (text "{{ macros::seller_settings_nav_options() }}")) - - ; ... - (div - ("class" "card w_full lowered flex flex_col gap_2") - ("data-tab" "account") - (div - ("class" "card_nest w_full") - (div - ("class" "card small flex items_center gap_2") - (div - ("class" "notification") - ("style" "width: 46px") - (icon (text "stripe"))) - - (b (str (text "marketplace:label.status")))) - - (div - ("class" "card") - (text "{% if user.seller_data.account_id -%}") - (text "{% if user.seller_data.completed_onboarding -%}") - ; completed onboarding + has stripe account linked - (button - ("onclick" "trigger('seller::login')") - (icon (text "arrow-right")) - (str (text "marketplace:action.open_seller_dashboard"))) - (text "{% else %}") - ; not completed onboarding - (p (text "You've not finished setting up your Stripe account.")) - (p (text "Please complete onboarding to accept payments.")) - - (button - ("onclick" "trigger('seller::onboarding')") - (icon (text "arrow-right")) - (str (text "marketplace:action.finsh_setting_up_account"))) - (text "{%- endif %}") - (text "{% else %}") - ; doesn't have a stripe account linked - (button - ("onclick" "trigger('seller::register')") - (icon (text "arrow-right")) - (str (text "marketplace:action.get_started"))) - (text "{%- endif %}")))) - - (div - ("class" "card w_full lowered hidden flex flex_col gap_2") - ("data-tab" "products") - (div - ("class" "card w_full flex flex_wrap gap_2") - ))) - -(text "{% endblock %}") diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index 9b8ad1d..e1f7def 100644 --- a/crates/app/src/public/js/me.js +++ b/crates/app/src/public/js/me.js @@ -1205,60 +1205,3 @@ ]); }); })(); - -(() => { - const self = reg_ns("seller"); - - self.define("register", async () => { - await trigger("atto::debounce", ["seller::register"]); - - if ( - !(await trigger("atto::confirm", [ - "Are you sure you want to do this?", - ])) - ) { - return; - } - - const res = await ( - await fetch("/api/v1/service_hooks/stripe/seller/register", { - method: "POST", - }) - ).json(); - - trigger("atto::toast", [res.ok ? "success" : "error", res.message]); - self.onboarding(); - }); - - self.define("onboarding", async () => { - await trigger("atto::debounce", ["seller::onboarding"]); - - const res = await ( - await fetch("/api/v1/service_hooks/stripe/seller/onboarding", { - method: "POST", - }) - ).json(); - - trigger("atto::toast", [res.ok ? "success" : "error", res.message]); - - if (res.ok) { - window.location.href = res.payload; - } - }); - - self.define("login", async () => { - await trigger("atto::debounce", ["seller::login"]); - - const res = await ( - await fetch("/api/v1/service_hooks/stripe/seller/login", { - method: "POST", - }) - ).json(); - - trigger("atto::toast", [res.ok ? "success" : "error", res.message]); - - if (res.ok) { - window.location.href = res.payload; - } - }); -})(); diff --git a/crates/app/src/routes/api/v1/auth/connections/stripe.rs b/crates/app/src/routes/api/v1/auth/connections/stripe.rs index 837d2d3..992deac 100644 --- a/crates/app/src/routes/api/v1/auth/connections/stripe.rs +++ b/crates/app/src/routes/api/v1/auth/connections/stripe.rs @@ -1,7 +1,5 @@ -use std::{str::FromStr, time::Duration}; - +use std::time::Duration; use axum::{http::HeaderMap, response::IntoResponse, Extension, Json}; -use crate::cookie::CookieJar; use tetratto_core::model::{ auth::{Notification, User}, moderation::AuditLogEntry, @@ -9,7 +7,7 @@ use tetratto_core::model::{ ApiReturn, Error, }; use stripe::{EventObject, EventType}; -use crate::{get_user_from_token, State}; +use crate::State; pub async fn stripe_webhook( Extension(data): Extension, @@ -471,145 +469,3 @@ pub async fn stripe_webhook( payload: (), }) } - -pub async fn onboarding_account_link_request( - jar: CookieJar, - Extension(data): Extension, -) -> impl IntoResponse { - let data = &(data.read().await); - let user = match get_user_from_token!(jar, data.0) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - if user.seller_data.account_id.is_some() { - return Json(Error::NotAllowed.into()); - } - - let client = match data.3 { - Some(ref c) => c, - None => return Json(Error::Unknown.into()), - }; - - match stripe::AccountLink::create( - &client, - stripe::CreateAccountLink { - account: match user.seller_data.account_id { - Some(id) => stripe::AccountId::from_str(&id).unwrap(), - None => return Json(Error::NotAllowed.into()), - }, - type_: stripe::AccountLinkType::AccountOnboarding, - collect: None, - expand: &[], - refresh_url: Some(&format!( - "{}/auth/connections_link/seller/refresh", - data.0.0.0.host - )), - return_url: Some(&format!( - "{}/auth/connections_link/seller/return", - data.0.0.0.host - )), - collection_options: None, - }, - ) - .await - { - Ok(x) => Json(ApiReturn { - ok: true, - message: "Acceptable".to_string(), - payload: Some(x.url), - }), - Err(e) => Json(Error::MiscError(e.to_string()).into()), - } -} - -pub async fn create_seller_account_request( - jar: CookieJar, - Extension(data): Extension, -) -> impl IntoResponse { - let data = &(data.read().await); - let mut user = match get_user_from_token!(jar, data.0) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - if user.seller_data.account_id.is_some() { - return Json(Error::NotAllowed.into()); - } - - let client = match data.3 { - Some(ref c) => c, - None => return Json(Error::Unknown.into()), - }; - - let account = match stripe::Account::create( - &client, - stripe::CreateAccount { - type_: Some(stripe::AccountType::Express), - capabilities: Some(stripe::CreateAccountCapabilities { - card_payments: Some(stripe::CreateAccountCapabilitiesCardPayments { - requested: Some(true), - }), - transfers: Some(stripe::CreateAccountCapabilitiesTransfers { - requested: Some(true), - }), - ..Default::default() - }), - ..Default::default() - }, - ) - .await - { - Ok(a) => a, - Err(e) => return Json(Error::MiscError(e.to_string()).into()), - }; - - user.seller_data.account_id = Some(account.id.to_string()); - match data - .0 - .update_user_seller_data(user.id, user.seller_data) - .await - { - Ok(_) => Json(ApiReturn { - ok: true, - message: "Acceptable".to_string(), - payload: (), - }), - Err(e) => return Json(e.into()), - } -} - -pub async fn login_link_request( - jar: CookieJar, - Extension(data): Extension, -) -> impl IntoResponse { - let data = &(data.read().await); - let user = match get_user_from_token!(jar, data.0) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - if user.seller_data.account_id.is_none() | !user.seller_data.completed_onboarding { - return Json(Error::NotAllowed.into()); - } - - let client = match data.3 { - Some(ref c) => c, - None => return Json(Error::Unknown.into()), - }; - - match stripe::LoginLink::create( - &client, - &stripe::AccountId::from_str(&user.seller_data.account_id.unwrap()).unwrap(), - &data.0.0.0.host, - ) - .await - { - Ok(x) => Json(ApiReturn { - ok: true, - message: "Acceptable".to_string(), - payload: Some(x.url), - }), - Err(e) => Json(Error::MiscError(e.to_string()).into()), - } -} diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index a4ec514..969cbce 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -1,4 +1,4 @@ -use std::{str::FromStr, time::Duration}; +use std::time::Duration; use crate::{ get_user_from_token, model::{ApiReturn, Error}, @@ -572,28 +572,11 @@ pub async fn delete_user_request( .delete_user(id, &req.password, user.permissions.check_manager()) .await { - Ok(ua) => { - // delete stripe user - if let Some(stripe_id) = ua.seller_data.account_id - && let Some(ref client) = data.3 - { - if let Err(e) = stripe::Account::delete( - &client, - &stripe::AccountId::from_str(&stripe_id).unwrap(), - ) - .await - { - return Json(Error::MiscError(e.to_string()).into()); - } - } - - // ... - Json(ApiReturn { - ok: true, - message: "User deleted".to_string(), - payload: (), - }) - } + Ok(_) => Json(ApiReturn { + ok: true, + message: "User deleted".to_string(), + payload: (), + }), Err(e) => Json(e.into()), } } diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 694ad29..5539bf4 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -8,7 +8,6 @@ pub mod journals; pub mod letters; pub mod notes; pub mod notifications; -pub mod products; pub mod reactions; pub mod reports; pub mod requests; @@ -34,7 +33,6 @@ use tetratto_core::model::{ littleweb::{DomainData, DomainTld, ServiceFsEntry}, oauth::AppScope, permissions::{FinePermission, SecondaryPermission}, - products::{ProductPrice, ProductType}, reactions::AssetType, stacks::{StackMode, StackPrivacy, StackSort}, }; @@ -566,18 +564,6 @@ pub fn routes() -> Router { "/service_hooks/stripe", post(auth::connections::stripe::stripe_webhook), ) - .route( - "/service_hooks/stripe/seller/register", - post(auth::connections::stripe::create_seller_account_request), - ) - .route( - "/service_hooks/stripe/seller/onboarding", - post(auth::connections::stripe::onboarding_account_link_request), - ) - .route( - "/service_hooks/stripe/seller/login", - post(auth::connections::stripe::login_link_request), - ) // channels .route("/channels", post(channels::channels::create_request)) .route( @@ -716,17 +702,6 @@ pub fn routes() -> Router { .route("/domains/{id}", get(domains::get_request)) .route("/domains/{id}", delete(domains::delete_request)) .route("/domains/{id}/data", post(domains::update_data_request)) - // products - .route("/products", get(products::list_request)) - .route("/products", post(products::create_request)) - .route("/products/{id}", get(products::get_request)) - .route("/products/{id}", delete(products::delete_request)) - .route("/products/{id}/name", post(products::update_name_request)) - .route( - "/products/{id}/description", - post(products::update_description_request), - ) - .route("/products/{id}/price", post(products::update_price_request)) // letters .route("/letters", post(letters::create_request)) .route("/letters/{id}", get(letters::get_request)) @@ -1207,29 +1182,6 @@ pub struct UpdateDomainData { pub data: Vec<(String, DomainData)>, } -#[derive(Deserialize)] -pub struct CreateProduct { - pub name: String, - pub description: String, - pub product_type: ProductType, - pub price: ProductPrice, -} - -#[derive(Deserialize)] -pub struct UpdateProductName { - pub name: String, -} - -#[derive(Deserialize)] -pub struct UpdateProductDescription { - pub description: String, -} - -#[derive(Deserialize)] -pub struct UpdateProductPrice { - pub price: ProductPrice, -} - #[derive(Deserialize)] pub struct UpdateUploadAlt { pub alt: String, diff --git a/crates/app/src/routes/api/v1/products.rs b/crates/app/src/routes/api/v1/products.rs deleted file mode 100644 index 4d53814..0000000 --- a/crates/app/src/routes/api/v1/products.rs +++ /dev/null @@ -1,234 +0,0 @@ -use crate::{ - get_user_from_token, - image::{save_webp_buffer, JsonMultipart}, - routes::{ - api::v1::{ - communities::posts::MAXIMUM_FILE_SIZE, CreateProduct, UpdateProductDescription, - UpdateProductName, UpdateProductPrice, - }, - pages::PaginatedQuery, - }, - State, -}; -use axum::{ - extract::{Path, Query}, - response::IntoResponse, - Extension, Json, -}; -use crate::cookie::CookieJar; -use tetratto_core::model::{ - oauth, - products::Product, - uploads::{MediaType, MediaUpload}, - ApiReturn, Error, -}; - -pub async fn get_request( - Path(id): Path, - Extension(data): Extension, -) -> impl IntoResponse { - let data = &(data.read().await).0; - match data.get_product_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, - Query(props): Query, -) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadProducts) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - match data.get_products_by_user(user.id, 12, props.page).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, - JsonMultipart(uploads, req): JsonMultipart, -) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateProducts) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - if uploads.len() > 4 { - return Json( - Error::MiscError("Too many uploads. Please use a maximum of 4".to_string()).into(), - ); - } - - let mut product = Product::new( - user.id, - req.name, - req.description, - req.price, - req.product_type, - ); - - // check sizes - for img in &uploads { - if img.len() > MAXIMUM_FILE_SIZE { - return Json(Error::FileTooLarge.into()); - } - } - - // create uploads - for _ in 0..uploads.len() { - product.uploads.push( - match data - .create_upload(MediaUpload::new(MediaType::Webp, product.owner)) - .await - { - Ok(u) => u.id, - Err(e) => return Json(e.into()), - }, - ); - } - - let product_uploads = product.uploads.clone(); - match data.create_product(product).await { - Ok(x) => { - // store uploads - for (i, upload_id) in product_uploads.iter().enumerate() { - let image = match uploads.get(i) { - Some(img) => img, - None => { - if let Err(e) = data.delete_upload(*upload_id).await { - return Json(e.into()); - } - - continue; - } - }; - - let upload = match data.get_upload_by_id(*upload_id).await { - Ok(u) => u, - Err(e) => return Json(e.into()), - }; - - if let Err(e) = - save_webp_buffer(&upload.path(&data.0.0).to_string(), image.to_vec(), None) - { - return Json(Error::MiscError(e.to_string()).into()); - } - } - - // ... - Json(ApiReturn { - ok: true, - message: "Product created".to_string(), - payload: x.id.to_string(), - }) - } - Err(e) => Json(e.into()), - } -} - -pub async fn update_name_request( - jar: CookieJar, - Extension(data): Extension, - Path(id): Path, - Json(req): Json, -) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - match data.update_product_name(id, &user, &req.name).await { - Ok(_) => Json(ApiReturn { - ok: true, - message: "Product updated".to_string(), - payload: (), - }), - Err(e) => Json(e.into()), - } -} - -pub async fn update_description_request( - jar: CookieJar, - Extension(data): Extension, - Path(id): Path, - Json(req): Json, -) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - match data - .update_product_description(id, &user, &req.description) - .await - { - Ok(_) => Json(ApiReturn { - ok: true, - message: "Product updated".to_string(), - payload: (), - }), - Err(e) => Json(e.into()), - } -} - -pub async fn update_price_request( - jar: CookieJar, - Extension(data): Extension, - Path(id): Path, - Json(req): Json, -) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - match data.update_product_price(id, &user, req.price).await { - Ok(_) => Json(ApiReturn { - ok: true, - message: "Product updated".to_string(), - payload: (), - }), - Err(e) => Json(e.into()), - } -} - -pub async fn delete_request( - jar: CookieJar, - Extension(data): Extension, - Path(id): Path, -) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - match data.delete_product(id, &user).await { - Ok(_) => Json(ApiReturn { - ok: true, - message: "Product deleted".to_string(), - payload: (), - }), - Err(e) => Json(e.into()), - } -} diff --git a/crates/app/src/routes/pages/marketplace.rs b/crates/app/src/routes/pages/marketplace.rs deleted file mode 100644 index 8d5a3be..0000000 --- a/crates/app/src/routes/pages/marketplace.rs +++ /dev/null @@ -1,107 +0,0 @@ -use super::render_error; -use crate::{ - assets::initial_context, get_lang, get_user_from_token, State, routes::pages::PaginatedQuery, -}; -use axum::{ - extract::Query, - response::{Html, IntoResponse}, - Extension, -}; -use crate::cookie::CookieJar; -use tetratto_core::model::Error; - -/// `/settings/seller` -pub async fn seller_settings_request( - jar: CookieJar, - Extension(data): Extension, - Query(props): Query, -) -> 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 products = match data.0.get_products_by_user(user.id, 12, props.page).await { - Ok(x) => x, - Err(e) => 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("list", &products); - context.insert("page", &props.page); - - // return - Ok(Html( - data.1.render("marketplace/seller.html", &context).unwrap(), - )) -} - -pub async fn connection_return_request( - jar: CookieJar, - Extension(data): Extension, -) -> 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, - )); - } - }; - - // update user - user.seller_data.completed_onboarding = true; - if let Err(e) = data - .0 - .update_user_seller_data(user.id, user.seller_data.clone()) - .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("connection_type", "return"); - - // return - Ok(Html( - data.1 - .render("auth/seller_connection.html", &context) - .unwrap(), - )) -} - -pub async fn connection_reload_request( - jar: CookieJar, - Extension(data): Extension, -) -> 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 lang = get_lang!(jar, data.0); - let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; - context.insert("connection_type", "reload"); - - // return - Ok(Html( - data.1 - .render("auth/seller_connection.html", &context) - .unwrap(), - )) -} diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 01f24d6..93cfc9a 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -6,7 +6,6 @@ pub mod forge; pub mod journals; pub mod littleweb; pub mod mail; -pub mod marketplace; pub mod misc; pub mod mod_panel; pub mod profile; @@ -77,14 +76,6 @@ pub fn routes() -> Router { "/auth/connections_link/app/{id}", get(developer::connection_callback_request), ) - .route( - "/auth/connections_link/seller/reload", - get(marketplace::connection_reload_request), - ) - .route( - "/auth/connections_link/seller/return", - get(marketplace::connection_return_request), - ) // profile .route("/settings", get(profile::settings_request)) .route("/@{username}", get(profile::posts_request)) @@ -163,11 +154,6 @@ pub fn routes() -> Router { .route("/domains/{id}", get(littleweb::domain_request)) .route("/net", get(littleweb::browser_home_request)) .route("/net/{*uri}", get(littleweb::browser_request)) - // marketplace - .route( - "/settings/seller", - get(marketplace::seller_settings_request), - ) // mail .route("/mail", get(mail::received_request)) .route("/mail/sent", get(mail::sent_request)) diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index e0aef74..05698ff 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -1,8 +1,7 @@ use super::common::NAME_REGEX; use oiseau::cache::Cache; use crate::model::auth::{ - Achievement, AchievementName, AchievementRarity, Notification, StripeSellerData, - UserConnections, ACHIEVEMENTS, + Achievement, AchievementName, AchievementRarity, Notification, UserConnections, ACHIEVEMENTS, }; use crate::model::moderation::AuditLogEntry; use crate::model::oauth::AuthGrant; @@ -125,11 +124,10 @@ impl DataManager { awaiting_purchase: get!(x->24(i32)) as i8 == 1, was_purchased: get!(x->25(i32)) as i8 == 1, browser_session: get!(x->26(String)), - seller_data: serde_json::from_str(&get!(x->27(String)).to_string()).unwrap(), - ban_reason: get!(x->28(String)), - channel_mutes: serde_json::from_str(&get!(x->29(String)).to_string()).unwrap(), - is_deactivated: get!(x->30(i32)) as i8 == 1, - ban_expire: get!(x->31(i64)) as usize, + ban_reason: get!(x->27(String)), + channel_mutes: serde_json::from_str(&get!(x->28(String)).to_string()).unwrap(), + is_deactivated: get!(x->29(i32)) as i8 == 1, + ban_expire: get!(x->30(i64)) as usize, } } @@ -286,7 +284,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, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32)", + "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, $27, $28, $29, $30, $31)", params![ &(data.id as i64), &(data.created as i64), @@ -315,7 +313,6 @@ impl DataManager { &if data.awaiting_purchase { 1_i32 } else { 0_i32 }, &if data.was_purchased { 1_i32 } else { 0_i32 }, &data.browser_session, - &serde_json::to_string(&data.seller_data).unwrap(), &data.ban_reason, &serde_json::to_string(&data.channel_mutes).unwrap(), &if data.is_deactivated { 1_i32 } else { 0_i32 }, @@ -1058,7 +1055,6 @@ impl DataManager { auto_method!(update_user_achievements(Vec)@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!(update_user_browser_session(&str)@get_user_by_id -> "UPDATE users SET browser_session = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); - auto_method!(update_user_seller_data(StripeSellerData)@get_user_by_id -> "UPDATE users SET seller_data = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(update_user_ban_reason(&str)@get_user_by_id -> "UPDATE users SET ban_reason = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); auto_method!(update_user_channel_mutes(Vec)@get_user_by_id -> "UPDATE users SET channel_mutes = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(update_user_ban_expire(i64)@get_user_by_id -> "UPDATE users SET ban_expire = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 5e10783..71e792d 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -42,7 +42,6 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_INVITE_CODES).unwrap(); execute!(&conn, common::CREATE_TABLE_DOMAINS).unwrap(); execute!(&conn, common::CREATE_TABLE_SERVICES).unwrap(); - execute!(&conn, common::CREATE_TABLE_PRODUCTS).unwrap(); execute!(&conn, common::CREATE_TABLE_APP_DATA).unwrap(); execute!(&conn, common::CREATE_TABLE_LETTERS).unwrap(); diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index bccbfb9..4881179 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -30,6 +30,5 @@ pub const CREATE_TABLE_MESSAGE_REACTIONS: &str = include_str!("./sql/create_mess pub const CREATE_TABLE_INVITE_CODES: &str = include_str!("./sql/create_invite_codes.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"); -pub const CREATE_TABLE_PRODUCTS: &str = include_str!("./sql/create_products.sql"); pub const CREATE_TABLE_APP_DATA: &str = include_str!("./sql/create_app_data.sql"); pub const CREATE_TABLE_LETTERS: &str = include_str!("./sql/create_letters.sql"); diff --git a/crates/core/src/database/drivers/sql/create_products.sql b/crates/core/src/database/drivers/sql/create_products.sql deleted file mode 100644 index 4a972aa..0000000 --- a/crates/core/src/database/drivers/sql/create_products.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE IF NOT EXISTS products ( - id BIGINT NOT NULL PRIMARY KEY, - created BIGINT NOT NULL, - owner BIGINT NOT NULL, - name TEXT NOT NULL, - description TEXT NOT NULL, - likes INT NOT NULL, - dislikes INT NOT NULL, - product_type TEXT NOT NULL, - price TEXT NOT NULL, - uploads TEXT NOT NULL -) diff --git a/crates/core/src/database/drivers/sql/create_users.sql b/crates/core/src/database/drivers/sql/create_users.sql index 06e86ef..e68a27c 100644 --- a/crates/core/src/database/drivers/sql/create_users.sql +++ b/crates/core/src/database/drivers/sql/create_users.sql @@ -26,7 +26,6 @@ CREATE TABLE IF NOT EXISTS users ( awaiting_purchase INT NOT NULL, was_purchased INT NOT NULL, browser_session TEXT NOT NULL, - seller_data TEXT NOT NULL, ban_reason TEXT NOT NULL, channel_mutes TEXT NOT NULL, is_deactivated INT NOT NULL, diff --git a/crates/core/src/database/drivers/sql/version_migrations.sql b/crates/core/src/database/drivers/sql/version_migrations.sql index 5c6c51e..5f3148c 100644 --- a/crates/core/src/database/drivers/sql/version_migrations.sql +++ b/crates/core/src/database/drivers/sql/version_migrations.sql @@ -29,3 +29,7 @@ ADD COLUMN IF NOT EXISTS topic BIGINT DEFAULT 0; -- users ban_expire ALTER TABLE users ADD COLUMN IF NOT EXISTS ban_expire BIGINT DEFAULT 0; + +-- remove users seller_data +ALTER TABLE users +DROP COLUMN IF EXISTS seller_data; diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index 218bcd6..5650d7d 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -23,7 +23,6 @@ mod notifications; mod polls; mod pollvotes; mod posts; -mod products; mod questions; mod reactions; mod reports; diff --git a/crates/core/src/database/products.rs b/crates/core/src/database/products.rs deleted file mode 100644 index b5e32a5..0000000 --- a/crates/core/src/database/products.rs +++ /dev/null @@ -1,175 +0,0 @@ -use crate::model::{ - auth::User, - permissions::{FinePermission, SecondaryPermission}, - products::{Product, ProductPrice}, - Error, Result, -}; -use crate::{auto_method, DataManager}; -use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow}; - -impl DataManager { - /// Get a [`Product`] from an SQL row. - pub(crate) fn get_product_from_row(x: &PostgresRow) -> Product { - Product { - 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)), - description: get!(x->4(String)), - likes: get!(x->5(i32)) as isize, - dislikes: get!(x->6(i32)) as isize, - product_type: serde_json::from_str(&get!(x->7(String))).unwrap(), - price: serde_json::from_str(&get!(x->8(String))).unwrap(), - uploads: serde_json::from_str(&get!(x->9(String))).unwrap(), - } - } - - auto_method!(get_product_by_id(usize as i64)@get_product_from_row -> "SELECT * FROM products WHERE id = $1" --name="product" --returns=Product --cache-key-tmpl="atto.product:{}"); - - /// Get all products by user. - /// - /// # Arguments - /// * `id` - the ID of the user to fetch products for - /// * `batch` - /// * `page` - pub async fn get_products_by_user( - &self, - id: usize, - batch: usize, - page: usize, - ) -> Result> { - 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 products WHERE owner = $1 ORDER BY created DESC LIMIT {} OFFSET {}", - &[&(id as i64), &(batch as i64), &((page * batch) as i64)], - |x| { Self::get_product_from_row(x) } - ); - - if res.is_err() { - return Err(Error::GeneralNotFound("product".to_string())); - } - - Ok(res.unwrap()) - } - - /// Get all products by user. - /// - /// # Arguments - /// * `id` - the ID of the user to fetch products for - pub async fn get_products_by_user_all(&self, id: usize) -> Result> { - 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 products WHERE owner = $1 ORDER BY created DESC", - &[&(id as i64)], - |x| { Self::get_product_from_row(x) } - ); - - if res.is_err() { - return Err(Error::GeneralNotFound("product".to_string())); - } - - Ok(res.unwrap()) - } - - const MAXIMUM_FREE_PRODUCTS: usize = 15; - - /// Create a new product in the database. - /// - /// # Arguments - /// * `data` - a mock [`Product`] object to insert - pub async fn create_product(&self, data: Product) -> Result { - // check values - if data.name.trim().len() < 2 { - return Err(Error::DataTooShort("name".to_string())); - } else if data.name.len() > 128 { - return Err(Error::DataTooLong("name".to_string())); - } - - // check number of products - let owner = self.get_user_by_id(data.owner).await?; - - if !owner.permissions.check(FinePermission::SUPPORTER) { - let products = self - .get_table_row_count_where("products", &format!("owner = {}", owner.id)) - .await? as usize; - - if products >= Self::MAXIMUM_FREE_PRODUCTS { - return Err(Error::MiscError( - "You already have the maximum number of products you can have".to_string(), - )); - } - } - - // ... - let conn = match self.0.connect().await { - Ok(c) => c, - Err(e) => return Err(Error::DatabaseConnection(e.to_string())), - }; - - let res = execute!( - &conn, - "INSERT INTO products VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", - params![ - &(data.id as i64), - &(data.created as i64), - &(data.owner as i64), - &data.name, - &data.description, - &0_i32, - &0_i32, - &serde_json::to_string(&data.product_type).unwrap(), - &serde_json::to_string(&data.price).unwrap(), - &serde_json::to_string(&data.uploads).unwrap(), - ] - ); - - if let Err(e) = res { - return Err(Error::DatabaseError(e.to_string())); - } - - Ok(data) - } - - pub async fn delete_product(&self, id: usize, user: &User) -> Result<()> { - let product = self.get_product_by_id(id).await?; - - // check user permission - if user.id != product.owner - && !user - .secondary_permissions - .check(SecondaryPermission::MANAGE_PRODUCTS) - { - 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 products WHERE id = $1", &[&(id as i64)]); - - if let Err(e) = res { - return Err(Error::DatabaseError(e.to_string())); - } - - // ... - self.0.1.remove(format!("atto.product:{}", id)).await; - Ok(()) - } - - auto_method!(update_product_name(&str)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET name = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}"); - auto_method!(update_product_description(&str)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET description = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}"); - auto_method!(update_product_price(ProductPrice)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET price = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.product:{}"); -} diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 7622f1a..ef19586 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -79,9 +79,6 @@ pub struct User { /// view pages which require authentication (all `$` routes). #[serde(default)] pub browser_session: String, - /// Stripe connected account information (for Tetratto marketplace). - #[serde(default)] - pub seller_data: StripeSellerData, /// The reason the user was banned. #[serde(default)] pub ban_reason: String, @@ -355,14 +352,6 @@ pub struct UserSettings { pub forum_signature: String, } -#[derive(Clone, Debug, Serialize, Deserialize, Default)] -pub struct StripeSellerData { - #[serde(default)] - pub account_id: Option, - #[serde(default)] - pub completed_onboarding: bool, -} - fn mime_avif() -> String { "image/avif".to_string() } @@ -407,7 +396,6 @@ impl User { awaiting_purchase: false, was_purchased: false, browser_session: String::new(), - seller_data: StripeSellerData::default(), ban_reason: String::new(), channel_mutes: Vec::new(), is_deactivated: false, diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index 5bf8c52..b78dd37 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -11,7 +11,6 @@ pub mod mail; pub mod moderation; pub mod oauth; pub mod permissions; -pub mod products; pub mod reactions; pub mod requests; pub mod socket; diff --git a/crates/core/src/model/products.rs b/crates/core/src/model/products.rs deleted file mode 100644 index 2b90ca5..0000000 --- a/crates/core/src/model/products.rs +++ /dev/null @@ -1,88 +0,0 @@ -use std::fmt::Display; - -use serde::{Serialize, Deserialize}; -use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Product { - pub id: usize, - pub created: usize, - pub owner: usize, - pub name: String, - pub description: String, - pub likes: isize, - pub dislikes: isize, - pub product_type: ProductType, - pub price: ProductPrice, - /// Optional uploads to accompany the product title and description. Maximum of 4. - pub uploads: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ProductType { - /// Text + images. - Data, - /// When a commission product is purchased, the creator will receive a request - /// prompting them to respond with text + images. - /// - /// This is the only product type which does not immediately return data to the - /// customer, as seller input is required. - /// - /// If the request is deleted, the purchase should be immediately refunded. - /// - /// Commissions are paid beforehand to prevent theft. This means it is vital - /// that refunds are enforced. - Commission, -} - -/// A currency. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum Currency { - USD, - EUR, - GBP, -} - -impl Display for Currency { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - Currency::USD => "$", - Currency::EUR => "€", - Currency::GBP => "£", - }) - } -} - -/// Price in USD. `(dollars, cents)`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProductPrice(u64, u64, Currency); - -impl Display for ProductPrice { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&format!("{}{}.{}", self.2, self.0, self.1)) - } -} - -impl Product { - /// Create a new [`Product`]. - pub fn new( - owner: usize, - name: String, - description: String, - price: ProductPrice, - r#type: ProductType, - ) -> Self { - Self { - id: Snowflake::new().to_string().parse::().unwrap(), - created: unix_epoch_timestamp(), - owner, - name, - description, - likes: 0, - dislikes: 0, - product_type: r#type, - price, - uploads: Vec::new(), - } - } -} From 81a76288613276cb10f11ade4f19a1f2985b4636 Mon Sep 17 00:00:00 2001 From: trisua Date: Wed, 6 Aug 2025 23:13:31 -0400 Subject: [PATCH 11/38] add: better pinned posts ui --- crates/app/src/public/css/style.css | 2 +- .../app/src/public/html/communities/feed.lisp | 14 +-- crates/app/src/public/html/components.lisp | 6 +- crates/app/src/public/html/profile/base.lisp | 10 -- crates/app/src/public/html/profile/posts.lisp | 21 ++-- .../src/public/html/profile/responses.lisp | 21 ++-- .../app/src/public/html/profile/settings.lisp | 5 - crates/app/src/routes/api/v1/auth/profile.rs | 30 ------ crates/app/src/routes/api/v1/mod.rs | 4 - crates/app/src/routes/pages/profile.rs | 6 -- crates/core/src/database/posts.rs | 95 +------------------ crates/core/src/model/auth.rs | 3 - 12 files changed, 25 insertions(+), 192 deletions(-) diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index d02e7b0..1333485 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -502,7 +502,7 @@ select:focus { } input[type="checkbox"] { - --color: #c9b1bc; + --color: #93c5fd; appearance: none; border-radius: var(--radius); transition: diff --git a/crates/app/src/public/html/communities/feed.lisp b/crates/app/src/public/html/communities/feed.lisp index 9c73512..ccf868e 100644 --- a/crates/app/src/public/html/communities/feed.lisp +++ b/crates/app/src/public/html/communities/feed.lisp @@ -1,18 +1,7 @@ (text "{% import \"components.html\" as components %} {% extends \"communities/base.html\" %} {% block content %}") (div ("class" "flex flex_col gap_4 w_full") - (text "{{ macros::community_nav(community=community, selected=\"posts\") }} {% if pinned|length != 0 %}") - (div - ("class" "card_nest") - (div - ("class" "card small flex gap_2 items_center") - (text "{{ icon \"pin\" }}") - (span - (text "{{ text \"communities:label.pinned\" }}"))) - (div - ("class" "card flex flex_col gap_4") - (text "{% for post in pinned %} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[2], post=post[0], owner=post[1], secondary=true, show_community=false, can_manage_post=can_manage_posts) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[3], secondary=true, show_community=false, can_manage_post=can_manage_posts, poll=post[4]) }} {%- endif %} {% endfor %}"))) - (text "{%- endif %}") + (text "{{ macros::community_nav(community=community, selected=\"posts\") }}") (div ("class" "card_nest") (div @@ -22,5 +11,6 @@ (text "{{ text \"communities:label.posts\" }}"))) (div ("class" "card flex flex_col gap_4") + (text "{% for post in pinned %} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[2], post=post[0], owner=post[1], secondary=true, show_community=false, can_manage_post=can_manage_posts) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[3], secondary=true, show_community=false, can_manage_post=can_manage_posts, poll=post[4]) }} {%- endif %} {% endfor %}") (text "{% for post in feed %} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[2], post=post[0], owner=post[1], secondary=true, show_community=false, can_manage_post=can_manage_posts) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[3], secondary=true, show_community=false, can_manage_post=can_manage_posts, poll=post[4]) }} {%- endif %} {% endfor %} {{ components::pagination(page=page, items=feed|length) }}")))) (text "{% endblock %}") diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 3870cc4..0a03300 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -358,8 +358,7 @@ ("class" "flush flex gap_1 items_center") (text "{{ self::community_avatar(id=post.community, community=community) }}") (b - (text "{% if community.context.display_name -%} {{ community.context.display_name }} {% else %} {{ community.title }} {%- endif %}")) - (text "{% if post.context.is_pinned or post.context.is_profile_pinned -%} {{ icon \"pin\" }} {%- endif %}"))) + (text "{% if community.context.display_name -%} {{ community.context.display_name }} {% else %} {{ community.title }} {%- endif %}")))) (text "{%- endif %} {%- endif %}") (div ("class" "card flex flex_col post gap_2 post:{{ post.id }} {% if secondary -%}secondary{%- endif %}") @@ -386,7 +385,8 @@ (span ("class" "name") (text "{{ self::full_username(user=owner) }}")) - (text "{{ self::post_info(post=post, community=community) }}")) + (text "{{ self::post_info(post=post, community=community) }}") + (text "{% if post.context.is_pinned or post.context.is_profile_pinned -%} {{ icon \"pin\" }} {%- endif %}")) (text "{% if not dont_show_title and post.title and community and community.context.enable_titles -%}") ; post has a title AND whatever is rendering this component wants to see it (a diff --git a/crates/app/src/public/html/profile/base.lisp b/crates/app/src/public/html/profile/base.lisp index a7e1cd3..e730aa6 100644 --- a/crates/app/src/public/html/profile/base.lisp +++ b/crates/app/src/public/html/profile/base.lisp @@ -170,16 +170,6 @@ (text "Posts")) (span (text "{{ profile.post_count }}"))) - (text "{% if gpa and gpa > 0 and (not user.settings.disable_gpa_fun or is_helper) -%}") - (div - ("class" "w_full flex justify_between items_center") - ("title" "great post average (limited time fun)") - (span - ("class" "notification chip") - (text "GPA")) - (span - (text "{{ gpa|round(method=\"floor\", precision=2) }}"))) - (text "{%- endif %}") (text "{% if not profile.settings.private_last_seen or is_self or is_helper %}") (div ("class" "w_full flex justify_between items_center") diff --git a/crates/app/src/public/html/profile/posts.lisp b/crates/app/src/public/html/profile/posts.lisp index ab33a08..7f40dbb 100644 --- a/crates/app/src/public/html/profile/posts.lisp +++ b/crates/app/src/public/html/profile/posts.lisp @@ -3,19 +3,8 @@ ("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=\"posts\") }}") +(text "{%- endif %}") +(text "{{ macros::profile_nav(selected=\"posts\") }}") (div ("class" "card_nest") (div @@ -42,6 +31,12 @@ (div ("class" "card w_full flex flex_col gap_2") ("ui_ident" "io_data_load") + ; pinned + (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 "{% if pinned|length > 0 -%}") + (div ("class" "squig")) + (text "{%- endif %}") + ; ... (div ("ui_ident" "io_data_marker")))) (text "{% set paged = user and user.settings.paged_timelines %}") diff --git a/crates/app/src/public/html/profile/responses.lisp b/crates/app/src/public/html/profile/responses.lisp index 93c605e..37e2e9e 100644 --- a/crates/app/src/public/html/profile/responses.lisp +++ b/crates/app/src/public/html/profile/responses.lisp @@ -3,19 +3,8 @@ ("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\") }}") +(text "{%- endif %}") +(text "{{ macros::profile_nav(selected=\"responses\") }}") (div ("class" "card_nest") (div @@ -42,6 +31,12 @@ (div ("class" "card w_full flex flex_col gap_2") ("ui_ident" "io_data_load") + ; pinned + (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 "{% if pinned|length > 0 -%}") + (div ("class" "squig")) + (text "{%- endif %}") + ; ... (div ("ui_ident" "io_data_marker")))) (text "{% set paged = user and user.settings.paged_timelines %}") diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 0b7abad..3f5901a 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -1970,11 +1970,6 @@ \"text\", ], [[], \"Fun\", \"title\"], - [ - [\"disable_gpa_fun\", \"Disable GPA\"], - \"{{ profile.settings.disable_gpa_fun }}\", - \"checkbox\", - ], [ [\"disable_achievements\", \"Disable achievements\"], \"{{ profile.settings.disable_achievements }}\", diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index 969cbce..ba3c17a 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -869,36 +869,6 @@ pub async fn post_to_socket_request( }) } -/// Calculate the user's great post average. -pub async fn get_user_gpa_request( - jar: CookieJar, - Path(id): Path, - Extension(data): Extension, -) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadProfile) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - if !user.permissions.check(FinePermission::MANAGE_USERS) { - return Json(Error::NotAllowed.into()); - } - - let gpa = data.calculate_user_gpa(id).await; - return Json(ApiReturn { - ok: true, - message: if gpa >= 3.0 { - "cool".to_string() - } else if gpa >= 4.0 { - "extraordinary".to_string() - } else { - "ok".to_string() - }, - payload: Some(gpa), - }); -} - /// Remove a grant token. pub async fn remove_grant_request( jar: CookieJar, diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 5539bf4..5e5ddb4 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -405,10 +405,6 @@ pub fn routes() -> Router { get(auth::profile::redirect_from_stripe_id), ) .route("/auth/ip/{ip}/block", post(auth::social::ip_block_request)) - .route( - "/auth/user/{id}/gpa", - get(auth::profile::get_user_gpa_request), - ) .route( "/auth/user/{id}/_connect/{stream}", any(auth::profile::subscription_handler), diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index 3f6274b..9f5b16d 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -371,7 +371,6 @@ pub async fn posts_request( context.insert("pinned", &pinned); context.insert("page", &props.page); context.insert("tag", &props.tag); - context.insert("gpa", &data.0.calculate_user_gpa(other_user.id).await); profile_context( &mut context, &user, @@ -488,7 +487,6 @@ pub async fn replies_request( context.insert("posts", &posts); context.insert("page", &props.page); - context.insert("gpa", &data.0.calculate_user_gpa(other_user.id).await); profile_context( &mut context, &user, @@ -601,7 +599,6 @@ pub async fn media_request( context.insert("posts", &posts); context.insert("page", &props.page); - context.insert("gpa", &data.0.calculate_user_gpa(other_user.id).await); profile_context( &mut context, &user, @@ -696,7 +693,6 @@ pub async fn outbox_request( context.insert("questions", &questions); context.insert("page", &props.page); - context.insert("gpa", &data.0.calculate_user_gpa(other_user.id).await); profile_context( &mut context, &Some(user), @@ -806,7 +802,6 @@ pub async fn following_request( context.insert("list", &list); context.insert("page", &props.page); - context.insert("gpa", &data.0.calculate_user_gpa(other_user.id).await); profile_context( &mut context, &user, @@ -916,7 +911,6 @@ pub async fn followers_request( context.insert("list", &list); context.insert("page", &props.page); - context.insert("gpa", &data.0.calculate_user_gpa(other_user.id).await); profile_context( &mut context, &user, diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index a98250f..7a7a2a7 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -13,9 +13,7 @@ use crate::model::{ }; use tetratto_shared::unix_epoch_timestamp; use crate::{auto_method, DataManager}; - -use oiseau::{PostgresRow, cache::redis::Commands}; -use oiseau::{execute, get, query_row, query_rows, params, cache::Cache}; +use oiseau::{PostgresRow, execute, get, query_row, query_rows, params, cache::Cache}; pub type FullPost = ( Post, @@ -816,95 +814,6 @@ impl DataManager { Ok(res.unwrap()) } - /// Calculate the GPA (great post average) of a given user. - /// - /// To be considered a "great post", a post must have a score ((likes - dislikes) / (likes + dislikes)) - /// of at least 0.6. - /// - /// GPA is calculated based on the user's last 48 posts. - pub async fn calculate_user_gpa(&self, id: usize) -> f32 { - // just for note, this is SUPER bad for performance... which is why we - // only calculate this when it expires in the cache (every day) - if let Some(cached) = self.0.1.get(format!("atto.user.gpa:{}", id)).await { - if let Ok(c) = cached.parse() { - return c; - } - } - - // ... - let conn = match self.0.connect().await { - Ok(c) => c, - Err(_) => return 0.0, - }; - - let res = query_rows!( - &conn, - &format!("SELECT * FROM posts WHERE owner = $1 ORDER BY created DESC LIMIT 48"), - &[&(id as i64)], - |x| { Self::get_post_from_row(x) } - ); - - if res.is_err() { - return 0.0; - } - - // ... - let mut real_posts_count: usize = 0; // posts which can be scored - let mut good_posts: usize = 0; - // let mut bad_posts: usize = 0; - - let posts = res.unwrap(); - - for post in posts { - if post.likes == 0 && post.dislikes == 0 { - // post has no likes or dislikes... doesn't count - if good_posts > 8 { - good_posts -= 1; // we're going to say this is a bad post because it isn't liked enough - } - - continue; - } - - real_posts_count += 1; - - // likes percentage / total likes - let score: f32 = (post.likes as f32 - post.dislikes as f32) - / (post.likes as f32 + post.dislikes as f32); - - if score.is_sign_negative() { - // bad_posts += 1; - continue; - } - - if score > 0.6 { - good_posts += 1; - } - // } else { - // bad_posts += 1; - // } - } - - let gpa = (good_posts as f32 / real_posts_count as f32) * 4.0; - let gpa_rounded = format!("{gpa:.2}").parse::().unwrap(); - - let mut redis_con = self.0.1.get_con().await; - - // expires in one day - if redis_con - .set_ex::( - format!("atto.user.gpa:{}", id), - gpa_rounded.to_string(), - 86400, - ) - .is_err() - { - return 0.0; - }; - - // ... - gpa_rounded - } - /// Get all replies from the given user (from most recent). /// /// # Arguments @@ -1924,6 +1833,8 @@ impl DataManager { } else { return Err(Error::GeneralNotFound("topic".to_string())); } + } else if data.topic != 0 { + return Err(Error::DoesNotSupportField("Community".to_string())); } // ... diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index ef19586..d730e13 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -294,9 +294,6 @@ pub struct UserSettings { /// If extra post tabs are hidden (replies, media). #[serde(default)] pub hide_extra_post_tabs: bool, - /// If the GPA experiment is disabled. - #[serde(default)] - pub disable_gpa_fun: bool, /// A list of strings the user has muted. #[serde(default)] pub muted: Vec, From 0a3ce3e9fe78849ec90037d9adcde064761b5771 Mon Sep 17 00:00:00 2001 From: trisua Date: Wed, 6 Aug 2025 23:35:13 -0400 Subject: [PATCH 12/38] add: user coins and transactions --- crates/core/src/database/auth.rs | 5 +- .../src/database/drivers/sql/create_users.sql | 3 +- .../drivers/sql/version_migrations.sql | 4 + crates/core/src/database/economy.rs | 91 +++++++++++++++++++ crates/core/src/database/mod.rs | 1 + crates/core/src/model/auth.rs | 4 + crates/core/src/model/economy.rs | 36 ++++++++ crates/core/src/model/mod.rs | 1 + 8 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 crates/core/src/database/economy.rs create mode 100644 crates/core/src/model/economy.rs diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 05698ff..09fe643 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -128,6 +128,7 @@ impl DataManager { channel_mutes: serde_json::from_str(&get!(x->28(String)).to_string()).unwrap(), is_deactivated: get!(x->29(i32)) as i8 == 1, ban_expire: get!(x->30(i64)) as usize, + coins: get!(x->31(i32)), } } @@ -284,7 +285,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, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31)", + "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, $27, $28, $29, $30, $31, $32)", params![ &(data.id as i64), &(data.created as i64), @@ -317,6 +318,7 @@ impl DataManager { &serde_json::to_string(&data.channel_mutes).unwrap(), &if data.is_deactivated { 1_i32 } else { 0_i32 }, &(data.ban_expire as i64), + &(data.coins as i32) ] ); @@ -1058,6 +1060,7 @@ impl DataManager { auto_method!(update_user_ban_reason(&str)@get_user_by_id -> "UPDATE users SET ban_reason = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); auto_method!(update_user_channel_mutes(Vec)@get_user_by_id -> "UPDATE users SET channel_mutes = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(update_user_ban_expire(i64)@get_user_by_id -> "UPDATE users SET ban_expire = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); + auto_method!(update_user_coins(i32)@get_user_by_id -> "UPDATE users SET coins = $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); diff --git a/crates/core/src/database/drivers/sql/create_users.sql b/crates/core/src/database/drivers/sql/create_users.sql index e68a27c..4cb9b03 100644 --- a/crates/core/src/database/drivers/sql/create_users.sql +++ b/crates/core/src/database/drivers/sql/create_users.sql @@ -29,5 +29,6 @@ CREATE TABLE IF NOT EXISTS users ( ban_reason TEXT NOT NULL, channel_mutes TEXT NOT NULL, is_deactivated INT NOT NULL, - ban_expire BIGINT NOT NULL + ban_expire BIGINT NOT NULL, + coins INT NOT NULL ) diff --git a/crates/core/src/database/drivers/sql/version_migrations.sql b/crates/core/src/database/drivers/sql/version_migrations.sql index 5f3148c..47bcb04 100644 --- a/crates/core/src/database/drivers/sql/version_migrations.sql +++ b/crates/core/src/database/drivers/sql/version_migrations.sql @@ -33,3 +33,7 @@ ADD COLUMN IF NOT EXISTS ban_expire BIGINT DEFAULT 0; -- remove users seller_data ALTER TABLE users DROP COLUMN IF EXISTS seller_data; + +-- users coins +ALTER TABLE users +ADD COLUMN IF NOT EXISTS coins INT DEFAULT 0; diff --git a/crates/core/src/database/economy.rs b/crates/core/src/database/economy.rs new file mode 100644 index 0000000..f652e22 --- /dev/null +++ b/crates/core/src/database/economy.rs @@ -0,0 +1,91 @@ +use crate::model::economy::CoinTransfer; +use crate::model::{Error, Result}; +use crate::{auto_method, DataManager}; +use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow}; + +impl DataManager { + /// Get a [`Letter`] from an SQL row. + pub(crate) fn get_transfer_from_row(x: &PostgresRow) -> CoinTransfer { + CoinTransfer { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + sender: get!(x->2(i64)) as usize, + receiver: get!(x->3(i64)) as usize, + amount: get!(x->4(i32)), + } + } + + auto_method!(get_transfer_by_id(usize as i64)@get_transfer_from_row -> "SELECT * FROM transfers WHERE id = $1" --name="transfer" --returns=CoinTransfer --cache-key-tmpl="atto.transfer:{}"); + + /// Get all transfers by user. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch transfers for + /// * `batch` - the limit of items in each page + /// * `page` - the page number + pub async fn get_transfers_by_user( + &self, + id: usize, + batch: usize, + page: usize, + ) -> Result> { + 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 transfers WHERE sender = $1 OR receiver = $1 ORDER BY created DESC LIMIT $2 OFFSET $3", + &[&(id as i64), &(batch as i64), &((page * batch) as i64)], + |x| { Self::get_transfer_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("transfer".to_string())); + } + + Ok(res.unwrap()) + } + + /// Create a new transfer in the database. + /// + /// # Arguments + /// * `data` - a mock [`Letter`] object to insert + pub async fn create_transfer(&self, data: CoinTransfer) -> Result { + // check values + let mut sender = self.get_user_by_id(data.sender).await?; + let mut receiver = self.get_user_by_id(data.receiver).await?; + let (sender_bankrupt, receiver_bankrupt) = data.apply(&mut sender, &mut receiver); + + if sender_bankrupt | receiver_bankrupt { + return Err(Error::MiscError( + "One party of this transfer cannot afford this".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 transfers VALUES ($1, $2, $3, $4, $5)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.sender as i64), + &(data.receiver as i64), + &data.amount + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } +} diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index 5650d7d..c49a71f 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -9,6 +9,7 @@ pub mod connections; mod domains; mod drafts; mod drivers; +mod economy; mod emojis; mod invite_codes; mod ipbans; diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index d730e13..680d059 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -92,6 +92,9 @@ pub struct User { /// The time at which the user's ban will automatically expire. #[serde(default)] pub ban_expire: usize, + /// The number of coins the user has. + #[serde(default)] + pub coins: i32, } pub type UserConnections = @@ -397,6 +400,7 @@ impl User { channel_mutes: Vec::new(), is_deactivated: false, ban_expire: 0, + coins: 0, } } diff --git a/crates/core/src/model/economy.rs b/crates/core/src/model/economy.rs new file mode 100644 index 0000000..95d8e01 --- /dev/null +++ b/crates/core/src/model/economy.rs @@ -0,0 +1,36 @@ +use serde::{Serialize, Deserialize}; +use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; + +use super::auth::User; + +#[derive(Serialize, Deserialize)] +pub struct CoinTransfer { + pub id: usize, + pub created: usize, + pub sender: usize, + pub receiver: usize, + pub amount: i32, +} + +impl CoinTransfer { + /// Create a new [`CoinTransfer`]. + pub fn new(sender: usize, receiver: usize, amount: i32) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created: unix_epoch_timestamp(), + sender, + receiver, + amount, + } + } + + /// Apply the effects of this transaction onto the sender and receiver balances. + /// + /// # Returns + /// `(sender bankrupt, receiver bankrupt)` + pub fn apply(&self, sender: &mut User, receiver: &mut User) -> (bool, bool) { + sender.coins -= self.amount; + receiver.coins += self.amount; + (sender.coins < 0, receiver.coins < 0) + } +} diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index b78dd37..9b46412 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -5,6 +5,7 @@ pub mod carp; pub mod channels; pub mod communities; pub mod communities_permissions; +pub mod economy; pub mod journals; pub mod littleweb; pub mod mail; From 3c4ce1fae50f64e2a40f55a88a190c9b008541cc Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 7 Aug 2025 00:22:37 -0400 Subject: [PATCH 13/38] add: economy api --- crates/app/src/public/css/root.css | 3 ++- crates/app/src/public/html/components.lisp | 21 +++++++--------- crates/app/src/routes/api/v1/economy.rs | 28 ++++++++++++++++++++++ crates/app/src/routes/api/v1/mod.rs | 9 +++++++ crates/core/src/database/economy.rs | 4 ++-- crates/core/src/model/oauth.rs | 2 ++ 6 files changed, 51 insertions(+), 16 deletions(-) create mode 100644 crates/app/src/routes/api/v1/economy.rs diff --git a/crates/app/src/public/css/root.css b/crates/app/src/public/css/root.css index 08437da..686cd49 100644 --- a/crates/app/src/public/css/root.css +++ b/crates/app/src/public/css/root.css @@ -250,7 +250,8 @@ svg.icon { height: 1em; } -svg.icon.filled { +svg.icon.filled, +.filled svg.icon { fill: currentColor; } diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 0a03300..900116a 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -6,7 +6,6 @@ ("class" "avatar shadow") ("loading" "lazy") ("style" "--size: {{ size }}")) - (text "{%- endmacro %} {% macro community_avatar(id, community=false, size=\"24px\") -%} {% if community -%}") (img ("src" "/api/v1/communities/{{ id }}/avatar") @@ -14,7 +13,6 @@ ("class" "avatar shadow") ("loading" "lazy") ("style" "--size: {{ size }}")) - (text "{% else %}") (img ("src" "/api/v1/communities/{{ id }}/avatar") @@ -22,7 +20,6 @@ ("class" "avatar shadow") ("loading" "lazy") ("style" "--size: {{ size }}")) - (text "{%- endif %} {%- endmacro %} {% macro banner(username, border_radius=\"var(--radius)\") -%}") (img ("title" "{{ username }}'s banner") @@ -31,21 +28,18 @@ ("class" "banner shadow w_full") ("loading" "lazy") ("style" "border-radius: {{ border_radius }};")) - (text "{%- endmacro %} {% macro community_banner(id, community=false) -%} {% if community %}") (img ("src" "/api/v1/communities/{{ id }}/banner") ("alt" "{{ community.title }}'s banner") ("class" "banner shadow") ("loading" "lazy")) - (text "{% else %}") (img ("src" "/api/v1/communities/{{ id }}/banner") ("alt" "{{ id }}'s banner") ("class" "banner shadow") ("loading" "lazy")) - (text "{%- endif %} {%- endmacro %} {% macro community_listing_card(community) -%}") (a ("class" "card secondary w_full flex items_center gap_4") @@ -69,12 +63,10 @@ (b (text "{{ community.member_count }} ")) (text "members")))) - (text "{%- endmacro %} {% macro username(user) -%}") (div ("style" "display: contents") (text "{% if user.settings.display_name -%} {{ user.settings.display_name }} {% else %} {{ user.username }} {%- endif %}")) - (text "{%- endmacro %} {% macro likes(id, asset_type, likes=0, dislikes=0, secondary=false, disable_dislikes=false) -%}") (button ("title" "Like") @@ -85,7 +77,6 @@ (span (text "{{ likes }}")) (text "{%- endif %}")) - (text "{% if not user or not user.settings.hide_dislikes and not disable_dislikes -%}") (button ("title" "Dislike") @@ -96,15 +87,21 @@ (span (text "{{ dislikes }}")) (text "{%- endif %}")) - (text "{%- endif %} {%- endmacro %} {% macro full_username(user) -%} {% if user and user.username -%}") (div ("class" "flex items_center") (a ("href" "/@{{ user.username }}") - ("class" "flush") + ("class" "flush flex gap_1") ("style" "font-weight: 600") ("target" "_top") + (text "{% if user.settings.private_profile -%}") + (span + ("title" "Private") + ("class" "flex items_center") + (icon (text "lock"))) + (text "{%- endif %}") + (text "{% if user.permissions|has_banned -%}") (del ("class" "fade") (text "{{ self::username(user=user) }}")) (text "{% else %}") @@ -134,7 +131,6 @@ ("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 %}") - (text "{% macro post_info(post, community) -%}") ; info about the post: edited, date, etc. (text "{% if post.context.edited != 0 -%}") @@ -200,7 +196,6 @@ (text "{{ icon \"trash-2\" }}")) (text "{%- endif %}") (text "{%- endmacro %}") - (text "{% macro post_buttons_box(post, community, owner, can_manage_post, show_comments=true) -%}") (div ("class" "flex justify_between items_center gap_2 w_full") diff --git a/crates/app/src/routes/api/v1/economy.rs b/crates/app/src/routes/api/v1/economy.rs new file mode 100644 index 0000000..fa7bf4a --- /dev/null +++ b/crates/app/src/routes/api/v1/economy.rs @@ -0,0 +1,28 @@ +use crate::{get_user_from_token, State, cookie::CookieJar}; +use axum::{response::IntoResponse, Extension, Json}; +use tetratto_core::model::{economy::CoinTransfer, oauth, ApiReturn, Error}; +use super::CreateCoinTransfer; + +pub async fn create_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserSendCoins) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .create_transfer(CoinTransfer::new(user.id, req.receiver, req.amount)) + .await + { + Ok(s) => Json(ApiReturn { + ok: true, + message: "Stack created".to_string(), + payload: s.id.to_string(), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 5e5ddb4..2dc9d6c 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -4,6 +4,7 @@ pub mod auth; pub mod channels; pub mod communities; pub mod domains; +pub mod economy; pub mod journals; pub mod letters; pub mod notes; @@ -705,6 +706,8 @@ pub fn routes() -> Router { .route("/letters/{id}/read", post(letters::add_read_request)) .route("/letters/sent", get(letters::list_sent_request)) .route("/letters/received", get(letters::list_received_request)) + // economy + .route("/transfers", post(economy::create_request)) } pub fn lw_routes() -> Router { @@ -1212,3 +1215,9 @@ pub struct CreateLetter { pub content: String, pub replying_to: String, } + +#[derive(Deserialize)] +pub struct CreateCoinTransfer { + pub receiver: usize, + pub amount: i32, +} diff --git a/crates/core/src/database/economy.rs b/crates/core/src/database/economy.rs index f652e22..f8efce4 100644 --- a/crates/core/src/database/economy.rs +++ b/crates/core/src/database/economy.rs @@ -4,7 +4,7 @@ use crate::{auto_method, DataManager}; use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow}; impl DataManager { - /// Get a [`Letter`] from an SQL row. + /// Get a [`CoinTransfer`] from an SQL row. pub(crate) fn get_transfer_from_row(x: &PostgresRow) -> CoinTransfer { CoinTransfer { id: get!(x->0(i64)) as usize, @@ -51,7 +51,7 @@ impl DataManager { /// Create a new transfer in the database. /// /// # Arguments - /// * `data` - a mock [`Letter`] object to insert + /// * `data` - a mock [`CoinTransfer`] object to insert pub async fn create_transfer(&self, data: CoinTransfer) -> Result { // check values let mut sender = self.get_user_by_id(data.sender).await?; diff --git a/crates/core/src/model/oauth.rs b/crates/core/src/model/oauth.rs index 62fd0c1..357e4ea 100644 --- a/crates/core/src/model/oauth.rs +++ b/crates/core/src/model/oauth.rs @@ -106,6 +106,8 @@ pub enum AppScope { UserCreateProducts, /// Create letters on behalf of the user. UserCreateLetters, + /// Send coins on behalf of the user. + UserSendCoins, /// Delete posts owned by the user. UserDeletePosts, /// Delete messages owned by the user. From df5eaf24f74b7931d9236a5264d4ac6360d200b1 Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 7 Aug 2025 13:52:48 -0400 Subject: [PATCH 14/38] add: products api --- crates/app/src/assets.rs | 4 + crates/app/src/langs/en-US.toml | 3 + crates/app/src/public/css/style.css | 10 + .../app/src/public/html/economy/wallet.lisp | 65 +++++ crates/app/src/routes/api/v1/letters.rs | 7 + crates/app/src/routes/api/v1/mod.rs | 65 ++++- crates/app/src/routes/api/v1/products.rs | 225 +++++++++++++++++ .../api/v1/{economy.rs => transfers.rs} | 19 +- crates/app/src/routes/pages/economy.rs | 45 ++++ crates/app/src/routes/pages/mod.rs | 3 + crates/core/src/config.rs | 6 + crates/core/src/database/common.rs | 2 + crates/core/src/database/drivers/common.rs | 2 + .../database/drivers/sql/create_products.sql | 11 + .../database/drivers/sql/create_requests.sql | 1 + .../database/drivers/sql/create_transfers.sql | 9 + .../drivers/sql/version_migrations.sql | 4 + crates/core/src/database/economy.rs | 91 ------- crates/core/src/database/memberships.rs | 1 + crates/core/src/database/mod.rs | 3 +- crates/core/src/database/products.rs | 228 ++++++++++++++++++ crates/core/src/database/questions.rs | 1 + crates/core/src/database/requests.rs | 4 +- crates/core/src/database/transfers.rs | 175 ++++++++++++++ crates/core/src/database/userfollows.rs | 1 + crates/core/src/model/economy.rs | 60 ++++- crates/core/src/model/oauth.rs | 12 +- crates/core/src/model/requests.rs | 74 +++++- example/tetratto.toml | 1 + 29 files changed, 1022 insertions(+), 110 deletions(-) create mode 100644 crates/app/src/public/html/economy/wallet.lisp create mode 100644 crates/app/src/routes/api/v1/products.rs rename crates/app/src/routes/api/v1/{economy.rs => transfers.rs} (55%) create mode 100644 crates/app/src/routes/pages/economy.rs create mode 100644 crates/core/src/database/drivers/sql/create_products.sql create mode 100644 crates/core/src/database/drivers/sql/create_transfers.sql delete mode 100644 crates/core/src/database/economy.rs create mode 100644 crates/core/src/database/products.rs create mode 100644 crates/core/src/database/transfers.rs diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index c8db3be..d39bf2e 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -147,6 +147,8 @@ pub const MAIL_SENT: &str = include_str!("./public/html/mail/sent.lisp"); pub const MAIL_COMPOSE: &str = include_str!("./public/html/mail/compose.lisp"); pub const MAIL_LETTER: &str = include_str!("./public/html/mail/letter.lisp"); +pub const ECONOMY_WALLET: &str = include_str!("./public/html/economy/wallet.lisp"); + // langs pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml"); @@ -379,6 +381,8 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"mail/compose.html"(crate::assets::MAIL_COMPOSE) --config=config --lisp plugins); write_template!(html_path->"mail/letter.html"(crate::assets::MAIL_LETTER) --config=config --lisp plugins); + write_template!(html_path->"economy/wallet.html"(crate::assets::ECONOMY_WALLET) -d "economy" --config=config --lisp plugins); + html_path } diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 523d7f7..0c778cd 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -20,6 +20,7 @@ version = "1.0.0" "general:link.achievements" = "Achievements" "general:link.little_web" = "Little web" "general:link.mail" = "Mail" +"general:link.wallet" = "Wallet" "general:action.save" = "Save" "general:action.delete" = "Delete" "general:action.purge" = "Purge" @@ -324,3 +325,5 @@ version = "1.0.0" "mail:label.content" = "Content" "mail:action.send" = "Send" "mail:action.send_mail" = "Send mail" + +"economy:label.recent_transfers" = "Recent transfers" diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index 1333485..1e9afe0 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -272,6 +272,16 @@ table ol { } } +.card.button { + justify-content: flex-start; + align-items: flex-start; + flex-direction: column; + gap: var(--pad-2); + width: 100%; + height: max-content; + padding: var(--pad-4); +} + /* supporter card */ @property --border-angle { syntax: ""; diff --git a/crates/app/src/public/html/economy/wallet.lisp b/crates/app/src/public/html/economy/wallet.lisp new file mode 100644 index 0000000..e390502 --- /dev/null +++ b/crates/app/src/public/html/economy/wallet.lisp @@ -0,0 +1,65 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "Wallet - {{ config.name }}")) +(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"wallet\") }}") +(main + ("class" "flex flex_col gap_2") + (div + ("class" "card_nest") + (div + ("class" "card small flex items_center justify_between gap_2") + (span + ("class" "flex items_center gap_2") + (icon (text "piggy-bank")) + (span (str (text "general:link.wallet"))))) + (div + ("class" "card lowered flex flex_col gap_4") + (a + ("class" "card button raised") + ("href" "/wallet/buy") + (b (text "Coin balance")) + (h3 + ("class" "flex gap_2 items_center") + ("style" "height: 24px") + (icon (text "badge-cent")) + (text "{{ user.coins }}"))))) + (div + ("class" "card_nest") + (div + ("class" "card small flex items_center justify_between gap_2") + (span + ("class" "flex items_center gap_2") + (icon (text "clock")) + (span (str (text "economy:label.recent_transfers"))))) + (div + ("class" "card lowered flex flex_col gap_4") + (div + ("class" "w_full") + ("style" "overflow: auto") + (table + ("class" "w_full") + (thead + (th (text "Created")) + (th (text "Sender")) + (th (text "Receiver")) + (th (text "Amount")) + (th (text "Product"))) + (tbody + (text "{% for transfer in list -%}") + (tr + (td (span ("class" "date short") (text "{{ transfer[1] }}"))) + (td ("class" "w_content") (text "{{ components::full_username(user=transfer[3]) }}")) + (td ("class" "w_content") (text "{{ components::full_username(user=transfer[4]) }}")) + (td + (text "{{ transfer[2] }}") + (text "{% if transfer[6] -%}") + (span ("title" "Pending") (icon (text "clock"))) + (text "{%- endif %}")) + (td + (text "{% if transfer[5] -%}") + (a + ("href" "/product/{{ transfer[5].id }}") + (icon (text "external-link"))) + (text "{%- endif %}"))) + (text "{%- endfor %}"))))))) +(text "{% endblock %}") diff --git a/crates/app/src/routes/api/v1/letters.rs b/crates/app/src/routes/api/v1/letters.rs index 9ab3d36..0d00749 100644 --- a/crates/app/src/routes/api/v1/letters.rs +++ b/crates/app/src/routes/api/v1/letters.rs @@ -187,6 +187,13 @@ pub async fn create_request( } } + // check if we're fulfilling a coin transfer + if props.transfer_id != 0 { + if let Err(e) = data.apply_transfer(props.transfer_id).await { + return Json(e.into()); + } + } + // ... Json(ApiReturn { ok: true, diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 2dc9d6c..9ce76ef 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -4,16 +4,17 @@ pub mod auth; pub mod channels; pub mod communities; pub mod domains; -pub mod economy; pub mod journals; pub mod letters; pub mod notes; pub mod notifications; +pub mod products; pub mod reactions; pub mod reports; pub mod requests; pub mod services; pub mod stacks; +pub mod transfers; pub mod uploads; pub mod util; @@ -30,6 +31,7 @@ use tetratto_core::model::{ PollOption, PostContext, }, communities_permissions::CommunityPermission, + economy::ProductFulfillmentMethod, journals::JournalPrivacyPermission, littleweb::{DomainData, DomainTld, ServiceFsEntry}, oauth::AppScope, @@ -706,8 +708,27 @@ pub fn routes() -> Router { .route("/letters/{id}/read", post(letters::add_read_request)) .route("/letters/sent", get(letters::list_sent_request)) .route("/letters/received", get(letters::list_received_request)) - // economy - .route("/transfers", post(economy::create_request)) + // transfers + .route("/transfers", post(transfers::create_request)) + // products + .route("/products", post(products::create_request)) + .route("/products/{id}", delete(products::delete_request)) + .route("/products/{id}/buy", post(products::buy_request)) + .route("/products/{id}/title", post(products::update_title_request)) + .route( + "/products/{id}/description", + post(products::update_description_request), + ) + .route( + "/products/{id}/on_sale", + post(products::update_on_sale_request), + ) + .route("/products/{id}/price", post(products::update_price_request)) + .route( + "/products/{id}/method", + post(products::update_method_request), + ) + .route("/products/{id}/stock", post(products::update_stock_request)) } pub fn lw_routes() -> Router { @@ -1214,6 +1235,8 @@ pub struct CreateLetter { pub subject: String, pub content: String, pub replying_to: String, + #[serde(default)] + pub transfer_id: usize, } #[derive(Deserialize)] @@ -1221,3 +1244,39 @@ pub struct CreateCoinTransfer { pub receiver: usize, pub amount: i32, } + +#[derive(Deserialize)] +pub struct CreateProduct { + pub title: String, + pub description: String, +} + +#[derive(Deserialize)] +pub struct UpdateProductTitle { + pub title: String, +} + +#[derive(Deserialize)] +pub struct UpdateProductDescription { + pub description: String, +} + +#[derive(Deserialize)] +pub struct UpdateProductOnSale { + pub on_sale: bool, +} + +#[derive(Deserialize)] +pub struct UpdateProductPrice { + pub price: i32, +} + +#[derive(Deserialize)] +pub struct UpdateProductMethod { + pub method: ProductFulfillmentMethod, +} + +#[derive(Deserialize)] +pub struct UpdateProductStock { + pub stock: i32, +} diff --git a/crates/app/src/routes/api/v1/products.rs b/crates/app/src/routes/api/v1/products.rs new file mode 100644 index 0000000..d377de8 --- /dev/null +++ b/crates/app/src/routes/api/v1/products.rs @@ -0,0 +1,225 @@ +use crate::{get_user_from_token, State, cookie::CookieJar}; +use axum::{extract::Path, response::IntoResponse, Extension, Json}; +use tetratto_core::model::{economy::Product, oauth, ApiReturn, Error}; +use super::{ + CreateProduct, UpdateProductDescription, UpdateProductMethod, UpdateProductOnSale, + UpdateProductPrice, UpdateProductStock, UpdateProductTitle, +}; + +pub async fn create_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateProducts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .create_product(Product::new(user.id, req.title, req.description)) + .await + { + Ok(s) => Json(ApiReturn { + ok: true, + message: "Product created".to_string(), + payload: s.id.to_string(), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateProducts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_product(id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Product deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_title_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(mut req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + req.title = req.title.trim().to_string(); + if req.title.len() < 2 { + return Json(Error::DataTooShort("title".to_string()).into()); + } else if req.title.len() > 128 { + return Json(Error::DataTooLong("title".to_string()).into()); + } + + match data.update_product_title(id, &user, &req.title).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Product updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_description_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(mut req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + req.description = req.description.trim().to_string(); + if req.description.len() < 2 { + return Json(Error::DataTooShort("description".to_string()).into()); + } else if req.description.len() > 1024 { + return Json(Error::DataTooLong("description".to_string()).into()); + } + + match data + .update_product_description(id, &user, &req.description) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Product updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_on_sale_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .update_product_on_sale(id, &user, if req.on_sale { 1 } else { 0 }) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Product updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_price_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_product_price(id, &user, req.price).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Product updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_method_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_product_method(id, &user, req.method).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Product updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_stock_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_product_stock(id, &user, req.stock).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Product updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn buy_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserSendCoins) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.purchase_product(id, &mut user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Product purchased".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/economy.rs b/crates/app/src/routes/api/v1/transfers.rs similarity index 55% rename from crates/app/src/routes/api/v1/economy.rs rename to crates/app/src/routes/api/v1/transfers.rs index fa7bf4a..5de916f 100644 --- a/crates/app/src/routes/api/v1/economy.rs +++ b/crates/app/src/routes/api/v1/transfers.rs @@ -1,6 +1,9 @@ use crate::{get_user_from_token, State, cookie::CookieJar}; use axum::{response::IntoResponse, Extension, Json}; -use tetratto_core::model::{economy::CoinTransfer, oauth, ApiReturn, Error}; +use tetratto_core::model::{ + economy::{CoinTransfer, CoinTransferMethod}, + oauth, ApiReturn, Error, +}; use super::CreateCoinTransfer; pub async fn create_request( @@ -15,13 +18,21 @@ pub async fn create_request( }; match data - .create_transfer(CoinTransfer::new(user.id, req.receiver, req.amount)) + .create_transfer( + &mut CoinTransfer::new( + user.id, + req.receiver, + req.amount, + CoinTransferMethod::Transfer, // this endpoint is ONLY for regular transfers; products export a buy endpoint for the other method + ), + true, + ) .await { Ok(s) => Json(ApiReturn { ok: true, - message: "Stack created".to_string(), - payload: s.id.to_string(), + message: "Transfer created".to_string(), + payload: s.to_string(), }), Err(e) => Json(e.into()), } diff --git a/crates/app/src/routes/pages/economy.rs b/crates/app/src/routes/pages/economy.rs new file mode 100644 index 0000000..ea6f6f6 --- /dev/null +++ b/crates/app/src/routes/pages/economy.rs @@ -0,0 +1,45 @@ +use axum::{ + extract::{Query, Path}, + response::{Html, IntoResponse}, + Extension, +}; +use crate::cookie::CookieJar; +use tetratto_core::model::Error; +use crate::{assets::initial_context, get_lang, get_user_from_token, State}; +use super::{render_error, PaginatedQuery}; + +/// `/wallet` +pub async fn wallet_request( + jar: CookieJar, + Extension(data): Extension, + Query(props): Query, +) -> 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 list = match data.0.get_transfers_by_user(user.id, 12, props.page).await { + Ok(x) => match data.0.fill_transfers(x).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + + context.insert("list", &list); + context.insert("page", &props.page); + + // return + Ok(Html( + data.1.render("economy/wallet.html", &context).unwrap(), + )) +} diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 93cfc9a..3cdae39 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -2,6 +2,7 @@ pub mod auth; pub mod chats; pub mod communities; pub mod developer; +pub mod economy; pub mod forge; pub mod journals; pub mod littleweb; @@ -159,6 +160,8 @@ pub fn routes() -> Router { .route("/mail/sent", get(mail::sent_request)) .route("/mail/compose", get(mail::compose_request)) .route("/mail/letter/{id}", get(mail::letter_request)) + // economy + .route("/wallet", get(economy::wallet_request)) } pub fn lw_routes() -> Router { diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 4924714..fb04c7b 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -315,6 +315,9 @@ pub struct Config { /// to post in by default. This should be some sort of "general" topic. #[serde(default)] pub town_square_forum_topic: usize, + /// The ID of the "system" user which will send system mails to users. + #[serde(default)] + pub system_user: usize, #[serde(default)] pub connections: ConnectionsConfig, /// The path to the HTML footer file. The contents of this file are embedded @@ -396,6 +399,8 @@ fn default_banned_usernames() -> Vec { "services".to_string(), "domains".to_string(), "mail".to_string(), + "product".to_string(), + "wallet".to_string(), ] } @@ -439,6 +444,7 @@ impl Default for Config { town_square: 0, town_square_forum: 0, town_square_forum_topic: 0, + system_user: 0, connections: default_connections(), html_footer_path: String::new(), stripe: None, diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 71e792d..4d3fe55 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -44,6 +44,8 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_SERVICES).unwrap(); execute!(&conn, common::CREATE_TABLE_APP_DATA).unwrap(); execute!(&conn, common::CREATE_TABLE_LETTERS).unwrap(); + execute!(&conn, common::CREATE_TABLE_TRANSFERS).unwrap(); + execute!(&conn, common::CREATE_TABLE_PRODUCTS).unwrap(); for x in common::VERSION_MIGRATIONS.split(";") { execute!(&conn, x).unwrap(); diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index 4881179..7c1b2e5 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -32,3 +32,5 @@ pub const CREATE_TABLE_DOMAINS: &str = include_str!("./sql/create_domains.sql"); pub const CREATE_TABLE_SERVICES: &str = include_str!("./sql/create_services.sql"); pub const CREATE_TABLE_APP_DATA: &str = include_str!("./sql/create_app_data.sql"); pub const CREATE_TABLE_LETTERS: &str = include_str!("./sql/create_letters.sql"); +pub const CREATE_TABLE_TRANSFERS: &str = include_str!("./sql/create_transfers.sql"); +pub const CREATE_TABLE_PRODUCTS: &str = include_str!("./sql/create_products.sql"); diff --git a/crates/core/src/database/drivers/sql/create_products.sql b/crates/core/src/database/drivers/sql/create_products.sql new file mode 100644 index 0000000..be32f3c --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_products.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS products ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + owner BIGINT NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL, + method TEXT NOT NULL, + on_sale INT NOT NULL, + price INT NOT NULL, + stock INT NOT NULL +) diff --git a/crates/core/src/database/drivers/sql/create_requests.sql b/crates/core/src/database/drivers/sql/create_requests.sql index ef5d83e..98bbdcb 100644 --- a/crates/core/src/database/drivers/sql/create_requests.sql +++ b/crates/core/src/database/drivers/sql/create_requests.sql @@ -4,5 +4,6 @@ CREATE TABLE IF NOT EXISTS requests ( owner BIGINT NOT NULL, action_type TEXT NOT NULL, linked_asset BIGINT NOT NULL, + data TEXT NOT NULL, PRIMARY KEY (id, owner, linked_asset) ) diff --git a/crates/core/src/database/drivers/sql/create_transfers.sql b/crates/core/src/database/drivers/sql/create_transfers.sql new file mode 100644 index 0000000..d747c78 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_transfers.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS transfers ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + sender BIGINT NOT NULL, + receiver BIGINT NOT NULL, + amount INT NOT NULL, + is_pending INT NOT NULL, + method TEXT NOT NULL +) diff --git a/crates/core/src/database/drivers/sql/version_migrations.sql b/crates/core/src/database/drivers/sql/version_migrations.sql index 47bcb04..eecaba6 100644 --- a/crates/core/src/database/drivers/sql/version_migrations.sql +++ b/crates/core/src/database/drivers/sql/version_migrations.sql @@ -37,3 +37,7 @@ DROP COLUMN IF EXISTS seller_data; -- users coins ALTER TABLE users ADD COLUMN IF NOT EXISTS coins INT DEFAULT 0; + +-- requests data +ALTER TABLE requests +ADD COLUMN IF NOT EXISTS data TEXT DEFAULT '"Null"'; diff --git a/crates/core/src/database/economy.rs b/crates/core/src/database/economy.rs deleted file mode 100644 index f8efce4..0000000 --- a/crates/core/src/database/economy.rs +++ /dev/null @@ -1,91 +0,0 @@ -use crate::model::economy::CoinTransfer; -use crate::model::{Error, Result}; -use crate::{auto_method, DataManager}; -use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow}; - -impl DataManager { - /// Get a [`CoinTransfer`] from an SQL row. - pub(crate) fn get_transfer_from_row(x: &PostgresRow) -> CoinTransfer { - CoinTransfer { - id: get!(x->0(i64)) as usize, - created: get!(x->1(i64)) as usize, - sender: get!(x->2(i64)) as usize, - receiver: get!(x->3(i64)) as usize, - amount: get!(x->4(i32)), - } - } - - auto_method!(get_transfer_by_id(usize as i64)@get_transfer_from_row -> "SELECT * FROM transfers WHERE id = $1" --name="transfer" --returns=CoinTransfer --cache-key-tmpl="atto.transfer:{}"); - - /// Get all transfers by user. - /// - /// # Arguments - /// * `id` - the ID of the user to fetch transfers for - /// * `batch` - the limit of items in each page - /// * `page` - the page number - pub async fn get_transfers_by_user( - &self, - id: usize, - batch: usize, - page: usize, - ) -> Result> { - 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 transfers WHERE sender = $1 OR receiver = $1 ORDER BY created DESC LIMIT $2 OFFSET $3", - &[&(id as i64), &(batch as i64), &((page * batch) as i64)], - |x| { Self::get_transfer_from_row(x) } - ); - - if res.is_err() { - return Err(Error::GeneralNotFound("transfer".to_string())); - } - - Ok(res.unwrap()) - } - - /// Create a new transfer in the database. - /// - /// # Arguments - /// * `data` - a mock [`CoinTransfer`] object to insert - pub async fn create_transfer(&self, data: CoinTransfer) -> Result { - // check values - let mut sender = self.get_user_by_id(data.sender).await?; - let mut receiver = self.get_user_by_id(data.receiver).await?; - let (sender_bankrupt, receiver_bankrupt) = data.apply(&mut sender, &mut receiver); - - if sender_bankrupt | receiver_bankrupt { - return Err(Error::MiscError( - "One party of this transfer cannot afford this".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 transfers VALUES ($1, $2, $3, $4, $5)", - params![ - &(data.id as i64), - &(data.created as i64), - &(data.sender as i64), - &(data.receiver as i64), - &data.amount - ] - ); - - if let Err(e) = res { - return Err(Error::DatabaseError(e.to_string())); - } - - Ok(data) - } -} diff --git a/crates/core/src/database/memberships.rs b/crates/core/src/database/memberships.rs index 610d0a0..e69de8d 100644 --- a/crates/core/src/database/memberships.rs +++ b/crates/core/src/database/memberships.rs @@ -200,6 +200,7 @@ impl DataManager { community.owner, ActionType::CommunityJoin, community.id, + None, )) .await?; diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index c49a71f..c52a107 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -9,7 +9,6 @@ pub mod connections; mod domains; mod drafts; mod drivers; -mod economy; mod emojis; mod invite_codes; mod ipbans; @@ -24,6 +23,7 @@ mod notifications; mod polls; mod pollvotes; mod posts; +mod products; mod questions; mod reactions; mod reports; @@ -31,6 +31,7 @@ mod requests; mod services; mod stackblocks; mod stacks; +mod transfers; mod uploads; mod user_warnings; mod userblocks; diff --git a/crates/core/src/database/products.rs b/crates/core/src/database/products.rs new file mode 100644 index 0000000..82240da --- /dev/null +++ b/crates/core/src/database/products.rs @@ -0,0 +1,228 @@ +use crate::model::{ + auth::User, + economy::{CoinTransfer, CoinTransferMethod, Product, ProductFulfillmentMethod}, + mail::Letter, + permissions::FinePermission, + Error, Result, +}; +use crate::{auto_method, DataManager}; +use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow}; + +impl DataManager { + /// Get a [`Product`] from an SQL row. + pub(crate) fn get_product_from_row(x: &PostgresRow) -> Product { + Product { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + owner: get!(x->2(i64)) as usize, + title: get!(x->3(String)), + description: get!(x->4(String)), + method: serde_json::from_str(&get!(x->5(String))).unwrap(), + on_sale: get!(x->6(i32)) as i8 == 1, + price: get!(x->7(i32)), + stock: get!(x->8(i32)), + } + } + + auto_method!(get_product_by_id(usize as i64)@get_product_from_row -> "SELECT * FROM products WHERE id = $1" --name="product" --returns=Product --cache-key-tmpl="atto.product:{}"); + + /// Get all products by user. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch products for + /// * `batch` - the limit of items in each page + /// * `page` - the page number + pub async fn get_products_by_user( + &self, + id: usize, + batch: usize, + page: usize, + ) -> Result> { + 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 products WHERE owner = $1 ORDER BY created DESC LIMIT $2 OFFSET $3", + &[&(id as i64), &(batch as i64), &((page * batch) as i64)], + |x| { Self::get_product_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("product".to_string())); + } + + Ok(res.unwrap()) + } + + const MAXIMUM_FREE_PRODUCTS: usize = 5; + + /// Create a new product in the database. + /// + /// # Arguments + /// * `data` - a mock [`Product`] object to insert + pub async fn create_product(&self, mut data: Product) -> Result { + data.title = data.title.trim().to_string(); + data.description = data.description.trim().to_string(); + + // check values + if data.title.len() < 2 { + return Err(Error::DataTooShort("title".to_string())); + } else if data.title.len() > 128 { + return Err(Error::DataTooLong("title".to_string())); + } + + if data.description.len() < 2 { + return Err(Error::DataTooShort("description".to_string())); + } else if data.description.len() > 1024 { + return Err(Error::DataTooLong("description".to_string())); + } + + // check number of stacks + let owner = self.get_user_by_id(data.owner).await?; + + if !owner.permissions.check(FinePermission::SUPPORTER) { + let products = self + .get_table_row_count_where("products", &format!("owner = {}", owner.id)) + .await? as usize; + + if products >= Self::MAXIMUM_FREE_PRODUCTS { + return Err(Error::MiscError( + "You already have the maximum number of products you can have".to_string(), + )); + } + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "INSERT INTO products VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.owner as i64), + &data.title, + &data.description, + &serde_json::to_string(&data.method).unwrap(), + &{ if data.on_sale { 1 } else { 0 } }, + &data.price, + &(data.stock as i32) + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + /// Purchase the given product as the given user. + pub async fn purchase_product( + &self, + product: usize, + customer: &mut User, + ) -> Result { + let product = self.get_product_by_id(product).await?; + let mut transfer = CoinTransfer::new( + customer.id, + product.owner, + product.price, + CoinTransferMethod::Purchase(product.id), + ); + + if !product.stock.is_negative() { + // check stock + if product.stock == 0 { + return Err(Error::MiscError("No remaining stock".to_string())); + } else { + self.decr_product_stock(product.id).await?; + } + } + + match product.method { + ProductFulfillmentMethod::AutoMail(message) => { + // we're basically done, transfer coins and send mail + self.create_transfer(&mut transfer, false).await?; + + self.create_letter(Letter::new( + self.0.0.system_user, + vec![customer.id], + format!("Thank you for purchasing \"{}\"", product.title), + format!("The message below was supplied by the product owner, and was automatically sent.\n***\n{message}"), + 0, + )) + .await?; + + Ok(transfer) + } + ProductFulfillmentMethod::ManualMail => { + // mark transfer as pending and create it + self.create_transfer(&mut transfer, false).await?; + + // tell product owner they have a new pending purchase + self.create_letter(Letter::new( + self.0.0.system_user, + vec![product.owner], + "New product purchase pending".to_string(), + format!( + "Somebody has purchased your [product](/product/{}) \"{}\". Per your product's settings, the payment will not be completed until you manually mail them a letter **using the link below**. + +If your product is a purchase of goods or services, please be sure to fulfill this purchase either in the letter or elsewhere. The customer may request support if you fail to do so. + +*** +Fulfill purchase", + product.id, product.title, customer.id, transfer.id + ), + 0, + )) + .await?; + + // return + Ok(transfer) + } + } + } + + pub async fn delete_product(&self, id: usize, user: &User) -> Result<()> { + let product = self.get_product_by_id(id).await?; + + // check user permission + if user.id != product.owner && !user.permissions.check(FinePermission::MANAGE_USERS) { + return Err(Error::NotAllowed); + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!(&conn, "DELETE FROM products WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // ... + self.0.1.remove(format!("atto.product:{}", id)).await; + Ok(()) + } + + auto_method!(update_product_title(&str)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}"); + auto_method!(update_product_description(&str)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET description = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}"); + auto_method!(update_product_price(i32)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET price = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}"); + auto_method!(update_product_on_sale(i32)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET on_sale = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}"); + auto_method!(update_product_method(ProductFulfillmentMethod)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET method = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.product:{}"); + + auto_method!(update_product_stock(i32)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET stock = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}"); + auto_method!(incr_product_stock() -> "UPDATE products SET stock = stock + 1 WHERE id = $1" --cache-key-tmpl="atto.product:{}" --incr); + auto_method!(decr_product_stock()@get_product_by_id -> "UPDATE products SET stock = stock - 1 WHERE id = $1" --cache-key-tmpl="atto.product:{}" --decr=stock); +} diff --git a/crates/core/src/database/questions.rs b/crates/core/src/database/questions.rs index 3703d4f..8372b95 100644 --- a/crates/core/src/database/questions.rs +++ b/crates/core/src/database/questions.rs @@ -506,6 +506,7 @@ impl DataManager { data.receiver, ActionType::Answer, data.id, + None, )) .await?; } diff --git a/crates/core/src/database/requests.rs b/crates/core/src/database/requests.rs index 5a82062..84356ac 100644 --- a/crates/core/src/database/requests.rs +++ b/crates/core/src/database/requests.rs @@ -14,6 +14,7 @@ impl DataManager { owner: get!(x->2(i64)) as usize, action_type: serde_json::from_str(&get!(x->3(String))).unwrap(), linked_asset: get!(x->4(i64)) as usize, + data: serde_json::from_str(&get!(x->5(String))).unwrap(), } } @@ -118,13 +119,14 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO requests VALUES ($1, $2, $3, $4, $5)", + "INSERT INTO requests VALUES ($1, $2, $3, $4, $5, $6)", params![ &(data.id as i64), &(data.created as i64), &(data.owner as i64), &serde_json::to_string(&data.action_type).unwrap().as_str(), &(data.linked_asset as i64), + &serde_json::to_string(&data.data).unwrap().as_str(), ] ); diff --git a/crates/core/src/database/transfers.rs b/crates/core/src/database/transfers.rs new file mode 100644 index 0000000..fb4ed6c --- /dev/null +++ b/crates/core/src/database/transfers.rs @@ -0,0 +1,175 @@ +use std::collections::HashMap; + +use crate::model::auth::User; +use crate::model::economy::{CoinTransferMethod, Product}; +use crate::model::{Error, Result, economy::CoinTransfer}; +use crate::{auto_method, DataManager}; +use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow}; + +impl DataManager { + /// Get a [`CoinTransfer`] from an SQL row. + pub(crate) fn get_transfer_from_row(x: &PostgresRow) -> CoinTransfer { + CoinTransfer { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + sender: get!(x->2(i64)) as usize, + receiver: get!(x->3(i64)) as usize, + amount: get!(x->4(i32)), + is_pending: get!(x->5(i32)) as i8 == 1, + method: serde_json::from_str(&get!(x->6(String))).unwrap(), + } + } + + auto_method!(get_transfer_by_id(usize as i64)@get_transfer_from_row -> "SELECT * FROM transfers WHERE id = $1" --name="transfer" --returns=CoinTransfer --cache-key-tmpl="atto.transfer:{}"); + + /// Fill a list of transfers with their users and product. + pub async fn fill_transfers( + &self, + list: Vec, + ) -> Result, bool)>> { + let mut out = Vec::new(); + let mut seen_users: HashMap = HashMap::new(); + let mut seen_products: HashMap = HashMap::new(); + + for transfer in list { + out.push(( + transfer.id, + transfer.created, + transfer.amount, + if let Some(user) = seen_users.get(&transfer.sender) { + user.to_owned() + } else { + let user = self.get_user_by_id(transfer.sender).await?; + seen_users.insert(user.id, user.clone()); + user + }, + if let Some(user) = seen_users.get(&transfer.receiver) { + user.to_owned() + } else { + let user = self.get_user_by_id(transfer.receiver).await?; + seen_users.insert(user.id, user.clone()); + user + }, + match transfer.method { + CoinTransferMethod::Transfer => None, + CoinTransferMethod::Purchase(id) => { + Some(if let Some(product) = seen_products.get(&id) { + product.to_owned() + } else { + let product = self.get_product_by_id(id).await?; + seen_products.insert(product.id, product.clone()); + product + }) + } + }, + transfer.is_pending, + )); + } + + Ok(out) + } + + /// Get all transfers by user. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch transfers for + /// * `batch` - the limit of items in each page + /// * `page` - the page number + pub async fn get_transfers_by_user( + &self, + id: usize, + batch: usize, + page: usize, + ) -> Result> { + 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 transfers WHERE sender = $1 OR receiver = $1 ORDER BY created DESC LIMIT $2 OFFSET $3", + &[&(id as i64), &(batch as i64), &((page * batch) as i64)], + |x| { Self::get_transfer_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("transfer".to_string())); + } + + Ok(res.unwrap()) + } + + /// Create a new transfer in the database. + /// + /// # Arguments + /// * `data` - a mock [`CoinTransfer`] object to insert + pub async fn create_transfer(&self, data: &mut CoinTransfer, apply: bool) -> Result { + // check values + let mut sender = self.get_user_by_id(data.sender).await?; + let mut receiver = self.get_user_by_id(data.receiver).await?; + let (sender_bankrupt, receiver_bankrupt) = data.apply(&mut sender, &mut receiver); + + if sender_bankrupt | receiver_bankrupt { + return Err(Error::MiscError( + "One party of this transfer cannot afford this".to_string(), + )); + } + + if apply { + self.update_user_coins(sender.id, sender.coins).await?; + self.update_user_coins(receiver.id, receiver.coins).await?; + } else { + // we haven't applied the transfer, so this must be pending + data.is_pending = true; + } + + // ... + 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 transfers VALUES ($1, $2, $3, $4, $5, $6, $7)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.sender as i64), + &(data.receiver as i64), + &data.amount, + &{ if data.is_pending { 1 } else { 0 } }, + &serde_json::to_string(&data.method).unwrap(), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data.id) + } + + /// Apply a pending transfer. + pub async fn apply_transfer(&self, id: usize) -> Result<()> { + let transfer = self.get_transfer_by_id(id).await?; + + let mut sender = self.get_user_by_id(transfer.sender).await?; + let mut receiver = self.get_user_by_id(transfer.receiver).await?; + let (sender_bankrupt, receiver_bankrupt) = transfer.apply(&mut sender, &mut receiver); + + if sender_bankrupt | receiver_bankrupt { + return Err(Error::MiscError( + "One party of this transfer cannot afford this".to_string(), + )); + } + + self.update_user_coins(sender.id, sender.coins).await?; + self.update_user_coins(receiver.id, receiver.coins).await?; + self.update_transfer_is_pending(id, 0).await?; + Ok(()) + } + + auto_method!(update_transfer_is_pending(i32) -> "UPDATE products SET is_pending = $1 WHERE id = $2" --cache-key-tmpl="atto.transfer:{}"); +} diff --git a/crates/core/src/database/userfollows.rs b/crates/core/src/database/userfollows.rs index 4b22835..fa358c0 100644 --- a/crates/core/src/database/userfollows.rs +++ b/crates/core/src/database/userfollows.rs @@ -276,6 +276,7 @@ impl DataManager { data.receiver, ActionType::Follow, data.receiver, + None, )) .await?; diff --git a/crates/core/src/model/economy.rs b/crates/core/src/model/economy.rs index 95d8e01..c7ed372 100644 --- a/crates/core/src/model/economy.rs +++ b/crates/core/src/model/economy.rs @@ -1,8 +1,60 @@ use serde::{Serialize, Deserialize}; use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; - use super::auth::User; +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum ProductFulfillmentMethod { + /// Automatically send a letter to the customer with the specified content. + AutoMail(String), + /// Manually send a letter to the customer with the specified content. + /// + /// This will leave the [`CoinTransfer`] pending until you send this mail. + ManualMail, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct Product { + pub id: usize, + pub created: usize, + pub owner: usize, + pub title: String, + pub description: String, + /// How this product will be delivered. + pub method: ProductFulfillmentMethod, + /// If this product is actually for sale. + pub on_sale: bool, + /// The price of this product. + pub price: i32, + /// The number of times this product can be purchased. + /// + /// A negative stock means the product has unlimited stock. + pub stock: i32, +} + +impl Product { + /// Create a new [`Product`]. + pub fn new(owner: usize, title: String, description: String) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created: unix_epoch_timestamp(), + owner, + title, + description, + method: ProductFulfillmentMethod::ManualMail, + on_sale: false, + price: 0, + stock: 0, + } + } +} + +#[derive(Serialize, Deserialize)] +pub enum CoinTransferMethod { + Transfer, + /// A [`Product`] purchase with the product's ID. + Purchase(usize), +} + #[derive(Serialize, Deserialize)] pub struct CoinTransfer { pub id: usize, @@ -10,17 +62,21 @@ pub struct CoinTransfer { pub sender: usize, pub receiver: usize, pub amount: i32, + pub is_pending: bool, + pub method: CoinTransferMethod, } impl CoinTransfer { /// Create a new [`CoinTransfer`]. - pub fn new(sender: usize, receiver: usize, amount: i32) -> Self { + pub fn new(sender: usize, receiver: usize, amount: i32, method: CoinTransferMethod) -> Self { Self { id: Snowflake::new().to_string().parse::().unwrap(), created: unix_epoch_timestamp(), sender, receiver, amount, + is_pending: false, + method, } } diff --git a/crates/core/src/model/oauth.rs b/crates/core/src/model/oauth.rs index 357e4ea..1ebc28c 100644 --- a/crates/core/src/model/oauth.rs +++ b/crates/core/src/model/oauth.rs @@ -74,10 +74,10 @@ pub enum AppScope { UserReadDomains, /// Read the user's services. UserReadServices, - /// Read the user's products. - UserReadProducts, /// Read the user's letters. UserReadLetters, + /// Read the user's products. + UserReadProducts, /// Create posts as the user. UserCreatePosts, /// Create messages as the user. @@ -102,10 +102,10 @@ pub enum AppScope { UserCreateDomains, /// Create services on behalf of the user. UserCreateServices, - /// Create products on behalf of the user. - UserCreateProducts, /// Create letters on behalf of the user. UserCreateLetters, + /// Create products on behalf of the user. + UserCreateProducts, /// Send coins on behalf of the user. UserSendCoins, /// Delete posts owned by the user. @@ -148,12 +148,12 @@ pub enum AppScope { UserManageDomains, /// Manage the user's services. UserManageServices, - /// Manage the user's products. - UserManageProducts, /// Manage the user's channel mutes. UserManageChannelMutes, /// Manage the user's letters. UserManageLetters, + /// Manage the user's products. + UserManageProducts, /// Edit posts created by the user. UserEditPosts, /// Edit drafts created by the user. diff --git a/crates/core/src/model/requests.rs b/crates/core/src/model/requests.rs index 4ffcb78..1b4f320 100644 --- a/crates/core/src/model/requests.rs +++ b/crates/core/src/model/requests.rs @@ -1,6 +1,51 @@ use serde::{Serialize, Deserialize}; use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; +#[derive(Serialize, Deserialize, PartialEq, Eq)] +pub enum ActionData { + String(String), + Int32(i32), + Usize(usize), + Many(Vec), + Null, +} + +impl ActionData { + pub fn read_string(self) -> String { + match self { + ActionData::String(x) => x, + _ => String::default(), + } + } + + pub fn read_int32(self) -> i32 { + match self { + ActionData::Int32(x) => x, + _ => i32::default(), + } + } + + pub fn read_usize(self) -> usize { + match self { + ActionData::Usize(x) => x, + _ => usize::default(), + } + } + + pub fn read_many(self) -> Vec { + match self { + ActionData::Many(x) => x, + _ => Vec::default(), + } + } +} + +impl Default for ActionData { + fn default() -> Self { + Self::Null + } +} + #[derive(Serialize, Deserialize, PartialEq, Eq)] pub enum ActionType { /// A request to join a community. @@ -15,6 +60,10 @@ pub enum ActionType { /// /// `users` table. Follow, + /// A request for the `owner` user (sender) to send the `linked_asset` user (receiver) coins. + /// + /// Expects a `data` value of [`ActionData::Int32`] representing the coin amount. + Transfer, } #[derive(Serialize, Deserialize)] @@ -26,28 +75,49 @@ pub struct ActionRequest { /// The ID of the asset this request links to. Should exist in the correct /// table for the given [`ActionType`]. pub linked_asset: usize, + /// Optional data attached to the action request. + pub data: ActionData, } impl ActionRequest { /// Create a new [`ActionRequest`]. - pub fn new(owner: usize, action_type: ActionType, linked_asset: usize) -> Self { + pub fn new( + owner: usize, + action_type: ActionType, + linked_asset: usize, + data: Option, + ) -> Self { Self { id: Snowflake::new().to_string().parse::().unwrap(), created: unix_epoch_timestamp(), owner, action_type, linked_asset, + data: match data { + Some(x) => x, + None => ActionData::default(), + }, } } /// Create a new [`ActionRequest`] with the given `id`. - pub fn with_id(id: usize, owner: usize, action_type: ActionType, linked_asset: usize) -> Self { + pub fn with_id( + id: usize, + owner: usize, + action_type: ActionType, + linked_asset: usize, + data: Option, + ) -> Self { Self { id, created: unix_epoch_timestamp(), owner, action_type, linked_asset, + data: match data { + Some(x) => x, + None => ActionData::default(), + }, } } } diff --git a/example/tetratto.toml b/example/tetratto.toml index bc7ff59..a7c45e5 100644 --- a/example/tetratto.toml +++ b/example/tetratto.toml @@ -20,6 +20,7 @@ banned_usernames = [ ] town_square = 166340372315581657 html_footer_path = "public/footer.html" +system_user = 211903918383300608 [security] registration_enabled = true From 8f76578f1b37be669514e7bd42d180794b5c7409 Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 7 Aug 2025 13:54:34 -0400 Subject: [PATCH 15/38] fix: pinned posts panic --- crates/app/src/public/html/profile/posts.lisp | 2 +- crates/app/src/public/html/profile/responses.lisp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/app/src/public/html/profile/posts.lisp b/crates/app/src/public/html/profile/posts.lisp index 7f40dbb..2632319 100644 --- a/crates/app/src/public/html/profile/posts.lisp +++ b/crates/app/src/public/html/profile/posts.lisp @@ -32,8 +32,8 @@ ("class" "card w_full flex flex_col gap_2") ("ui_ident" "io_data_load") ; pinned - (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 "{% if pinned|length > 0 -%}") + (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 %}") (div ("class" "squig")) (text "{%- endif %}") ; ... diff --git a/crates/app/src/public/html/profile/responses.lisp b/crates/app/src/public/html/profile/responses.lisp index 37e2e9e..67ccd80 100644 --- a/crates/app/src/public/html/profile/responses.lisp +++ b/crates/app/src/public/html/profile/responses.lisp @@ -32,8 +32,8 @@ ("class" "card w_full flex flex_col gap_2") ("ui_ident" "io_data_load") ; pinned - (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 "{% if pinned|length > 0 -%}") + (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 %}") (div ("class" "squig")) (text "{%- endif %}") ; ... From fd529d384761d6e569b111115e30edbe5270d460 Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 8 Aug 2025 02:17:06 -0400 Subject: [PATCH 16/38] add: products ui --- Cargo.lock | 4 +- crates/app/Cargo.toml | 2 +- crates/app/src/assets.rs | 8 + crates/app/src/langs/en-US.toml | 16 + crates/app/src/public/css/style.css | 7 + crates/app/src/public/html/components.lisp | 20 ++ crates/app/src/public/html/economy/edit.lisp | 322 ++++++++++++++++++ .../app/src/public/html/economy/product.lisp | 67 ++++ .../app/src/public/html/economy/products.lisp | 91 +++++ .../app/src/public/html/economy/wallet.lisp | 3 +- crates/app/src/public/html/macros.lisp | 17 +- crates/app/src/public/html/mail/compose.lisp | 1 + crates/app/src/public/html/misc/requests.lisp | 82 ++++- crates/app/src/public/html/profile/base.lisp | 42 +++ .../app/src/public/html/profile/settings.lisp | 17 + crates/app/src/public/html/profile/shop.lisp | 17 + crates/app/src/public/js/atto.js | 4 + crates/app/src/routes/api/v1/letters.rs | 25 +- crates/app/src/routes/api/v1/mod.rs | 5 +- crates/app/src/routes/api/v1/products.rs | 9 + crates/app/src/routes/api/v1/transfers.rs | 41 ++- crates/app/src/routes/pages/economy.rs | 106 ++++++ crates/app/src/routes/pages/mod.rs | 4 + crates/app/src/routes/pages/profile.rs | 95 +++++- crates/core/Cargo.toml | 2 +- crates/core/src/config.rs | 1 + crates/core/src/database/letters.rs | 18 + crates/core/src/database/products.rs | 14 +- crates/core/src/database/transfers.rs | 38 ++- crates/core/src/model/auth.rs | 6 + crates/core/src/model/requests.rs | 6 +- 31 files changed, 1041 insertions(+), 49 deletions(-) create mode 100644 crates/app/src/public/html/economy/edit.lisp create mode 100644 crates/app/src/public/html/economy/product.lisp create mode 100644 crates/app/src/public/html/economy/products.lisp create mode 100644 crates/app/src/public/html/profile/shop.lisp diff --git a/Cargo.lock b/Cargo.lock index 75260ea..56b0651 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3318,7 +3318,7 @@ dependencies = [ [[package]] name = "tetratto" -version = "14.0.0" +version = "15.0.0" dependencies = [ "ammonia", "async-stripe", @@ -3350,7 +3350,7 @@ dependencies = [ [[package]] name = "tetratto-core" -version = "14.0.0" +version = "15.0.0" dependencies = [ "async-recursion", "base16ct", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 4fb69e9..bbbe59c 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto" -version = "14.0.0" +version = "15.0.0" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index d39bf2e..29ff4e6 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -69,6 +69,7 @@ pub const PROFILE_REPLIES: &str = include_str!("./public/html/profile/replies.li 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 PROFILE_SHOP: &str = include_str!("./public/html/profile/shop.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"); @@ -148,6 +149,9 @@ pub const MAIL_COMPOSE: &str = include_str!("./public/html/mail/compose.lisp"); pub const MAIL_LETTER: &str = include_str!("./public/html/mail/letter.lisp"); pub const ECONOMY_WALLET: &str = include_str!("./public/html/economy/wallet.lisp"); +pub const ECONOMY_PRODUCTS: &str = include_str!("./public/html/economy/products.lisp"); +pub const ECONOMY_EDIT: &str = include_str!("./public/html/economy/edit.lisp"); +pub const ECONOMY_PRODUCT: &str = include_str!("./public/html/economy/product.lisp"); // langs pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml"); @@ -310,6 +314,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { 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->"profile/shop.html"(crate::assets::PROFILE_SHOP) --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); @@ -382,6 +387,9 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"mail/letter.html"(crate::assets::MAIL_LETTER) --config=config --lisp plugins); write_template!(html_path->"economy/wallet.html"(crate::assets::ECONOMY_WALLET) -d "economy" --config=config --lisp plugins); + write_template!(html_path->"economy/products.html"(crate::assets::ECONOMY_PRODUCTS) --config=config --lisp plugins); + write_template!(html_path->"economy/edit.html"(crate::assets::ECONOMY_EDIT) --config=config --lisp plugins); + write_template!(html_path->"economy/product.html"(crate::assets::ECONOMY_PRODUCT) --config=config --lisp plugins); html_path } diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 0c778cd..73507f4 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -88,6 +88,7 @@ version = "1.0.0" "auth:label.replies" = "Replies" "auth:label.media" = "Media" "auth:label.outbox" = "Outbox" +"auth:label.shop" = "Shop" "auth:label.before_you_view" = "Before you view" "auth:label.private_profile" = "Private profile" "auth:label.private_profile_message" = "This profile is private, meaning you can only view it if they follow you." @@ -227,6 +228,7 @@ version = "1.0.0" "requests:label.user_follow_request" = "User follow request" "requests:action.view_profile" = "View profile" "requests:label.user_follow_request_message" = "Accepting this request will not allow them to see your profile. For that, you must follow them back." +"requests:label.coin_transfer_request" = "Coin transfer request" "chats:label.my_chats" = "My chats" "chats:action.move" = "Move" @@ -327,3 +329,17 @@ version = "1.0.0" "mail:action.send_mail" = "Send mail" "economy:label.recent_transfers" = "Recent transfers" +"economy:action.request" = "Request" +"economy:label.title" = "Title" +"economy:label.description" = "Description" +"economy:label.my_products" = "My products" +"economy:label.my_wallet" = "My wallet" +"economy:label.create_new" = "Create new product" +"economy:label.price" = "Price" +"economy:label.on_sale" = "On sale" +"economy:label.stock" = "Stock" +"economy:label.unlimited" = "Unlimited" +"economy:label.fulfillment_style" = "Fulfillment style" +"economy:label.use_automail" = "Use automail" +"economy:label.automail_message" = "Automail message" +"economy:action.buy" = "Buy" diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index 1e9afe0..a3145db 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -483,6 +483,13 @@ select:focus { border-color: var(--color-super-lowered); } +input:disabled, +textarea:disabled, +select:disabled { + opacity: 50%; + cursor: not-allowed; +} + .poll_bar { background-color: var(--color-primary); border-radius: var(--radius); diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 900116a..3753516 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -2681,3 +2681,23 @@ (td (text "{{ ((post.likes + 1) / (post.dislikes + 1))|round(method=\"ceil\", precision=2) }}")) (td (span ("class" "date short") (text "{{ post.created }}")))) (text "{%- endmacro %}") + +(text "{% macro product_listing_card(product, owner=false, edit=false) -%}") +(a + ("class" "card button lowered w_full flex flex_col gap_2") + ("href" "/product/{{ product.id }}{% if edit -%} /edit {%- endif %}") + (text "{% if owner -%}") + (text "{{ self::full_username(user=owner) }}") + (text "{%- endif %}") + + (h3 + ("class" "flex gap_2 items_center {% if not product.on_sale -%} fade {%- endif %}") + ("style" "height: 24px; text-decoration: {% if not product.on_sale -%} line-through {%- else -%} none {%- endif %}") + (icon (text "package")) + (text "{{ product.title }}")) + (h4 + ("class" "flex gap_2 items_center") + ("style" "height: 18px") + (icon (text "badge-cent")) + (text "{{ product.price }}"))) +(text "{%- endmacro %}") diff --git a/crates/app/src/public/html/economy/edit.lisp b/crates/app/src/public/html/economy/edit.lisp new file mode 100644 index 0000000..30accd2 --- /dev/null +++ b/crates/app/src/public/html/economy/edit.lisp @@ -0,0 +1,322 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "Manage product - {{ 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 gap_2 items_center") + (icon (text "pencil-line")) + (b + (str (text "economy:label.title")))) + (form + ("class" "card flex flex_col gap_2") + ("onsubmit" "update_title_from_form(event)") + (div + ("class" "flex flex_col gap_1") + (label + ("for" "title") + (str (text "economy:label.title"))) + (input + ("type" "text") + ("name" "title") + ("id" "title") + ("placeholder" "title") + ("required" "") + ("minlength" "2") + ("maxlength" "128") + ("value" "{{ product.title }}"))) + (button (str (text "general:action.save"))))) + + (div + ("class" "card_nest") + (div + ("class" "card small flex gap_2 items_center") + (icon (text "pencil-line")) + (b + (str (text "economy:label.description")))) + (form + ("class" "card flex flex_col gap_2") + ("onsubmit" "update_description_from_form(event)") + (div + ("class" "flex flex_col gap_1") + (label + ("for" "description") + (str (text "economy:label.description"))) + (textarea + ("name" "description") + ("id" "description") + ("placeholder" "description") + ("required" "") + ("minlength" "2") + ("maxlength" "1024") + (text "{{ product.description }}"))) + (button (str (text "general:action.save"))))) + + (div + ("class" "card_nest") + (div + ("class" "card small flex gap_2 items_center") + (icon (text "badge-cent")) + (b + (str (text "economy:label.price")))) + (form + ("class" "card flex flex_col gap_2") + ("onsubmit" "update_price_from_form(event)") + (label + ("for" "on_sale") + ("class" "flex items_center gap_2") + (input + ("type" "checkbox") + ("id" "on_sale") + ("name" "on_sale") + ("class" "w_content") + ("checked" "{{ product.on_sale }}") + ("oninput" "event.preventDefault(); update_on_sale_from_form(event.target.checked)")) + (span + (str (text "economy:label.on_sale")))) + (div + ("class" "flex flex_col gap_1") + (label + ("for" "title") + (str (text "economy:label.price"))) + (input + ("type" "number") + ("name" "price") + ("id" "price") + ("placeholder" "price") + ("required" "") + ("min" "0") + ("max" "1000000") + ("value" "{{ product.price }}"))) + (button (str (text "general:action.save"))))) + + (div + ("class" "card_nest") + (div + ("class" "card small flex gap_2 items_center") + (icon (text "weight")) + (b + (str (text "economy:label.stock")))) + (form + ("class" "card flex flex_col gap_2") + ("onsubmit" "update_stock_from_form(event)") + (label + ("for" "unlimited") + ("class" "flex items_center gap_2") + (input + ("type" "checkbox") + ("id" "unlimited") + ("name" "unlimited") + ("class" "w_content") + ("checked" "{{ product.stock == -1 }}") + ("oninput" "event.preventDefault(); event.target.checked ? document.getElementById('stock').value = -1 : document.getElementById('stock').value = 0")) + (span + (str (text "economy:label.unlimited")))) + (div + ("class" "flex flex_col gap_1") + (label + ("for" "title") + (str (text "economy:label.stock"))) + (input + ("type" "number") + ("name" "stock") + ("id" "stock") + ("placeholder" "stock") + ("required" "") + ("min" "-1") + ("max" "1000000") + ("value" "{{ product.stock }}"))) + (button (str (text "general:action.save"))))) + + (div + ("class" "card_nest") + (div + ("class" "card small flex gap_2 items_center") + (icon (text "package-check")) + (b + (str (text "economy:label.fulfillment_style")))) + (form + ("class" "card flex flex_col gap_2") + ("onsubmit" "update_method_from_form(event)") + (p (text "If you choose to send an automated mail letter upon purchase, users will automatically receive the message you supply below.")) + (p (text "If you disable automail, you'll be required to manually mail users who have purchased your product before the transfer is finalized.")) + + (label + ("for" "use_automail") + ("class" "flex items_center gap_2") + (input + ("type" "checkbox") + ("id" "use_automail") + ("name" "use_automail") + ("class" "w_content") + ("oninput" "mirror_use_automail()") + ("checked" "{% if product.method != \"ManualMail\" -%} true {%- else -%} false {%- endif %}")) + (span + (str (text "economy:label.use_automail")))) + (div + ("class" "flex flex_col gap_1") + (label + ("for" "automail_message") + (str (text "economy:label.automail_message"))) + (textarea + ("name" "automail_message") + ("id" "automail_message") + ("placeholder" "automail_message") + (text "{% if product.method != \"ManualMail\" -%} {{ product.method.AutoMail }} {%- endif %}"))) + (button (str (text "general:action.save"))))) + + (a + ("class" "button secondary") + ("href" "/product/{{ product.id }}") + (icon (text "arrow-left")) + (str (text "general:action.back")))) + +(script + (text "async function update_title_from_form(e) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"products::update\"]); + + fetch(\"/api/v1/products/{{ product.id }}/title\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + title: e.target.title.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + async function update_description_from_form(e) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"products::update\"]); + + fetch(\"/api/v1/products/{{ product.id }}/description\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + description: e.target.description.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + async function update_on_sale_from_form(on_sale) { + await trigger(\"atto::debounce\", [\"products::update\"]); + + fetch(\"/api/v1/products/{{ product.id }}/on_sale\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + on_sale, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + async function update_price_from_form(e) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"products::update\"]); + + fetch(\"/api/v1/products/{{ product.id }}/price\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + price: e.target.price.valueAsNumber, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + async function update_stock_from_form(e) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"products::update\"]); + + fetch(\"/api/v1/products/{{ product.id }}/stock\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + stock: e.target.stock.valueAsNumber, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + async function update_method_from_form(e) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"products::update\"]); + + fetch(\"/api/v1/products/{{ product.id }}/method\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + method: e.target.use_automail.checked ? { AutoMail: e.target.automail_message.value } : \"ManualMail\", + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + globalThis.mirror_use_automail = () => { + const use_automail = document.getElementById(\"use_automail\").checked; + + if (use_automail) { + document.getElementById(\"automail_message\").removeAttribute(\"disabled\"); + } else { + document.getElementById(\"automail_message\").setAttribute(\"disabled\", \"true\"); + } + } + + setTimeout(() => { + mirror_use_automail(); + }, 150);")) +(text "{% endblock %}") diff --git a/crates/app/src/public/html/economy/product.lisp b/crates/app/src/public/html/economy/product.lisp new file mode 100644 index 0000000..bcceb2b --- /dev/null +++ b/crates/app/src/public/html/economy/product.lisp @@ -0,0 +1,67 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "{{ product.title }} - {{ config.name }}")) +(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"\") }}") +(main + ("class" "flex flex_col gap_2") + (div + ("class" "card flex flex_col gap_2") + (h3 + ("style" "height: 32px") + (text "{{ product.title }}")) + (text "{{ components::full_username(user=owner) }}") + + (text "{% if product.stock >= 0 -%}") + (span ("class" "red") (text "{{ product.stock }} remaining")) + (text "{%- endif %}") + + (div + ("class" "card lowered w_full no_p_margin") + (text "{{ product.description|markdown|safe }}")) + + (div + ("class" "flex gap_2 items_center") + (a + ("class" "button camo lowered") + ("href" "/wallet") + ("target" "_blank") + (icon (text "badge-cent")) + (text "{{ product.price }}")) + (text "{% if user.id != product.owner -%}") + (button + ("onclick" "purchase()") + ("disabled" "{{ product.stock == 0 }}") + (icon (text "piggy-bank")) + (str (text "economy:action.buy"))) + (text "{% else %}") + (a + ("class" "button") + ("href" "/product/{{ product.id }}/edit") + (icon (text "settings")) + (str (text "general:label.edit"))) + (text "{%- endif %}")))) + +(script + (text "async function purchase() { + await trigger(\"atto::debounce\", [\"products::buy\"]); + + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this? Your new balance will be {{ user.coins - product.price }} coins.\", + ])) + ) { + return; + } + + fetch(\"/api/v1/products/{{ product.id }}/buy\", { + method: \"POST\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + }")) +(text "{% endblock %}") diff --git a/crates/app/src/public/html/economy/products.lisp b/crates/app/src/public/html/economy/products.lisp new file mode 100644 index 0000000..c6f734a --- /dev/null +++ b/crates/app/src/public/html/economy/products.lisp @@ -0,0 +1,91 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "My products - {{ config.name }}")) + +(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"products\") }}") +(main + ("class" "flex flex_col gap_2") + ; create new + (text "{{ components::supporter_ad(body=\"Become a supporter to create unlimited products!\") }}") + + (div + ("class" "card_nest") + (div + ("class" "card small") + (b + (str (text "economy:label.create_new")))) + (form + ("class" "card flex flex_col gap_2") + ("onsubmit" "create_product_from_form(event)") + (div + ("class" "flex flex_col gap_1") + (label + ("for" "title") + (str (text "economy:label.title"))) + (input + ("type" "text") + ("name" "title") + ("id" "title") + ("placeholder" "title") + ("required" "") + ("minlength" "2") + ("maxlength" "128"))) + (div + ("class" "flex flex_col gap_1") + (label + ("for" "description") + (str (text "economy:label.description"))) + (textarea + ("name" "description") + ("id" "description") + ("placeholder" "description") + ("required" "") + ("minlength" "2") + ("maxlength" "1024"))) + (button + (text "{{ text \"communities:action.create\" }}")))) + + ; product listing + (div + ("class" "card_nest") + (div + ("class" "card small flex items_center gap_2") + (icon (text "store")) + (str (text "economy:label.my_products"))) + + (div + ("class" "card flex flex_col gap_2") + (text "{% for item in list %} {{ components::product_listing_card(product=item, edit=true) }} {% endfor %}") + (text "{{ components::pagination(page=page, items=list|length) }}")))) + +(script + (text "async function create_product_from_form(e) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"products::create\"]); + + fetch(\"/api/v1/products\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + title: e.target.title.value, + description: e.target.description.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + e.target.reset(); + setTimeout(() => { + window.location.href = `/product/${res.payload}/edit`; + }, 100); + } + }); + }")) +(text "{% endblock %}") diff --git a/crates/app/src/public/html/economy/wallet.lisp b/crates/app/src/public/html/economy/wallet.lisp index e390502..ddeb428 100644 --- a/crates/app/src/public/html/economy/wallet.lisp +++ b/crates/app/src/public/html/economy/wallet.lisp @@ -51,9 +51,10 @@ (td ("class" "w_content") (text "{{ components::full_username(user=transfer[3]) }}")) (td ("class" "w_content") (text "{{ components::full_username(user=transfer[4]) }}")) (td + ("class" "flex items_center gap_1") (text "{{ transfer[2] }}") (text "{% if transfer[6] -%}") - (span ("title" "Pending") (icon (text "clock"))) + (span ("class" "flex items_center gap_1") ("title" "Pending") (icon (text "clock"))) (text "{%- endif %}")) (td (text "{% if transfer[5] -%}") diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp index cd35481..ab7c3af 100644 --- a/crates/app/src/public/html/macros.lisp +++ b/crates/app/src/public/html/macros.lisp @@ -73,14 +73,20 @@ ("class" "inner") (a ("href" "/chats/0/0") - ("title" "Chats") (icon (text "message-circle")) (str (text "communities:label.chats"))) (a ("href" "/mail") - ("title" "Mail") (icon (text "mail")) (str (text "general:link.mail"))) + (a + ("href" "/wallet") + (icon (text "piggy-bank")) + (str (text "economy:label.my_wallet"))) + (a + ("href" "/products") + (icon (text "store")) + (str (text "economy:label.my_products"))) (a ("href" "/journals/0/0") (icon (text "notebook")) @@ -318,6 +324,13 @@ ("class" "{% if selected == 'media' -%}active{%- endif %}") (str (text "auth:label.media"))) + (text "{% if user and profile.settings.enable_shop -%}") + (a + ("href" "/@{{ profile.username }}/shop") + ("class" "{% if selected == 'shop' -%}active{%- endif %}") + (str (text "auth:label.shop"))) + (text "{%- endif %}") + (text "{% if is_self or is_helper -%}") (a ("href" "/@{{ profile.username }}/outbox") diff --git a/crates/app/src/public/html/mail/compose.lisp b/crates/app/src/public/html/mail/compose.lisp index 5b9f401..430a8ff 100644 --- a/crates/app/src/public/html/mail/compose.lisp +++ b/crates/app/src/public/html/mail/compose.lisp @@ -112,6 +112,7 @@ subject: e.target.subject.value.trim(), receivers: RECEIVERS, replying_to: SEARCH_PARAMS.get(\"replying_to\") || \"0\", + transfer_id: SEARCH_PARAMS.get(\"transfer_id\") || \"0\", }), }) .then((res) => res.json()) diff --git a/crates/app/src/public/html/misc/requests.lisp b/crates/app/src/public/html/misc/requests.lisp index 2735e0e..73d03ef 100644 --- a/crates/app/src/public/html/misc/requests.lisp +++ b/crates/app/src/public/html/misc/requests.lisp @@ -92,6 +92,37 @@ (text "{{ icon \"trash\" }}") (span (text "{{ text \"general:action.delete\" }}")))))) + (text "{% elif request.action_type == \"Transfer\" %}") + (div + ("class" "card_nest") + (div + ("class" "card small flex items_center gap_2") + (icon (text "piggy-bank")) + (span + (str (text "requests:label.coin_transfer_request")))) + (div + ("class" "card flex flex_col gap_2") + (span (a ("href" "/api/v1/auth/user/find/{{ request.linked_asset }}") (text "Somebody")) (text " is asking for a transfer of ") (b (text "{{ request.data.Int32 }} coins")) (text ".")) + (div + ("class" "card flex flex_wrap w_full secondary gap_2") + (a + ("href" "/api/v1/auth/user/find/{{ request.linked_asset }}") + ("class" "button") + (text "{{ icon \"external-link\" }}") + (span + (text "{{ text \"requests:action.view_profile\" }}"))) + (button + ("class" "lowered green") + ("onclick" "accept_transfer_request(event, '{{ request.id }}', '{{ request.linked_asset }}', {{ request.data.Int32 }})") + (text "{{ icon \"check\" }}") + (span + (text "{{ text \"general:action.accept\" }}"))) + (button + ("class" "lowered red") + ("onclick" "remove_request('{{ request.id }}', '{{ request.linked_asset }}')") + (text "{{ icon \"trash\" }}") + (span + (text "{{ text \"general:action.delete\" }}")))))) (text "{%- endif %} {% endfor %} {% for question in questions %}") (div ("class" "card_nest") @@ -138,13 +169,15 @@ (text "{{ components::pagination(page=page, items=requests|length, key=\"&id=\", value=profile.id) }}")) (script - (text "async function remove_request(id, linked_asset) { - if ( - !(await trigger(\"atto::confirm\", [ - \"Are you sure you want to do this?\", - ])) - ) { - return; + (text "async function remove_request(id, linked_asset, confirm = true) { + if (confirm) { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you want to do this?\", + ])) + ) { + return; + } } fetch(`/api/v1/requests/${id}/${linked_asset}`, { @@ -275,6 +308,41 @@ } } }); + }; + + globalThis.accept_transfer_request = async (e, id, receiver, amount) => { + await trigger(\"atto::debounce\", [\"economy::transfer\"]); + + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(`/api/v1/transfers`, { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + receiver, + amount, + }), + }) + .then((res) => res.json()) + .then(async (res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + e.target.parentElement.parentElement.parentElement.parentElement.remove(); + remove_request(id, receiver, false); + } + }); };")) (text "{% endblock %}") diff --git a/crates/app/src/public/html/profile/base.lisp b/crates/app/src/public/html/profile/base.lisp index e730aa6..aab9c70 100644 --- a/crates/app/src/public/html/profile/base.lisp +++ b/crates/app/src/public/html/profile/base.lisp @@ -256,6 +256,13 @@ (icon (text "mail-plus")) (span (str (text "mail:action.send_mail")))) + (text "{%- endif %} {% if not profile.settings.no_transfers -%}") + (button + ("onclick" "request_transfer()") + ("class" "lowered") + (icon (text "badge-cent")) + (span + (str (text "economy:action.request")))) (text "{%- endif %} {% if is_helper -%}") (a ("href" "/mod_panel/profile/{{ profile.id }}") @@ -289,6 +296,41 @@ }); }; + globalThis.request_transfer = async () => { + await trigger(\"atto::debounce\", [\"economy::transfer\"]); + const amount = Number.parseInt((await trigger(\"atto::prompt\", [\"Request amount:\"])) || \"\"); + + if (amount === 0) { + return; + } + + if ( + !(await trigger(\"atto::confirm\", [ + `Are you sure you would like to request ${amount} coins from {{ profile.username }}?`, + ])) + ) { + return; + } + + fetch(`/api/v1/transfers/ask`, { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + receiver: \"{{ profile.id }}\", + amount, + }), + }) + .then((res) => res.json()) + .then(async (res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + }; + globalThis.toggle_follow_user = async (e) => { await trigger(\"atto::debounce\", [ \"users::follow\", diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 3f5901a..0356283 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -1958,6 +1958,23 @@ settings.forum_signature, \"textarea\", ], + [[], \"Economy\", \"title\"], + [ + [ + \"enable_shop\", + \"Show shop tab on my profile\", + ], + \"{{ profile.settings.enable_shop }}\", + \"checkbox\", + ], + [ + [ + \"no_transfers\", + \"Disable transfer requests\", + ], + \"{{ profile.settings.no_transfers }}\", + \"checkbox\", + ], [[], \"Misc\", \"title\"], [ [\"hide_dislikes\", \"Hide post dislikes\"], diff --git a/crates/app/src/public/html/profile/shop.lisp b/crates/app/src/public/html/profile/shop.lisp new file mode 100644 index 0000000..744d2f1 --- /dev/null +++ b/crates/app/src/public/html/profile/shop.lisp @@ -0,0 +1,17 @@ +(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 %}") +(text "{{ macros::profile_nav(selected=\"shop\") }}") +(div + ("class" "card_nest") + (div + ("class" "card small flex gap_2 items_center") + (icon (text "store")) + (str (text "auth:label.shop"))) + (div + ("class" "card w_full flex flex_col gap_2") + (text "{% for item in list %} {{ components::product_listing_card(product=item) }} {% endfor %}") + (text "{{ components::pagination(page=page, items=list|length) }}"))) +(text "{% endblock %}") diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index 8791aeb..ac6dda8 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -85,6 +85,10 @@ media_theme_pref(); element.removeAttribute("checked"); } + for (const element of document.querySelectorAll('[disabled="false"]')) { + element.removeAttribute("disabled"); + } + for (const element of document.querySelectorAll('[selected="true"]')) { element.parentElement.value = element.value; } diff --git a/crates/app/src/routes/api/v1/letters.rs b/crates/app/src/routes/api/v1/letters.rs index 0d00749..c2a525b 100644 --- a/crates/app/src/routes/api/v1/letters.rs +++ b/crates/app/src/routes/api/v1/letters.rs @@ -3,7 +3,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use tetratto_core::model::{auth::Notification, mail::Letter, oauth, ApiReturn, Error}; +use tetratto_core::model::{mail::Letter, oauth, ApiReturn, Error}; use crate::{cookie::CookieJar, get_user_from_token, routes::pages::PaginatedQuery, State}; use super::CreateLetter; @@ -170,30 +170,19 @@ pub async fn create_request( .await { Ok(l) => { - // send notifications - for x in &l.receivers { + // check if we're fulfilling a coin transfer + if !props.transfer_id.is_empty() && props.transfer_id != "0" { if let Err(e) = data - .create_notification(Notification::new( - "You've got mail!".to_string(), - format!( - "[@{}](/api/v1/auth/user/find/{}) has sent you a [letter](/mail/letter/{}).", - user.username, user.id, l.id - ), - *x, - )) + .apply_transfer(match props.transfer_id.parse() { + Ok(x) => x, + Err(_) => return Json(Error::Unknown.into()), + }) .await { return Json(e.into()); } } - // check if we're fulfilling a coin transfer - if props.transfer_id != 0 { - if let Err(e) = data.apply_transfer(props.transfer_id).await { - return Json(e.into()); - } - } - // ... Json(ApiReturn { ok: true, diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 9ce76ef..f725f39 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -710,6 +710,7 @@ pub fn routes() -> Router { .route("/letters/received", get(letters::list_received_request)) // transfers .route("/transfers", post(transfers::create_request)) + .route("/transfers/ask", post(transfers::ask_request)) // products .route("/products", post(products::create_request)) .route("/products/{id}", delete(products::delete_request)) @@ -1236,12 +1237,12 @@ pub struct CreateLetter { pub content: String, pub replying_to: String, #[serde(default)] - pub transfer_id: usize, + pub transfer_id: String, } #[derive(Deserialize)] pub struct CreateCoinTransfer { - pub receiver: usize, + pub receiver: String, pub amount: i32, } diff --git a/crates/app/src/routes/api/v1/products.rs b/crates/app/src/routes/api/v1/products.rs index d377de8..df73b86 100644 --- a/crates/app/src/routes/api/v1/products.rs +++ b/crates/app/src/routes/api/v1/products.rs @@ -149,6 +149,15 @@ pub async fn update_price_request( None => return Json(Error::NotAllowed.into()), }; + if req.price < 25 { + return Json( + Error::MiscError( + "Price is too low, please a price of use 25 coins or more".to_string(), + ) + .into(), + ); + } + match data.update_product_price(id, &user, req.price).await { Ok(_) => Json(ApiReturn { ok: true, diff --git a/crates/app/src/routes/api/v1/transfers.rs b/crates/app/src/routes/api/v1/transfers.rs index 5de916f..be26656 100644 --- a/crates/app/src/routes/api/v1/transfers.rs +++ b/crates/app/src/routes/api/v1/transfers.rs @@ -2,7 +2,9 @@ use crate::{get_user_from_token, State, cookie::CookieJar}; use axum::{response::IntoResponse, Extension, Json}; use tetratto_core::model::{ economy::{CoinTransfer, CoinTransferMethod}, - oauth, ApiReturn, Error, + oauth, + requests::{ActionData, ActionRequest, ActionType}, + ApiReturn, Error, }; use super::CreateCoinTransfer; @@ -21,7 +23,10 @@ pub async fn create_request( .create_transfer( &mut CoinTransfer::new( user.id, - req.receiver, + match req.receiver.parse() { + Ok(x) => x, + Err(_) => return Json(Error::Unknown.into()), + }, req.amount, CoinTransferMethod::Transfer, // this endpoint is ONLY for regular transfers; products export a buy endpoint for the other method ), @@ -37,3 +42,35 @@ pub async fn create_request( Err(e) => Json(e.into()), } } + +pub async fn ask_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserSendCoins) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .create_request(ActionRequest::new( + match req.receiver.parse() { + Ok(x) => x, + Err(_) => return Json(Error::Unknown.into()), + }, + ActionType::Transfer, + user.id, + Some(ActionData::Int32(req.amount)), + )) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Asked user for transfer".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/pages/economy.rs b/crates/app/src/routes/pages/economy.rs index ea6f6f6..36c9cbe 100644 --- a/crates/app/src/routes/pages/economy.rs +++ b/crates/app/src/routes/pages/economy.rs @@ -43,3 +43,109 @@ pub async fn wallet_request( data.1.render("economy/wallet.html", &context).unwrap(), )) } + +/// `/products` +pub async fn products_request( + jar: CookieJar, + Extension(data): Extension, + Query(props): Query, +) -> 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 list = match data.0.get_products_by_user(user.id, 12, props.page).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + + context.insert("list", &list); + context.insert("page", &props.page); + + // return + Ok(Html( + data.1.render("economy/products.html", &context).unwrap(), + )) +} + +/// `/product/{id}/edit` +pub async fn edit_product_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> 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 product = match data.0.get_product_by_id(id).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + if user.id != product.owner { + return Err(Html( + render_error(Error::NotAllowed, &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("product", &product); + + // return + Ok(Html(data.1.render("economy/edit.html", &context).unwrap())) +} + +/// `/product/{id}` +pub async fn product_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> 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 product = match data.0.get_product_by_id(id).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + let owner = match data.0.get_user_by_id(product.owner).await { + Ok(x) => x, + 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("product", &product); + context.insert("owner", &owner); + + // return + Ok(Html( + data.1.render("economy/product.html", &context).unwrap(), + )) +} diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 3cdae39..be8c3df 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -85,6 +85,7 @@ pub fn routes() -> Router { .route("/@{username}/replies", get(profile::replies_request)) .route("/@{username}/following", get(profile::following_request)) .route("/@{username}/followers", get(profile::followers_request)) + .route("/@{username}/shop", get(profile::shop_request)) // communities .route("/communities", get(communities::list_request)) .route("/communities/search", get(communities::search_request)) @@ -162,6 +163,9 @@ pub fn routes() -> Router { .route("/mail/letter/{id}", get(mail::letter_request)) // economy .route("/wallet", get(economy::wallet_request)) + .route("/products", get(economy::products_request)) + .route("/product/{id}/edit", get(economy::edit_product_request)) + .route("/product/{id}", get(economy::product_request)) } pub fn lw_routes() -> Router { diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index 9f5b16d..3f58805 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -307,20 +307,20 @@ pub async fn posts_request( ) .await { - Ok(p) => Some(data.0.posts_muted_phrase_filter( + Ok(p) => data.0.posts_muted_phrase_filter( &p, if let Some(ref ua) = user { Some(&ua.settings.muted) } else { None }, - )), + ), Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), }, Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), } } else { - None + Vec::new() }; let communities = match data.0.get_memberships_by_owner(other_user.id).await { @@ -614,6 +614,95 @@ pub async fn media_request( Ok(Html(data.1.render("profile/media.html", &context).unwrap())) } +/// `/@{username}/shop` +pub async fn shop_request( + jar: CookieJar, + Path(username): Path, + Query(props): Query, + Extension(data): Extension, +) -> 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 other_user = match data.0.get_user_by_username(&username).await { + Ok(ua) => ua, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + if !other_user.settings.enable_shop { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &Some(user)).await, + )); + } + + check_user_blocked_or_private!(Some(user.clone()), other_user, data, jar); + + // fetch data + let list = match data + .0 + .get_products_by_user(other_user.id, 12, props.page) + .await + { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + let communities = match data.0.get_memberships_by_owner(other_user.id).await { + Ok(m) => match data.0.fill_communities(m).await { + Ok(m) => m, + 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)), + }; + + // init context + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &Some(user.clone())).await; + + let is_self = user.id == other_user.id; + + let is_following = data + .0 + .get_userfollow_by_initiator_receiver(user.id, other_user.id) + .await + .is_ok(); + + let is_following_you = data + .0 + .get_userfollow_by_receiver_initiator(user.id, other_user.id) + .await + .is_ok(); + + let is_blocking = data + .0 + .get_userblock_by_initiator_receiver(user.id, other_user.id) + .await + .is_ok(); + + context.insert("list", &list); + context.insert("page", &props.page); + profile_context( + &mut context, + &Some(user), + &other_user, + &communities, + is_self, + is_following, + is_following_you, + is_blocking, + ); + + // return + Ok(Html(data.1.render("profile/shop.html", &context).unwrap())) +} + /// `/@{username}/outbox` pub async fn outbox_request( jar: CookieJar, diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 6d4a438..1904994 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "tetratto-core" description = "The core behind Tetratto" -version = "14.0.0" +version = "15.0.0" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index fb04c7b..814fcb1 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -401,6 +401,7 @@ fn default_banned_usernames() -> Vec { "mail".to_string(), "product".to_string(), "wallet".to_string(), + "products".to_string(), ] } diff --git a/crates/core/src/database/letters.rs b/crates/core/src/database/letters.rs index 80f973e..4375ebb 100644 --- a/crates/core/src/database/letters.rs +++ b/crates/core/src/database/letters.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; +use crate::model::auth::Notification; use crate::model::{auth::User, mail::Letter, permissions::SecondaryPermission, Error, Result}; use crate::{auto_method, DataManager}; use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow}; @@ -160,6 +161,9 @@ impl DataManager { return Err(Error::DataTooLong("receivers".to_string())); } + // get sender + let sender = self.get_user_by_id(data.owner).await?; + // ... let conn = match self.0.connect().await { Ok(c) => c, @@ -185,6 +189,20 @@ impl DataManager { return Err(Error::DatabaseError(e.to_string())); } + // send notifications + for x in &data.receivers { + self.create_notification(Notification::new( + "You've got mail!".to_string(), + format!( + "[@{}](/api/v1/auth/user/find/{}) has sent you a [letter](/mail/letter/{}).", + sender.username, sender.id, data.id + ), + *x, + )) + .await?; + } + + // ... Ok(data) } diff --git a/crates/core/src/database/products.rs b/crates/core/src/database/products.rs index 82240da..714531d 100644 --- a/crates/core/src/database/products.rs +++ b/crates/core/src/database/products.rs @@ -150,7 +150,7 @@ impl DataManager { match product.method { ProductFulfillmentMethod::AutoMail(message) => { // we're basically done, transfer coins and send mail - self.create_transfer(&mut transfer, false).await?; + self.create_transfer(&mut transfer, true).await?; self.create_letter(Letter::new( self.0.0.system_user, @@ -167,6 +167,16 @@ impl DataManager { // mark transfer as pending and create it self.create_transfer(&mut transfer, false).await?; + // tell the customer to wait + self.create_letter(Letter::new( + self.0.0.system_user, + vec![customer.id], + format!("Thank you for purchasing \"{}\"", product.title), + "This product uses manual mail, meaning you won't be charged until the product owner sends you a letter about the product. You'll see a pending transfer in your wallet.".to_string(), + 0, + )) + .await?; + // tell product owner they have a new pending purchase self.create_letter(Letter::new( self.0.0.system_user, @@ -178,7 +188,7 @@ impl DataManager { If your product is a purchase of goods or services, please be sure to fulfill this purchase either in the letter or elsewhere. The customer may request support if you fail to do so. *** -Fulfill purchase", +Fulfill purchase", product.id, product.title, customer.id, transfer.id ), 0, diff --git a/crates/core/src/database/transfers.rs b/crates/core/src/database/transfers.rs index fb4ed6c..833539e 100644 --- a/crates/core/src/database/transfers.rs +++ b/crates/core/src/database/transfers.rs @@ -1,8 +1,9 @@ use std::collections::HashMap; - -use crate::model::auth::User; -use crate::model::economy::{CoinTransferMethod, Product}; -use crate::model::{Error, Result, economy::CoinTransfer}; +use crate::model::{ + Error, Result, + economy::{CoinTransferMethod, Product, CoinTransfer}, + auth::{Notification, User}, +}; use crate::{auto_method, DataManager}; use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow}; @@ -168,8 +169,35 @@ impl DataManager { self.update_user_coins(sender.id, sender.coins).await?; self.update_user_coins(receiver.id, receiver.coins).await?; self.update_transfer_is_pending(id, 0).await?; + + self.create_notification(Notification::new( + "Purchase fulfilled!".to_string(), + format!( + "You've just successfully fulfilled a purchase for a [product](/product/{}).", + match transfer.method { + CoinTransferMethod::Purchase(x) => x, + _ => 0, + } + ), + receiver.id, + )) + .await?; + + self.create_notification(Notification::new( + "Purchase fulfilled!".to_string(), + format!( + "Your purchase for a [product](/product/{}) has been fulfilled.", + match transfer.method { + CoinTransferMethod::Purchase(x) => x, + _ => 0, + } + ), + sender.id, + )) + .await?; + Ok(()) } - auto_method!(update_transfer_is_pending(i32) -> "UPDATE products SET is_pending = $1 WHERE id = $2" --cache-key-tmpl="atto.transfer:{}"); + auto_method!(update_transfer_is_pending(i32) -> "UPDATE transfers SET is_pending = $1 WHERE id = $2" --cache-key-tmpl="atto.transfer:{}"); } diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 680d059..0217a02 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -350,6 +350,12 @@ pub struct UserSettings { /// The signature automatically attached to new forum posts. #[serde(default)] pub forum_signature: String, + /// If coin transfer requests are disabled. + #[serde(default)] + pub no_transfers: bool, + /// If your profile has the "Shop" tab enabled. + #[serde(default)] + pub enable_shop: bool, } fn mime_avif() -> String { diff --git a/crates/core/src/model/requests.rs b/crates/core/src/model/requests.rs index 1b4f320..3a32f38 100644 --- a/crates/core/src/model/requests.rs +++ b/crates/core/src/model/requests.rs @@ -1,7 +1,7 @@ use serde::{Serialize, Deserialize}; use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; -#[derive(Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum ActionData { String(String), Int32(i32), @@ -46,7 +46,7 @@ impl Default for ActionData { } } -#[derive(Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum ActionType { /// A request to join a community. /// @@ -66,7 +66,7 @@ pub enum ActionType { Transfer, } -#[derive(Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] pub struct ActionRequest { pub id: usize, pub created: usize, From 44f9edd67eab65691e373137f6b0de07d5cbd57b Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 8 Aug 2025 13:25:47 -0400 Subject: [PATCH 17/38] add: coin purchases + donator badge --- crates/app/src/public/css/utility.css | 2 +- crates/app/src/public/html/body.lisp | 7 +- crates/app/src/public/html/chats/app.lisp | 2 +- .../app/src/public/html/chats/channels.lisp | 2 +- crates/app/src/public/html/components.lisp | 22 +- .../app/src/public/html/economy/wallet.lisp | 54 ++++- crates/app/src/public/html/journals/app.lisp | 6 +- crates/app/src/public/html/profile/base.lisp | 8 +- crates/app/src/public/html/profile/posts.lisp | 2 +- .../src/public/html/profile/responses.lisp | 2 +- .../app/src/public/html/stacks/add_user.lisp | 2 +- crates/app/src/public/js/me.js | 2 +- .../routes/api/v1/auth/connections/stripe.rs | 214 +++++++++++++++++- crates/app/src/routes/api/v1/mod.rs | 8 + crates/app/src/routes/api/v1/products.rs | 2 +- crates/core/src/config.rs | 16 +- crates/core/src/database/auth.rs | 7 +- .../src/database/drivers/sql/create_users.sql | 3 +- .../drivers/sql/version_migrations.sql | 4 + crates/core/src/database/transfers.rs | 11 +- crates/core/src/model/auth.rs | 7 + 21 files changed, 345 insertions(+), 38 deletions(-) diff --git a/crates/app/src/public/css/utility.css b/crates/app/src/public/css/utility.css index 4b0460f..f5f834f 100644 --- a/crates/app/src/public/css/utility.css +++ b/crates/app/src/public/css/utility.css @@ -35,7 +35,7 @@ justify-content: right; } -.justify-start { +.justify_start { justify-content: flex-start !important; } diff --git a/crates/app/src/public/html/body.lisp b/crates/app/src/public/html/body.lisp index 89b6d53..705b14c 100644 --- a/crates/app/src/public/html/body.lisp +++ b/crates/app/src/public/html/body.lisp @@ -169,21 +169,20 @@ ("id" "littleweb") (div ("class" "inner flex flex_col gap_2") - (a - ("class" "button w_full lowered justify-start") + ("class" "button w_full lowered justify_start") ("href" "/net") (icon (text "globe")) (str (text "littleweb:label.browser"))) (a - ("class" "button w_full lowered justify-start") + ("class" "button w_full lowered justify_start") ("href" "/services") (icon (text "panel-top")) (str (text "littleweb:label.my_services"))) (a - ("class" "button w_full lowered justify-start") + ("class" "button w_full lowered justify_start") ("href" "/domains") (icon (text "panel-top")) (str (text "littleweb:label.my_domains"))) diff --git a/crates/app/src/public/html/chats/app.lisp b/crates/app/src/public/html/chats/app.lisp index 8d0b3d4..e64dc0b 100644 --- a/crates/app/src/public/html/chats/app.lisp +++ b/crates/app/src/public/html/chats/app.lisp @@ -73,7 +73,7 @@ (text "{%- endif %}")) (text "{% if can_manage_channels -%}") (a - ("class" "button w_full justify-start lowered") + ("class" "button w_full justify_start lowered") ("href" "/community/{{ selected_community }}/manage#/channels") (text "{{ icon \"plus\" }}") (span diff --git a/crates/app/src/public/html/chats/channels.lisp b/crates/app/src/public/html/chats/channels.lisp index 2e12b18..fb61be0 100644 --- a/crates/app/src/public/html/chats/channels.lisp +++ b/crates/app/src/public/html/chats/channels.lisp @@ -7,7 +7,7 @@ (div ("class" "flex flex_row gap_1") (a - ("class" "w_full justify-start button {% if selected_channel == channel.id -%}lowered{% else %}camo{%- endif %}") + ("class" "w_full justify_start button {% if selected_channel == channel.id -%}lowered{% else %}camo{%- endif %}") ("href" "/chats/{{ selected_community }}/{{ channel.id }}") ("data-turbo" "{{ selected_community == '0' }}") (text "{{ icon \"rss\" }}") diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 3753516..f88a872 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -1863,14 +1863,14 @@ ; option a (button - ("class" "hover_left_bar raised justify-start w_full poll_option") + ("class" "hover_left_bar raised justify_start w_full poll_option") ("onclick" "trigger('me::vote', ['{{ post.id }}', 'A'])") (icon (text "tally-1")) (text "{{ poll[0].option_a }}")) ; option b (button - ("class" "hover_left_bar raised justify-start w_full poll_option") + ("class" "hover_left_bar raised justify_start w_full poll_option") ("onclick" "trigger('me::vote', ['{{ post.id }}', 'B'])") (icon (text "tally-2")) (text "{{ poll[0].option_b }}")) @@ -1878,7 +1878,7 @@ ; option c (text "{% if poll[0].option_c -%}") (button - ("class" "hover_left_bar raised justify-start w_full poll_option") + ("class" "hover_left_bar raised justify_start w_full poll_option") ("onclick" "trigger('me::vote', ['{{ post.id }}', 'C'])") (icon (text "tally-3")) (text "{{ poll[0].option_c }}")) @@ -1887,7 +1887,7 @@ ; option d (text "{% if poll[0].option_d -%}") (button - ("class" "hover_left_bar raised justify-start w_full poll_option") + ("class" "hover_left_bar raised justify_start w_full poll_option") ("onclick" "trigger('me::vote', ['{{ post.id }}', 'D'])") (icon (text "tally-4")) (text "{{ poll[0].option_d }}")) @@ -2181,7 +2181,7 @@ ("class" "flex flex_row gap_1") (a ("href" "/journals/{{ journal.id }}/0?view={{ view_mode }}") - ("class" "button justify-start lowered w_full") + ("class" "button justify_start lowered w_full") (icon (text "notebook")) (text "{{ journal.title }}")) @@ -2207,7 +2207,7 @@ (div ("class" "flex flex_row gap_1") (button - ("class" "justify-start lowered w_full") + ("class" "justify_start lowered w_full") (icon (text "arrow-down")) (text "{{ journal.title }}")) @@ -2257,7 +2257,7 @@ ; create note (text "{% if user and user.id == journal.owner -%}") (button - ("class" "lowered justify-start w_full") + ("class" "lowered justify_start w_full") ("onclick" "create_note()") (icon (text "plus")) (str (text "journals:action.create_note"))) @@ -2271,7 +2271,7 @@ (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") + ("class" "button w_full justify_start raised w_full") (icon (text "folder")) (text "{{ dir[2] }}")) @@ -2299,7 +2299,7 @@ ("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 %}") + ("class" "button justify_start w_full {% if selected_note == note.id -%} lowered {%- else -%} raised {%- endif %}") (icon (text "file-text")) (text "{{ note.title }}")) @@ -2380,7 +2380,7 @@ (div ("class" "flex flex_row gap_1") (button - ("class" "justify-start lowered w_full") + ("class" "justify_start lowered w_full") (icon (text "folder-open")) (text "{{ dir[2] }}")) @@ -2423,7 +2423,7 @@ (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") + ("class" "justify_start lowered w_full") (icon (text "folder-open")) (text "{{ dir[2] }}")) diff --git a/crates/app/src/public/html/economy/wallet.lisp b/crates/app/src/public/html/economy/wallet.lisp index ddeb428..0fb5b92 100644 --- a/crates/app/src/public/html/economy/wallet.lisp +++ b/crates/app/src/public/html/economy/wallet.lisp @@ -14,9 +14,9 @@ (span (str (text "general:link.wallet"))))) (div ("class" "card lowered flex flex_col gap_4") - (a + (button ("class" "card button raised") - ("href" "/wallet/buy") + ("onclick" "document.getElementById('buy_dialog').showModal()") (b (text "Coin balance")) (h3 ("class" "flex gap_2 items_center") @@ -63,4 +63,54 @@ (icon (text "external-link"))) (text "{%- endif %}"))) (text "{%- endfor %}"))))))) + +(dialog + ("id" "buy_dialog") + (div + ("class" "inner flex flex_col gap_2") + (p (text "All coin purchases are one-time and will not recur.")) + (p (text "If you do not receive your coins within a minute of purchase, please contact support.")) + + (button + ("class" "lowered w_full justify_start") + ("onclick" "checkout('Coins100')") + (text "100 coins ({{ config.stripe.price_texts.coins_100 }})")) + + (button + ("class" "w_full justify_start") + ("onclick" "checkout('Coins400')") + (text "400 coins ({{ config.stripe.price_texts.coins_400 }})")) + + (hr ("class" "margin")) + (div + ("class" "flex gap_2 justify_between") + (div null?) + (button + ("class" "lowered red") + ("type" "button") + ("onclick", "document.getElementById('buy_dialog').close()") + (icon (text "x")) + (str (text "dialog:action.cancel")))))) + +(script + (text "globalThis.checkout = (product) => { + document.getElementById('buy_dialog').close(); + fetch(\"/api/v1/service_hooks/stripe/checkout\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + product, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [res.ok ? \"success\" : \"error\", res.message]); + + if (res.ok) { + window.location.href = res.payload; + } + }); + }")) (text "{% endblock %}") diff --git a/crates/app/src/public/html/journals/app.lisp b/crates/app/src/public/html/journals/app.lisp index 246e930..48e9e12 100644 --- a/crates/app/src/public/html/journals/app.lisp +++ b/crates/app/src/public/html/journals/app.lisp @@ -121,7 +121,7 @@ (div ("class" "flex flex_col gap_2 w_full") (button - ("class" "lowered justify-start w_full") + ("class" "lowered justify_start w_full") ("onclick" "create_journal()") (icon (text "plus")) (str (text "journals:action.create_journal"))) @@ -207,7 +207,7 @@ (details ("class" "w_full") (summary - ("class" "button lowered w_full justify-start") + ("class" "button lowered w_full justify_start") (icon (text "settings")) (str (text "general:action.manage"))) @@ -261,7 +261,7 @@ (details ("class" "w_full") (summary - ("class" "button lowered w_full justify-start") + ("class" "button lowered w_full justify_start") (icon (text "folders")) (str (text "journals:label.directories"))) diff --git a/crates/app/src/public/html/profile/base.lisp b/crates/app/src/public/html/profile/base.lisp index aab9c70..c1788b3 100644 --- a/crates/app/src/public/html/profile/base.lisp +++ b/crates/app/src/public/html/profile/base.lisp @@ -64,7 +64,7 @@ ("id" "username") ("class" "username flex items_center gap_2 flex_wrap w_full") (span - ("class" "name shorter") + ("class" "name") (text "{{ components::username(user=profile) }}")) (text "{% if profile.is_verified -%}") (span @@ -84,6 +84,12 @@ ("style" "color: var(--color-primary);") ("class" "flex items_center") (text "{{ icon \"id-card-lanyard\" }}")) + (text "{%- endif %} {% if profile.checkouts|length > 0 -%}") + (span + ("title" "Donator") + ("style" "color: var(--color-primary);") + ("class" "flex items_center") + (text "{{ icon \"hand-heart\" }}")) (text "{%- endif %} {% if profile.permissions|has_staff_badge -%}") (span ("title" "Staff") diff --git a/crates/app/src/public/html/profile/posts.lisp b/crates/app/src/public/html/profile/posts.lisp index 2632319..e01660b 100644 --- a/crates/app/src/public/html/profile/posts.lisp +++ b/crates/app/src/public/html/profile/posts.lisp @@ -32,7 +32,7 @@ ("class" "card w_full flex flex_col gap_2") ("ui_ident" "io_data_load") ; pinned - (text "{% if pinned|length > 0 -%}") + (text "{% if pinned and pinned|length > 0 -%}") (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 %}") (div ("class" "squig")) (text "{%- endif %}") diff --git a/crates/app/src/public/html/profile/responses.lisp b/crates/app/src/public/html/profile/responses.lisp index 67ccd80..925d271 100644 --- a/crates/app/src/public/html/profile/responses.lisp +++ b/crates/app/src/public/html/profile/responses.lisp @@ -32,7 +32,7 @@ ("class" "card w_full flex flex_col gap_2") ("ui_ident" "io_data_load") ; pinned - (text "{% if pinned|length > 0 -%}") + (text "{% if pinned and pinned|length > 0 -%}") (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 %}") (div ("class" "squig")) (text "{%- endif %}") diff --git a/crates/app/src/public/html/stacks/add_user.lisp b/crates/app/src/public/html/stacks/add_user.lisp index 7318cfc..5ae6f90 100644 --- a/crates/app/src/public/html/stacks/add_user.lisp +++ b/crates/app/src/public/html/stacks/add_user.lisp @@ -16,7 +16,7 @@ (span (text "Select a stack to add this user to:")) (text "{% for stack in stacks %}") (button - ("class" "justify-start lowered w_full") + ("class" "justify_start lowered w_full") ("onclick" "choose_stack('{{ stack.id }}')") (icon (text "layers")) (text "{{ stack.name }}")) diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index e1f7def..708baf4 100644 --- a/crates/app/src/public/js/me.js +++ b/crates/app/src/public/js/me.js @@ -726,7 +726,7 @@ element.innerHTML = ""; for (const token of Object.entries($.LOGIN_ACCOUNT_TOKENS)) { element.innerHTML += `
-