add: forums ui

This commit is contained in:
trisua 2025-08-04 12:12:04 -04:00
parent 2be87c397d
commit 9ec52abfe4
24 changed files with 770 additions and 64 deletions

4
Cargo.lock generated
View file

@ -3318,7 +3318,7 @@ dependencies = [
[[package]]
name = "tetratto"
version = "13.0.0"
version = "14.0.0"
dependencies = [
"ammonia",
"async-stripe",
@ -3350,7 +3350,7 @@ dependencies = [
[[package]]
name = "tetratto-core"
version = "13.0.0"
version = "14.0.0"
dependencies = [
"async-recursion",
"base16ct",

View file

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

View file

@ -81,6 +81,8 @@ pub const COMMUNITIES_CREATE_POST: &str =
include_str!("./public/html/communities/create_post.lisp");
pub const COMMUNITIES_QUESTION: &str = include_str!("./public/html/communities/question.lisp");
pub const COMMUNITIES_QUESTIONS: &str = include_str!("./public/html/communities/questions.lisp");
pub const COMMUNITIES_TOPICS: &str = include_str!("./public/html/communities/topics.lisp");
pub const COMMUNITIES_TOPIC: &str = include_str!("./public/html/communities/topic.lisp");
pub const POST_POST: &str = include_str!("./public/html/post/post.lisp");
pub const POST_REPOSTS: &str = include_str!("./public/html/post/reposts.lisp");
@ -316,6 +318,8 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
write_template!(html_path->"communities/create_post.html"(crate::assets::COMMUNITIES_CREATE_POST) --config=config --lisp plugins);
write_template!(html_path->"communities/question.html"(crate::assets::COMMUNITIES_QUESTION) --config=config --lisp plugins);
write_template!(html_path->"communities/questions.html"(crate::assets::COMMUNITIES_QUESTIONS) --config=config --lisp plugins);
write_template!(html_path->"communities/topics.html"(crate::assets::COMMUNITIES_TOPICS) --config=config --lisp plugins);
write_template!(html_path->"communities/topic.html"(crate::assets::COMMUNITIES_TOPIC) --config=config --lisp plugins);
write_template!(html_path->"post/post.html"(crate::assets::POST_POST) -d "post" --config=config --lisp plugins);
write_template!(html_path->"post/reposts.html"(crate::assets::POST_REPOSTS) --config=config --lisp plugins);

View file

@ -37,6 +37,7 @@ version = "1.0.0"
"general:label.account" = "Account"
"general:label.safety" = "Safety"
"general:label.share" = "Share"
"general:label.edit" = "Edit"
"general:action.add_account" = "Add account"
"general:action.switch_account" = "Switch account"
"general:label.mod" = "Mod"
@ -102,6 +103,9 @@ version = "1.0.0"
"communities:action.select" = "Select"
"communities:label.create_new" = "Create new community"
"communities:label.name" = "Name"
"communities:label.description" = "Description"
"communities:label.color" = "Color"
"communities:label.position" = "Position"
"communities:label.my_communities" = "My communities"
"communities:label.popular_communities" = "Popular communities"
"communities:action.join" = "Join"
@ -112,6 +116,7 @@ version = "1.0.0"
"communities:label.content" = "Content"
"communities:label.title" = "Title"
"communities:label.posts" = "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"
"communities:label.might_need_to_join" = "You might need to join this community in order to interact with it!"
@ -129,6 +134,7 @@ version = "1.0.0"
"communities:label.change_title" = "Change title"
"communities:label.new_title" = "New title"
"communities:label.pinned" = "Pinned"
"communities:label.stickied" = "Stickied"
"communities:label.edit_content" = "Edit content"
"communities:label.repost" = "Repost"
"communities:label.quote_post" = "Quote post"
@ -149,6 +155,8 @@ version = "1.0.0"
"communities:label.load" = "Load"
"communities:action.draw" = "Draw"
"communities:action.remove_drawing" = "Remove drawing"
"communities:tab.topics" = "Topics"
"communities:action.create_topic" = "Create topic"
"notifs:action.mark_as_read" = "Mark as read"
"notifs:action.mark_as_unread" = "Mark as unread"

View file

@ -83,6 +83,10 @@
--size-formula: clamp(24px, calc(var(--size) * 0.75), 64px);
}
.smaller_avatar .avatar {
--size-formula: clamp(18px, calc(var(--size) * 0.75), 64px);
}
textarea {
min-height: 12rem !important;
}

View file

@ -163,7 +163,8 @@
(text "{{ text \"communities:action.create\" }}"))))))
(text "{% if not quoting -%}")
(script
(text "async function create_post_from_form(e) {
(text "globalThis.SEARCH_PARAMS = new URLSearchParams(window.location.search);
async function create_post_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"posts::create\"]);
@ -204,6 +205,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\",
poll: poll_data[1],
title: e.target.title.value,
}),
@ -457,7 +459,7 @@
check_community_supports_title({
target: document.getElementById(\"community_to_post_to\"),
});
}, 150);
}, 250);
window.cancel_create_post = async () => {
if (

View file

@ -23,5 +23,4 @@
(div
("class" "card flex flex_col gap_4")
(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 %}")

View file

@ -6,7 +6,9 @@
(main
("class" "flex flex_col gap_2")
(div
("class" "pillmenu")
("class" "pillmenu rows w-full")
(div
("class" "row")
(a
("href" "#/general")
("data-tab-button" "general")
@ -25,7 +27,9 @@
("data-tab-button" "members")
(text "{{ icon \"users-round\" }}")
(span
(text "{{ text \"communities:tab.members\" }}")))
(text "{{ text \"communities:tab.members\" }}"))))
(div
("class" "row")
(text "{% if can_manage_channels -%}")
(a
("href" "#/channels")
@ -33,6 +37,13 @@
(text "{{ icon \"rss\" }}")
(span
(text "{{ text \"communities:tab.channels\" }}")))
(text "{%- endif %} {% if community.is_forum -%}")
(a
("href" "#/topics")
("data-tab-button" "topics")
(icon (text "list"))
(span
(str (text "communities:tab.topics"))))
(text "{%- endif %} {% if can_manage_emojis -%}")
(a
("href" "#/emojis")
@ -40,7 +51,7 @@
(text "{{ icon \"smile\" }}")
(span
(text "{{ text \"communities:tab.emojis\" }}")))
(text "{%- endif %}"))
(text "{%- endif %}")))
(div
("class" "w_full flex flex_col gap_2")
("data-tab" "general")
@ -564,6 +575,235 @@
]);
});
};"))
(text "{%- endif %}")
(text "{% if community.is_forum -%}")
(script ("type" "application/json") ("id" "community_topics") (text "{{ community.topics | json_encode() | safe }}"))
(div
("class" "card lowered w_full hidden flex flex_col gap_2")
("data-tab" "topics")
(div
("class" "card_nest")
(div
("class" "card small")
(b
(str (text "communities:action.create_topic"))))
(form
("class" "card flex flex_col gap_2")
("onsubmit" "create_topic_from_form(event)")
(div
("class" "flex flex_col gap_1")
(label
("for" "title")
(text "{{ text \"communities:label.name\" }}"))
(input
("type" "text")
("name" "title")
("id" "title")
("placeholder" "name")
("required" "")
("minlength" "2")
("maxlength" "32")))
(div
("class" "flex flex_col gap_1")
(label
("for" "description")
(str (text "communities:label.description")))
(input
("type" "text")
("name" "description")
("id" "description")
("placeholder" "description")
("required" "")
("minlength" "2")
("maxlength" "256")))
(div
("class" "flex flex_col gap_1")
(label
("for" "color")
(str (text "communities:label.color")))
(input
("type" "color")
("name" "color")
("id" "color")
("placeholder" "color")
("required" "")
("style" "width: 8rem")))
(div
("class" "flex flex_col gap_1")
(label
("for" "position")
(str (text "communities:label.position")))
(input
("type" "number")
("name" "position")
("id" "position")
("placeholder" "position")
("required" "")
("value" "0")
("min" "0")
("max" "256")))
(button
(text "{{ text \"communities:action.create\" }}"))))
(text "{% for id, topic in community.topics %}")
(div
("class" "card_nest")
(div
("class" "card small flex justify_between gap_2")
(div
("class" "flex gap_2")
(b
(text "{{ topic.position }} "))
(text "{{ topic.title }}"))
(button
("class" "red lowered small")
("onclick" "delete_topic('{{ id }}')")
(icon (text "trash"))
(str (text "general:action.delete"))))
(div
("class" "card flex flex_col gap_2")
(details
("class" "accordion")
(summary ("class" "flex items_center gap_2") (icon (text "pencil")) (str (text "general:label.edit")))
(form
("class" "inner flex flex_col gap_2")
("style" "background: var(--color-super-raised)")
("onsubmit" "update_topic_from_form(event, '{{ id }}')")
(div
("class" "flex flex_col gap_1")
(label
("for" "title")
(text "{{ text \"communities:label.name\" }}"))
(input
("type" "text")
("name" "title")
("id" "title")
("placeholder" "name")
("value" "{{ topic.title }}")
("required" "")
("minlength" "2")
("maxlength" "32")))
(div
("class" "flex flex_col gap_1")
(label
("for" "description")
(str (text "communities:label.description")))
(input
("type" "text")
("name" "description")
("id" "description")
("placeholder" "description")
("value" "{{ topic.description }}")
("required" "")
("minlength" "2")
("maxlength" "256")))
(div
("class" "flex flex_col gap_1")
(label
("for" "color")
(str (text "communities:label.color")))
(input
("type" "color")
("name" "color")
("id" "color")
("placeholder" "color")
("required" "")
("value" "{{ topic.color }}")
("style" "width: 8rem")))
(div
("class" "flex flex_col gap_1")
(label
("for" "position")
(str (text "communities:label.position")))
(input
("type" "number")
("name" "position")
("id" "position")
("placeholder" "position")
("required" "")
("value" "{{ topic.position }}")
("min" "0")
("max" "256")))
(button
(icon (text "check"))
(str (text "general:action.save")))))))
(text "{% endfor %}"))
(script
(text "globalThis.delete_topic = async (id) => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(`/api/v1/communities/{{ community.id }}/topics/${id}`, {
method: \"DELETE\",
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
};
async function create_topic_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"topics::create\"]);
fetch(\"/api/v1/communities/{{ community.id }}/topics\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
title: e.target.title.value,
description: e.target.description.value,
color: e.target.color.value,
position: Number.parseInt(e.target.position.value),
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
e.target.reset();
window.location.reload();
}
});
}
async function update_topic_from_form(e, id) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"topics::update\"]);
fetch(`/api/v1/communities/{{ community.id }}/topics/${id}`, {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
title: e.target.title.value,
description: e.target.description.value,
color: e.target.color.value,
position: Number.parseInt(e.target.position.value),
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
});
}"))
(text "{%- endif %}"))
(script

View file

@ -0,0 +1,42 @@
(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\") }}")
(div
("class" "card_nest")
(div
("class" "card small flex justify_between gap_2")
(text "{{ components::topic_display(id=topic_id, topic=topic, community=community, show_description=false) }}")
(div
("class" "flex gap_2")
(a
("href" "/communities/intents/post?community={{ community.id }}&topic={{ topic_id }}")
("class" "button small lowered")
("data-turbo" "false")
(icon (text "plus"))
(span
(str (text "general:action.post"))))
(a
("href" "/community/{{ community.title }}")
("class" "button lowered small")
(icon (text "arrow-left"))
(str (text "general:action.back")))))
(div
("class" "card flex flex_col gap_4")
(span ("class" "no_p_margin") (text "{{ topic.description|markdown|safe }}"))
(hr)
(div
("class" "w_full")
("style" "overflow: auto")
(table
("class" "w_full")
(thead
(th (text "Title"))
(th (text "Replies"))
(th (text "Score"))
(th (text "Created")))
(tbody
(text "{% for post in pinned %} {{ components::topic_post_display(post=post[0], owner=post[1], is_pinned=true) }} {% endfor %}")
(text "{% for post in feed %} {{ components::topic_post_display(post=post[0], owner=post[1]) }} {% endfor %}"))))
(text "{{ components::pagination(page=page, items=feed|length) }}"))))
(text "{% endblock %}")

