diff --git a/Cargo.lock b/Cargo.lock index cb439e7..1b81526 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3288,7 +3288,7 @@ dependencies = [ [[package]] name = "tetratto" -version = "5.0.0" +version = "6.0.0" dependencies = [ "ammonia", "async-stripe", @@ -3319,7 +3319,7 @@ dependencies = [ [[package]] name = "tetratto-core" -version = "5.0.0" +version = "6.0.0" dependencies = [ "async-recursion", "base16ct", @@ -3341,7 +3341,7 @@ dependencies = [ [[package]] name = "tetratto-l10n" -version = "5.0.0" +version = "6.0.0" dependencies = [ "pathbufd", "serde", @@ -3350,7 +3350,7 @@ dependencies = [ [[package]] name = "tetratto-shared" -version = "5.0.0" +version = "6.0.0" dependencies = [ "ammonia", "chrono", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 6464be0..73e8dbd 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto" -version = "5.0.0" +version = "6.0.0" edition = "2024" [features] diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index a63d795..6b9e3d0 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -119,6 +119,7 @@ pub const STACKS_MANAGE: &str = include_str!("./public/html/stacks/manage.lisp") pub const FORGE_HOME: &str = include_str!("./public/html/forge/home.lisp"); pub const FORGE_BASE: &str = include_str!("./public/html/forge/base.lisp"); pub const FORGE_INFO: &str = include_str!("./public/html/forge/info.lisp"); +pub const FORGE_TICKETS: &str = include_str!("./public/html/forge/tickets.lisp"); // langs pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml"); @@ -392,6 +393,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"forge/home.html"(crate::assets::FORGE_HOME) -d "forge" --config=config --lisp plugins); write_template!(html_path->"forge/base.html"(crate::assets::FORGE_BASE) --config=config --lisp plugins); write_template!(html_path->"forge/info.html"(crate::assets::FORGE_INFO) --config=config --lisp plugins); + write_template!(html_path->"forge/tickets.html"(crate::assets::FORGE_TICKETS) --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 52f56e7..6c0d911 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -208,3 +208,5 @@ version = "1.0.0" "forge:label.create_new" = "Create new forge" "forge:tab.info" = "Info" "forge:tab.tickets" = "Tickets" +"forge:action.reopen" = "Reopen" +"forge:action.close" = "Close" diff --git a/crates/app/src/public/css/root.css b/crates/app/src/public/css/root.css index 6e349be..1614a5e 100644 --- a/crates/app/src/public/css/root.css +++ b/crates/app/src/public/css/root.css @@ -27,6 +27,7 @@ --color-red: hsl(0, 84%, 40%); --color-green: hsl(100, 84%, 20%); --color-yellow: hsl(41, 63%, 75%); + --color-purple: hsl(284, 84%, 20%); --radius: 6px; --circle: 360px; --shadow-x-offset: 0; @@ -63,6 +64,7 @@ --color-red: hsl(0, 94%, 82%); --color-green: hsl(100, 94%, 82%); --color-yellow: hsl(41, 63%, 65%); + --color-purple: hsl(284, 94%, 82%); } * { diff --git a/crates/app/src/public/css/utility.css b/crates/app/src/public/css/utility.css index 8b84a98..3b0e005 100644 --- a/crates/app/src/public/css/utility.css +++ b/crates/app/src/public/css/utility.css @@ -179,6 +179,14 @@ color: var(--color-green) !important; } +.yellow { + color: var(--color-yellow) !important; +} + +.purple { + color: var(--color-purple) !important; +} + .hidden { display: none !important; } diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 1e482d0..ac1076a 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -115,7 +115,6 @@ (div ("style" "display: contents") (text "{{ self::post(post=post, owner=owner, secondary=secondary, community=community, show_community=show_community, can_manage_post=can_manage_post, repost=repost, expect_repost=true) }}")) - (text "{%- endmacro %} {% macro post(post, owner, question=false, secondary=false, community=false, show_community=true, can_manage_post=false, repost=false, expect_repost=false, poll=false, dont_show_title=false) -%} {% if community and show_community and community.id != config.town_square or question %}") (div ("class" "card-nest") @@ -174,7 +173,19 @@ ("class" "flex items-center") ("style" "color: var(--color-primary)") (text "{{ icon \"square-asterisk\" }}")) - (text "{%- endif %} {% if post.context.repost and post.context.repost.reposting %}") + (text "{%- endif %}") + (text "{% if community and community.is_forge -%} {% if post.is_open -%}") + (span + ("title" "Open") + ("class" "flex items-center green") + (text "{{ icon \"circle-dot\" }}")) + (text "{% else %}") + (span + ("title" "Closed") + ("class" "flex items-center purple") + (text "{{ icon \"circle-check\" }}")) + (text "{%- endif %} {%- endif %}") + (text "{% if post.context.repost and post.context.repost.reposting %}") (span ("title" "Repost") ("class" "flex items-center") @@ -215,7 +226,7 @@ ; title (text "{% if post.title and community and community.context.enable_titles -%}") (h2 (text "{{ post.title }}")) - (hr ("class" "margin")) + (hr ("class" "margin") ("style" "margin-top: var(--pad-2)")) (text "{%- endif %}") ; content @@ -323,7 +334,23 @@ (text "{{ icon \"quote\" }}") (span (text "{{ text \"communities:label.quote_post\" }}"))) - (text "{%- endif %} {% if user.id != post.owner -%}") + (text "{%- endif %}") + (text "{% if community and community.is_forge -%} {% if post.is_open -%}") + (button + ("class" "green") + ("onclick" "trigger('me::update_open', ['{{ post.id }}', false])") + (text "{{ icon \"circle-check\" }}") + (span + (text "{{ text \"forge:action.close\" }}"))) + (text "{% else %}") + (button + ("class" "purple") + ("onclick" "trigger('me::update_open', ['{{ post.id }}', true])") + (text "{{ icon \"refresh-ccw-dot\" }}") + (span + (text "{{ text \"forge:action.reopen\" }}"))) + (text "{%- endif %} {%- endif %}") + (text "{% if user.id != post.owner -%}") (b ("class" "title") (text "{{ text \"general:label.safety\" }}")) @@ -1746,3 +1773,42 @@ (text "{%- endif %}")) (text "{%- endif %}") (text "{%- endmacro %}") + +(text "{% macro ticket(post, owner) -%}") +(div + ("href" "/post/{{ post.id }}") + ("class" "card secondary w-fill flex flex-col gap-2") + (div + ("class" "flex gap-2 items-center") + ; user info + (a + ("href" "/@{{ owner.username }}") + (text "{{ self::avatar(username=owner.username, size=\"24px\", selector_type=\"username\") }}")) + (span + ("class" "name") + (text "{{ self::full_username(user=owner) }}")) + + ; timestamp + (span ("class" "date") (text "{{ post.created }}"))) + + ; post title + (a + ("href" "/post/{{ post.id }}") + ("class" "flush flex gap-2 items-center") + ; open/closed icon + (text "{% if community and community.is_forge -%} {% if post.is_open -%}") + (span + ("title" "Open") + ("class" "flex items-center green") + (text "{{ icon \"circle-dot\" }}")) + (text "{% else %}") + (span + ("title" "Closed") + ("class" "flex items-center purple") + (text "{{ icon \"circle-check\" }}")) + (text "{%- endif %} {%- endif %}") + + (h4 + ("class" "no_p_margin") + (text "{{ post.title|markdown|safe }}")))) +(text "{%- endmacro %}") diff --git a/crates/app/src/public/html/forge/info.lisp b/crates/app/src/public/html/forge/info.lisp index 4019546..df5a29b 100644 --- a/crates/app/src/public/html/forge/info.lisp +++ b/crates/app/src/public/html/forge/info.lisp @@ -3,4 +3,5 @@ ("class" "flex flex-col gap-4 w-full") (text "{{ macros::forge_nav(community=community, selected=\"info\") }}") (text "{{ components::community_info(community=community) }}")) + ; (text "{{ components::community_banner(id=community.id, community=community) }}") (text "{% endblock %}") diff --git a/crates/app/src/public/html/forge/tickets.lisp b/crates/app/src/public/html/forge/tickets.lisp new file mode 100644 index 0000000..99370a5 --- /dev/null +++ b/crates/app/src/public/html/forge/tickets.lisp @@ -0,0 +1,18 @@ +(text "{% extends \"forge/base.html\" %} {% block content %}") +(div + ("class" "flex flex-col gap-4 w-full") + (text "{{ macros::forge_nav(community=community, selected=\"tickets\") }}") + (div + ("class" "card-nest") + (div + ("class" "card small flex items-center gap-2") + (icon (text "circle-dot")) + (str (text "forge:tab.tickets"))) + + (div + ("class" "card flex flex-col gap-2") + (text "{% for post in feed -%}") + (text "{{ components::ticket(post=post[0], owner=post[1]) }}") + (text "{%- endfor %}") + (text "{{ components::pagination(page=page, items=feed|length) }}")))) +(text "{% endblock %}") diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index f88acb7..0512160 100644 --- a/crates/app/src/public/js/me.js +++ b/crates/app/src/public/js/me.js @@ -135,6 +135,31 @@ }); }); + self.define("update_open", async (_, id, status) => { + if ( + !(await trigger("atto::confirm", [ + "Are you sure you want to do this?", + ])) + ) { + return; + } + + fetch(`/api/v1/posts/${id}/open`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ open: status }), + }) + .then((res) => res.json()) + .then((res) => { + trigger("atto::toast", [ + res.ok ? "success" : "error", + res.message, + ]); + }); + }); + self.define("react", async (_, element, asset, asset_type, is_like) => { await trigger("atto::debounce", ["reactions::toggle"]); fetch("/api/v1/reactions", { diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs index f8d3351..f9139ba 100644 --- a/crates/app/src/routes/api/v1/communities/posts.rs +++ b/crates/app/src/routes/api/v1/communities/posts.rs @@ -15,7 +15,10 @@ use tetratto_core::model::{ use crate::{ get_user_from_token, image::{save_webp_buffer, JsonMultipart}, - routes::api::v1::{CreatePost, CreateRepost, UpdatePostContent, UpdatePostContext, VoteInPoll}, + routes::api::v1::{ + CreatePost, CreateRepost, UpdatePostContent, UpdatePostContext, UpdatePostIsOpen, + VoteInPoll, + }, State, }; @@ -383,3 +386,25 @@ pub async fn vote_request( Err(e) => Json(e.into()), } } + +pub async fn update_is_open_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) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_post_is_open(id, user, req.open).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Post updated".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 27c34e8..c455e25 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -121,6 +121,10 @@ pub fn routes() -> Router { "/posts/{id}/poll_vote", post(communities::posts::vote_request), ) + .route( + "/posts/{id}/open", + post(communities::posts::update_is_open_request), + ) // drafts .route("/drafts", post(communities::drafts::create_request)) .route("/drafts/{id}", delete(communities::drafts::delete_request)) @@ -639,3 +643,8 @@ pub struct VoteInPoll { pub struct AppendAssociations { pub tokens: Vec, } + +#[derive(Deserialize)] +pub struct UpdatePostIsOpen { + pub open: bool, +} diff --git a/crates/app/src/routes/pages/forge.rs b/crates/app/src/routes/pages/forge.rs index 40de554..0eadee9 100644 --- a/crates/app/src/routes/pages/forge.rs +++ b/crates/app/src/routes/pages/forge.rs @@ -1,11 +1,11 @@ -use super::{communities::community_context, render_error}; +use super::{communities::community_context, render_error, PaginatedQuery}; use crate::{ assets::initial_context, check_community_permissions, community_context_bools, get_lang, get_user_from_token, State, }; use axum::{ + extract::{Path, Query}, response::{Html, IntoResponse}, - extract::Path, Extension, }; use axum_extra::extract::CookieJar; @@ -124,3 +124,93 @@ pub async fn info_request( // return Ok(Html(data.1.render("forge/info.html", &context).unwrap())) } + +/// `/forge/{title}/tickets` +pub async fn tickets_request( + jar: CookieJar, + Path(title): Path, + Query(props): Query, + Extension(data): Extension, +) -> impl IntoResponse { + let data = data.read().await; + let user = get_user_from_token!(jar, data.0); + + let community = match data.0.get_community_by_title(&title.to_lowercase()).await { + Ok(ua) => ua, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + + if community.id == 0 { + // don't show page for void community + return Err(Html( + render_error( + Error::GeneralNotFound("community".to_string()), + &jar, + &data, + &user, + ) + .await, + )); + } + + // check permissions + let (can_read, _) = check_community_permissions!(community, jar, data, user); + + // ... + let ignore_users = crate::ignore_users_gen!(user, data); + + let feed = match data + .0 + .get_posts_by_community(community.id, 12, props.page) + .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)), + }; + + // let pinned = match data.0.get_pinned_posts_by_community(community.id).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("feed", &feed); + // context.insert("pinned", &pinned); + context.insert("page", &props.page); + 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("forge/tickets.html", &context).unwrap())) +} diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index d0ed2c0..c5e0b8f 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -114,6 +114,7 @@ pub fn routes() -> Router { // forge .route("/forges", get(forge::home_request)) .route("/forge/{title}", get(forge::info_request)) + .route("/forge/{title}/tickets", get(forge::tickets_request)) .route("/forge/{title}/members", get(communities::members_request)) // stacks .route("/stacks", get(stacks::list_request)) diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index e998103..2cabddb 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-core" -version = "5.0.0" +version = "6.0.0" edition = "2024" [features] diff --git a/crates/core/src/database/drivers/sql/create_posts.sql b/crates/core/src/database/drivers/sql/create_posts.sql index 665fa92..58c3eea 100644 --- a/crates/core/src/database/drivers/sql/create_posts.sql +++ b/crates/core/src/database/drivers/sql/create_posts.sql @@ -15,5 +15,7 @@ CREATE TABLE IF NOT EXISTS posts ( uploads TEXT NOT NULL, is_deleted INT NOT NULL, tsvector_content tsvector GENERATED ALWAYS AS (to_tsvector ('english', coalesce(content, ''))) STORED, - poll_id BIGINT NOT NULL + poll_id BIGINT NOT NULL, + title TEXT NOT NULL, + is_open INT NOT NULL DEFAULT 1 ) diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index 5292bc2..4331ac6 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -124,6 +124,7 @@ impl DataManager { // SKIP tsvector (12) poll_id: get!(x->13(i64)) as usize, title: get!(x->14(String)), + is_open: get!(x->15(i32)) as i8 == 1, } } @@ -1564,7 +1565,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO posts VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, DEFAULT, $13, $14)", + "INSERT INTO posts VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, DEFAULT, $13, $14, $15)", params![ &(data.id as i64), &(data.created as i64), @@ -1583,7 +1584,8 @@ impl DataManager { &serde_json::to_string(&data.uploads).unwrap(), &{ if data.is_deleted { 1 } else { 0 } }, &(data.poll_id as i64), - &data.title + &data.title, + &{ if data.is_open { 1 } else { 0 } }, ] ); @@ -1803,6 +1805,59 @@ impl DataManager { Ok(()) } + pub async fn update_post_is_open(&self, id: usize, user: User, is_open: bool) -> Result<()> { + let y = self.get_post_by_id(id).await?; + + // make sure this is a forge community + let community = self.get_community_by_id(y.community).await?; + + if !community.is_forge { + return Err(Error::MiscError( + "This community does not support this".to_string(), + )); + } + + // check permissions + let user_membership = self + .get_membership_by_owner_community(user.id, y.community) + .await?; + + if (user.id != y.owner) + && !user_membership + .role + .check(CommunityPermission::MANAGE_POSTS) + { + if !user.permissions.check(FinePermission::MANAGE_POSTS) { + return Err(Error::NotAllowed); + } else { + self.create_audit_log_entry(AuditLogEntry::new( + user.id, + format!("invoked `update_post_is_open` with x value `{id}`"), + )) + .await? + } + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "UPDATE posts SET is_open = $1 WHERE id = $2", + params![if is_open { 1 } else { 0 }, &(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.0.1.remove(format!("atto.post:{}", id)).await; + Ok(()) + } + pub async fn update_post_context( &self, id: usize, diff --git a/crates/core/src/model/communities.rs b/crates/core/src/model/communities.rs index ecf8ded..2015a4f 100644 --- a/crates/core/src/model/communities.rs +++ b/crates/core/src/model/communities.rs @@ -258,6 +258,8 @@ pub struct Post { pub poll_id: usize, /// The title of the post (in communities where titles are enabled). pub title: String, + /// If the post is "open". Posts can act as tickets in a forge community. + pub is_open: bool, } impl Post { @@ -284,6 +286,7 @@ impl Post { is_deleted: false, poll_id, title: String::new(), + is_open: true, } } diff --git a/crates/l10n/Cargo.toml b/crates/l10n/Cargo.toml index e8a5b4f..d8d8685 100644 --- a/crates/l10n/Cargo.toml +++ b/crates/l10n/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-l10n" -version = "5.0.0" +version = "6.0.0" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index 0db8015..da11da0 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-shared" -version = "5.0.0" +version = "6.0.0" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/sql_changes/posts_is_open.sql b/sql_changes/posts_is_open.sql new file mode 100644 index 0000000..6938410 --- /dev/null +++ b/sql_changes/posts_is_open.sql @@ -0,0 +1,2 @@ +ALTER TABLE posts +ADD COLUMN is_open INT NOT NULL DEFAULT 1;