add: finish forge stuff

This commit is contained in:
trisua 2025-06-10 22:02:06 -04:00
parent 53fb4d5778
commit 68071b96c8
21 changed files with 329 additions and 18 deletions

8
Cargo.lock generated
View file

@ -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",

View file

@ -1,6 +1,6 @@
[package]
name = "tetratto"
version = "5.0.0"
version = "6.0.0"
edition = "2024"
[features]

View file

@ -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
}

View file

@ -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"

View file

@ -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%);
}
* {

View file

@ -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;
}

View file

@ -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 %}")

View file

@ -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 %}")

View file

@ -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 %}")

View file

@ -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", {

View file

@ -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<State>,
Path(id): Path<usize>,
Json(req): Json<UpdatePostIsOpen>,
) -> 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()),
}
}

View file

@ -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<String>,
}
#[derive(Deserialize)]
pub struct UpdatePostIsOpen {
pub open: bool,
}

View file

@ -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<String>,
Query(props): Query<PaginatedQuery>,
Extension(data): Extension<State>,
) -> 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()))
}

View file

@ -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))

View file

@ -1,6 +1,6 @@
[package]
name = "tetratto-core"
version = "5.0.0"
version = "6.0.0"
edition = "2024"
[features]

View file

@ -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
)

View file

@ -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,

View file

@ -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,
}
}

View file

@ -1,6 +1,6 @@
[package]
name = "tetratto-l10n"
version = "5.0.0"
version = "6.0.0"
edition = "2024"
authors.workspace = true
repository.workspace = true

View file

@ -1,6 +1,6 @@
[package]
name = "tetratto-shared"
version = "5.0.0"
version = "6.0.0"
edition = "2024"
authors.workspace = true
repository.workspace = true

View file

@ -0,0 +1,2 @@
ALTER TABLE posts
ADD COLUMN is_open INT NOT NULL DEFAULT 1;