View file

@ -0,0 +1,19 @@
(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\") }}")
(div
("class" "card_nest")
(div
("class" "card small flex gap_2 items_center")
(icon (text "list"))
(span
(str (text "communities:label.topics"))))
(div
("class" "card flex flex_col gap_4")
(text "{% for topic in topics_sorted %}")
(div
("class" "card lowered w_full flex flex_col gap_2")
(text "{{ components::topic_display(id=topic[0], topic=topic[1], community=community) }}"))
(text "{% endfor %}"))))
(text "{% endblock %}")

View file

@ -543,8 +543,14 @@
(text "{{ self::avatar(username=owner.username, size=\"24px\", selector_type=\"username\") }}"))
(span
("class" "name")
(text "{{ self::full_username(user=owner) }}")))
(text "{{ self::post_info(post=post, community=community) }}"))
(text "{{ self::full_username(user=owner) }}"))
(a
("href" "/community/{{ community.title }}/topic/{{ post.topic }}")
("class" "flush flex gap_1 items_center smaller_avatar")
(text "{{ self::community_avatar(id=post.community, community=community, size=\"18px\") }}")))
(div
("class" "flex gap_2")
(text "{{ self::post_info(post=post, community=community) }}")))
(div
("class" "card_nest_horizontal")
; author info
@ -2066,6 +2072,7 @@
(text "{{ icon \"message-circle\" }}")
(span
(text "{{ text \"communities:label.chats\" }}")))
(text "{% if not community.is_forum -%}")
(a
("href" "/communities/intents/post?community={{ community.id }}")
("class" "button lowered")
@ -2073,6 +2080,7 @@
(text "{{ icon \"plus\" }}")
(span
(text "{{ text \"general:action.post\" }}")))
(text "{%- endif %}")
(text "{%- endif %} {% if can_manage_community or is_manager -%}")
(a
("href" "/community/{{ community.id }}/manage")
@ -2606,3 +2614,39 @@
(icon (text "trash")))
(text "{%- endif %}"))))
(text "{%- endmacro %}")
(text "{% macro topic_display(id, topic, community, show_description=true) -%}")
(div
("class" "flex items_center gap_2")
(svg
("width" "12")
("height" "12")
("viewBox" "0 0 12 12")
("style" "fill: {% if topic.color == \"#000000\" -%} var(--color-primary) {%- else -%} {{ topic.color }} {%- endif %}; margin-top: 3.5px")
(circle
("cx" "6")
("cy" "6")
("r" "6")))
(a
("href" "/community/{{ community.title }}/topic/{{ id }}")
("class" "flush")
(b (text "{{ topic.title }}"))))
(text "{% if show_description -%}")
(span ("class" "no_p_margin") (text "{{ topic.description|markdown|safe }}"))
(text "{%- endif %}")
(text "{%- endmacro %}")
(text "{% macro topic_post_display(post, owner, is_pinned=false) -%}")
(tr
(td
("class" "flex gap_1")
(a
("href" "/post/{{ post.id }}")
(text "{% if is_pinned -%}Sticky: {% endif %}")
(text "{{ post.title }}"))
(span ("class" "fade") (text "by"))
(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 }}"))))
(text "{%- endmacro %}")

View file

@ -354,6 +354,7 @@
content: e.target.content.value,
community: \"{{ community.id }}\",
stack: \"{{ post.stack }}\",
topic: \"{{ post.topic }}\",
replying_to: \"{{ post.id }}\",
poll: poll_data[1],
}),

View file

@ -153,7 +153,8 @@ media_theme_pref();
.replaceAll(" months ago", "m")
.replaceAll(" month ago", "m")
.replaceAll(" years ago", "y")
.replaceAll(" year ago", "y");
.replaceAll(" year ago", "y")
.replaceAll("just now", "now");
}
element.innerText = !pretty ? then.toLocaleDateString() : pretty;
@ -194,7 +195,8 @@ media_theme_pref();
.replaceAll(" month ago", "m")
.replaceAll(" years ago", "y")
.replaceAll(" year ago", "y")
.replaceAll("Yesterday", "1d") || "";
.replaceAll("Yesterday", "1d")
.replaceAll("just now", "now") || "";
element.innerText = !pretty ? then.toLocaleDateString() : pretty;
element.style.display = "inline-block";

View file

@ -538,7 +538,7 @@ pub async fn add_topic_request(
None => return Json(Error::NotAllowed.into()),
};
let mut community = match data.get_community_by_id(id).await {
let mut community = match data.get_community_by_id_no_void(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
@ -547,8 +547,22 @@ pub async fn add_topic_request(
return Json(Error::DoesNotSupportField("community".to_string()).into());
}
let (id, topic) = ForumTopic::new(req.title, req.description, req.color);
community.topics.insert(id, topic);
// check lengths
if req.title.len() > 32 {
return Json(Error::DataTooLong("title".to_string()).into());
}
if req.title.len() < 2 {
return Json(Error::DataTooShort("title".to_string()).into());
}
if req.description.len() > 256 {
return Json(Error::DataTooLong("description".to_string()).into());
}
// ...
let (topic_id, topic) = ForumTopic::new(req.title, req.description, req.color, req.position);
community.topics.insert(topic_id, topic);
match data
.update_community_topics(id, &user, community.topics)
@ -575,7 +589,7 @@ pub async fn update_topic_request(
None => return Json(Error::NotAllowed.into()),
};
let mut community = match data.get_community_by_id(id).await {
let mut community = match data.get_community_by_id_no_void(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
@ -584,10 +598,25 @@ pub async fn update_topic_request(
return Json(Error::DoesNotSupportField("community".to_string()).into());
}
// check lengths
if req.title.len() > 32 {
return Json(Error::DataTooLong("title".to_string()).into());
}
if req.title.len() < 2 {
return Json(Error::DataTooShort("title".to_string()).into());
}
if req.description.len() > 256 {
return Json(Error::DataTooLong("description".to_string()).into());
}
// ...
let topic = ForumTopic {
title: req.title,
description: req.description,
color: req.color,
position: req.position,
};
community.topics.insert(topic_id, topic);
@ -616,7 +645,7 @@ pub async fn delete_topic_request(
None => return Json(Error::NotAllowed.into()),
};
let mut community = match data.get_community_by_id(id).await {
let mut community = match data.get_community_by_id_no_void(id).await {
Ok(x) => x,
Err(e) => return Json(e.into()),
};
@ -631,11 +660,14 @@ pub async fn delete_topic_request(
.update_community_topics(id, &user, community.topics)
.await
{
Ok(_) => match data.delete_topic_posts(id, topic_id).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Community updated".to_string(),
payload: (),
}),
Err(e) => Json(e.into()),
},
Err(e) => Json(e.into()),
}
}

View file

@ -129,6 +129,10 @@ pub async fn create_request(
Ok(x) => x,
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
};
props.topic = match req.topic.parse::<usize>() {
Ok(x) => x,
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
};
}
// check sizes

View file

@ -795,6 +795,8 @@ pub struct AddTopic {
pub title: String,
pub description: String,
pub color: String,
#[serde(default)]
pub position: i32,
}
#[derive(Deserialize)]
@ -821,6 +823,8 @@ pub struct CreatePost {
pub title: String,
#[serde(default)]
pub stack: String,
#[serde(default)]
pub topic: String,
}
#[derive(Deserialize)]

View file

@ -16,7 +16,7 @@ use tera::Context;
use tetratto_core::model::{
addr::RemoteAddr,
auth::User,
communities::Community,
communities::{Community, ForumTopic},
communities_permissions::CommunityPermission,
permissions::FinePermission,
stacks::{StackMode, UserStack},
@ -424,6 +424,47 @@ pub async fn feed_request(
// check permissions
let (can_read, _) = check_community_permissions!(community, jar, data, user);
// is this is a forum, just show topics
if community.is_forum {
// 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);
let mut sorted: Vec<(&usize, &ForumTopic)> = community.topics.iter().collect();
sorted.sort_by(|x, y| x.1.position.cmp(&y.1.position));
context.insert("topics_sorted", &sorted);
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
return Ok(Html(
data.1.render("communities/topics.html", &context).unwrap(),
));
}
// ...
let ignore_users = crate::ignore_users_gen!(user, data);
@ -485,6 +526,119 @@ pub async fn feed_request(
))
}
/// `/community/{title}/topic/{id}`
pub async fn topic_feed_request(
jar: CookieJar,
Path((title, topic_id)): Path<(String, usize)>,
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,
));
}
let topic = match community.topics.get(&topic_id) {
Some(x) => x,
None => {
return Err(Html(
render_error(
Error::GeneralNotFound("topic".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_topic(community.id, topic_id, 12, props.page, &user)
.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_topic(community.id, topic_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);
context.insert("topic", &topic);
context.insert("topic_id", &topic_id);
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("communities/topic.html", &context).unwrap(),
))
}
/// `/community/{title}/questions`
pub async fn questions_request(
jar: CookieJar,

View file

@ -99,6 +99,10 @@ pub fn routes() -> Router {
get(communities::create_post_request),
)
.route("/community/{title}", get(communities::feed_request))
.route(
"/community/{title}/topic/{id}",
get(communities::topic_feed_request),
)
.route(
"/community/{title}/questions",
get(communities::questions_request),

View file

@ -1,7 +1,7 @@
[package]
name = "tetratto-core"
description = "The core behind Tetratto"
version = "13.0.0"
version = "14.0.0"
edition = "2024"
authors.workspace = true
repository.workspace = true
@ -9,7 +9,13 @@ license.workspace = true
homepage.workspace = true
[features]
database = ["dep:oiseau", "dep:base64", "dep:base16ct", "dep:async-recursion", "dep:md-5"]
database = [
"dep:oiseau",
"dep:base64",
"dep:base16ct",
"dep:async-recursion",
"dep:md-5",
]
types = ["dep:totp-rs", "dep:paste", "dep:bitflags"]
sdk = ["types", "dep:reqwest"]
default = ["database", "types", "sdk"]
@ -21,8 +27,14 @@ toml = "0.9.4"
tetratto-shared = { version = "12.0.6", path = "../shared" }
tetratto-l10n = { version = "12.0.0", path = "../l10n" }
serde_json = "1.0.142"
totp-rs = { version = "5.7.0", features = ["qr", "gen_secret"], optional = true }
reqwest = { version = "0.12.22", features = ["json", "multipart"], optional = true }
totp-rs = { version = "5.7.0", features = [
"qr",
"gen_secret",
], optional = true }
reqwest = { version = "0.12.22", features = [
"json",
"multipart",
], optional = true }
bitflags = { version = "2.9.1", optional = true }
async-recursion = { version = "1.1.1", optional = true }
md-5 = { version = "0.10.6", optional = true }

View file

@ -532,6 +532,25 @@ impl DataManager {
Ok(())
}
pub async fn delete_topic_posts(&self, id: usize, topic: usize) -> Result<()> {
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 posts WHERE community = $1 AND topic = $2",
params![&(id as i64), &(topic as i64)]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
Ok(())
}
auto_method!(update_community_context(CommunityContext)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET context = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
auto_method!(update_community_read_access(CommunityReadAccess)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET read_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
auto_method!(update_community_write_access(CommunityWriteAccess)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET write_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);

View file

@ -18,5 +18,6 @@ CREATE TABLE IF NOT EXISTS posts (
poll_id BIGINT NOT NULL,
title TEXT NOT NULL,
is_open INT NOT NULL DEFAULT 1,
circle BIGINT NOT NULL
stack BIGINT NOT NULL,
topic BIGINT NOT NULL
)

View file

@ -21,3 +21,7 @@ ADD COLUMN IF NOT EXISTS is_forum INT DEFAULT 0;
-- communities topics
ALTER TABLE communities
ADD COLUMN IF NOT EXISTS topics TEXT DEFAULT '{}';
-- posts topic
ALTER TABLE posts
ADD COLUMN IF NOT EXISTS topic BIGINT DEFAULT 0;

View file

@ -116,6 +116,7 @@ impl DataManager {
title: get!(x->14(String)),
is_open: get!(x->15(i32)) as i8 == 1,
stack: get!(x->16(i64)) as usize,
topic: get!(x->17(i64)) as usize,
}
}
@ -1209,6 +1210,60 @@ impl DataManager {
Ok(res.unwrap())
}
/// Get all posts from the given community and topic (from most recent).
///
/// # Arguments
/// * `id` - the ID of the community the requested posts belong to
/// * `topic` - the ID of the topic the requested posts belong to
/// * `batch` - the limit of posts in each page
/// * `page` - the page number
pub async fn get_posts_by_community_topic(
&self,
id: usize,
topic: usize,
batch: usize,
page: usize,
user: &Option<User>,
) -> Result<Vec<Post>> {
let conn = match self.0.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
// check if we should hide nsfw posts
let mut hide_nsfw: bool = true;
if let Some(ua) = user {
hide_nsfw = !ua.settings.show_nsfw;
}
// ...
let res = query_rows!(
&conn,
&format!(
"SELECT * FROM posts WHERE community = $1 AND topic = $2 AND replying_to = 0 AND NOT context LIKE '%\"is_pinned\":true%' AND is_deleted = 0 {} ORDER BY created DESC LIMIT $3 OFFSET $4",
if hide_nsfw {
"AND NOT (context::json->>'is_nsfw')::boolean"
} else {
""
}
),
&[
&(id as i64),
&(topic as i64),
&(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 all posts from the given stack (from most recent).
///
/// # Arguments
@ -1264,6 +1319,35 @@ impl DataManager {
Ok(res.unwrap())
}
/// Get all pinned posts from the given community (from most recent).
///
/// # Arguments
/// * `id` - the ID of the community the requested posts belong to
/// * `topic` - the ID of the topic the requested posts belong to
pub async fn get_pinned_posts_by_community_topic(
&self,
id: usize,
topic: usize,
) -> Result<Vec<Post>> {
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 posts WHERE community = $1 AND topic = $2 AND context LIKE '%\"is_pinned\":true%' ORDER BY created DESC",
&[&(id as i64), &(topic as i64)],
|x| { Self::get_post_from_row(x) }
);
if res.is_err() {
return Err(Error::GeneralNotFound("post".to_string()));
}
Ok(res.unwrap())
}
/// Get all pinned posts from the given user (from most recent).
///
/// # Arguments
@ -1494,7 +1578,7 @@ impl DataManager {
let res = query_rows!(
&conn,
&format!(
"SELECT * FROM posts WHERE replying_to = 0{}{}{} AND NOT context LIKE '%\"full_unlist\":true%' ORDER BY created DESC LIMIT $1 OFFSET $2",
"SELECT * FROM posts WHERE replying_to = 0{}{}{} AND NOT context LIKE '%\"full_unlist\":true%' AND topic = 0 ORDER BY created DESC LIMIT $1 OFFSET $2",
if before_time > 0 {
format!(" AND created < {before_time}")
} else {
@ -1748,6 +1832,20 @@ impl DataManager {
self.get_community_by_id(data.community).await?
};
// check is_forum
if community.is_forum {
if data.topic == 0 {
return Err(Error::MiscError(
"Topic is required for this community".to_string(),
));
}
if community.topics.get(&data.topic).is_none() {
return Err(Error::GeneralNotFound("topic".to_string()));
}
}
// ...
let mut owner = self.get_user_by_id(data.owner).await?;
// check values (if this isn't reposting something else)
@ -2019,7 +2117,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, $15, $16)",
"INSERT INTO posts VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, DEFAULT, $13, $14, $15, $16, $17)",
params![
&(data.id as i64),
&(data.created as i64),
@ -2041,6 +2139,7 @@ impl DataManager {
&data.title,
&{ if data.is_open { 1 } else { 0 } },
&(data.stack as i64),
&(data.topic as i64),
]
);

View file

@ -280,6 +280,11 @@ pub struct Post {
///
/// If stack is not 0, community should be 0 (and vice versa).
pub stack: usize,
/// The ID of the topic this post belongs to. 0 means no topic is connected.
///
/// This can only be set if the post is created in a community with `is_forum: true`,
/// where this is also a required field.
pub topic: usize,
}
impl Post {
@ -308,6 +313,7 @@ impl Post {
title: String::new(),
is_open: true,
stack: 0,
topic: 0,
}
}
@ -534,6 +540,7 @@ pub struct ForumTopic {
pub title: String,
pub description: String,
pub color: String,
pub position: i32,
}
impl ForumTopic {
@ -542,13 +549,14 @@ impl ForumTopic {
/// # Returns
/// * ID for [`Community`] hashmap
/// * [`ForumTopic`]
pub fn new(title: String, description: String, color: String) -> (usize, Self) {
pub fn new(title: String, description: String, color: String, position: i32) -> (usize, Self) {
(
Snowflake::new().to_string().parse::<usize>().unwrap(),
Self {
title,
description,
color,
position,
},
)
}