add: forums ui
This commit is contained in:
parent
2be87c397d
commit
9ec52abfe4
24 changed files with 770 additions and 64 deletions
4
Cargo.lock
generated
4
Cargo.lock
generated
|
@ -3318,7 +3318,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tetratto"
|
name = "tetratto"
|
||||||
version = "13.0.0"
|
version = "14.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ammonia",
|
"ammonia",
|
||||||
"async-stripe",
|
"async-stripe",
|
||||||
|
@ -3350,7 +3350,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tetratto-core"
|
name = "tetratto-core"
|
||||||
version = "13.0.0"
|
version = "14.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-recursion",
|
"async-recursion",
|
||||||
"base16ct",
|
"base16ct",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "tetratto"
|
name = "tetratto"
|
||||||
version = "13.0.0"
|
version = "14.0.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
|
@ -81,6 +81,8 @@ pub const COMMUNITIES_CREATE_POST: &str =
|
||||||
include_str!("./public/html/communities/create_post.lisp");
|
include_str!("./public/html/communities/create_post.lisp");
|
||||||
pub const COMMUNITIES_QUESTION: &str = include_str!("./public/html/communities/question.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_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_POST: &str = include_str!("./public/html/post/post.lisp");
|
||||||
pub const POST_REPOSTS: &str = include_str!("./public/html/post/reposts.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/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/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/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/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);
|
write_template!(html_path->"post/reposts.html"(crate::assets::POST_REPOSTS) --config=config --lisp plugins);
|
||||||
|
|
|
@ -37,6 +37,7 @@ version = "1.0.0"
|
||||||
"general:label.account" = "Account"
|
"general:label.account" = "Account"
|
||||||
"general:label.safety" = "Safety"
|
"general:label.safety" = "Safety"
|
||||||
"general:label.share" = "Share"
|
"general:label.share" = "Share"
|
||||||
|
"general:label.edit" = "Edit"
|
||||||
"general:action.add_account" = "Add account"
|
"general:action.add_account" = "Add account"
|
||||||
"general:action.switch_account" = "Switch account"
|
"general:action.switch_account" = "Switch account"
|
||||||
"general:label.mod" = "Mod"
|
"general:label.mod" = "Mod"
|
||||||
|
@ -102,6 +103,9 @@ version = "1.0.0"
|
||||||
"communities:action.select" = "Select"
|
"communities:action.select" = "Select"
|
||||||
"communities:label.create_new" = "Create new community"
|
"communities:label.create_new" = "Create new community"
|
||||||
"communities:label.name" = "Name"
|
"communities:label.name" = "Name"
|
||||||
|
"communities:label.description" = "Description"
|
||||||
|
"communities:label.color" = "Color"
|
||||||
|
"communities:label.position" = "Position"
|
||||||
"communities:label.my_communities" = "My communities"
|
"communities:label.my_communities" = "My communities"
|
||||||
"communities:label.popular_communities" = "Popular communities"
|
"communities:label.popular_communities" = "Popular communities"
|
||||||
"communities:action.join" = "Join"
|
"communities:action.join" = "Join"
|
||||||
|
@ -112,6 +116,7 @@ version = "1.0.0"
|
||||||
"communities:label.content" = "Content"
|
"communities:label.content" = "Content"
|
||||||
"communities:label.title" = "Title"
|
"communities:label.title" = "Title"
|
||||||
"communities:label.posts" = "Posts"
|
"communities:label.posts" = "Posts"
|
||||||
|
"communities:label.topics" = "Topics"
|
||||||
"communities:label.questions" = "Questions"
|
"communities:label.questions" = "Questions"
|
||||||
"communities:label.not_allowed_to_read" = "You're not allowed to view this community's posts"
|
"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!"
|
"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.change_title" = "Change title"
|
||||||
"communities:label.new_title" = "New title"
|
"communities:label.new_title" = "New title"
|
||||||
"communities:label.pinned" = "Pinned"
|
"communities:label.pinned" = "Pinned"
|
||||||
|
"communities:label.stickied" = "Stickied"
|
||||||
"communities:label.edit_content" = "Edit content"
|
"communities:label.edit_content" = "Edit content"
|
||||||
"communities:label.repost" = "Repost"
|
"communities:label.repost" = "Repost"
|
||||||
"communities:label.quote_post" = "Quote post"
|
"communities:label.quote_post" = "Quote post"
|
||||||
|
@ -149,6 +155,8 @@ version = "1.0.0"
|
||||||
"communities:label.load" = "Load"
|
"communities:label.load" = "Load"
|
||||||
"communities:action.draw" = "Draw"
|
"communities:action.draw" = "Draw"
|
||||||
"communities:action.remove_drawing" = "Remove drawing"
|
"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_read" = "Mark as read"
|
||||||
"notifs:action.mark_as_unread" = "Mark as unread"
|
"notifs:action.mark_as_unread" = "Mark as unread"
|
||||||
|
|
|
@ -83,6 +83,10 @@
|
||||||
--size-formula: clamp(24px, calc(var(--size) * 0.75), 64px);
|
--size-formula: clamp(24px, calc(var(--size) * 0.75), 64px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.smaller_avatar .avatar {
|
||||||
|
--size-formula: clamp(18px, calc(var(--size) * 0.75), 64px);
|
||||||
|
}
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
min-height: 12rem !important;
|
min-height: 12rem !important;
|
||||||
}
|
}
|
||||||
|
|
|
@ -163,7 +163,8 @@
|
||||||
(text "{{ text \"communities:action.create\" }}"))))))
|
(text "{{ text \"communities:action.create\" }}"))))))
|
||||||
(text "{% if not quoting -%}")
|
(text "{% if not quoting -%}")
|
||||||
(script
|
(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();
|
e.preventDefault();
|
||||||
await trigger(\"atto::debounce\", [\"posts::create\"]);
|
await trigger(\"atto::debounce\", [\"posts::create\"]);
|
||||||
|
|
||||||
|
@ -204,6 +205,7 @@
|
||||||
content: e.target.content.value,
|
content: e.target.content.value,
|
||||||
community: !is_selected_stack ? selected_community : \"0\",
|
community: !is_selected_stack ? selected_community : \"0\",
|
||||||
stack: 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],
|
poll: poll_data[1],
|
||||||
title: e.target.title.value,
|
title: e.target.title.value,
|
||||||
}),
|
}),
|
||||||
|
@ -457,7 +459,7 @@
|
||||||
check_community_supports_title({
|
check_community_supports_title({
|
||||||
target: document.getElementById(\"community_to_post_to\"),
|
target: document.getElementById(\"community_to_post_to\"),
|
||||||
});
|
});
|
||||||
}, 150);
|
}, 250);
|
||||||
|
|
||||||
window.cancel_create_post = async () => {
|
window.cancel_create_post = async () => {
|
||||||
if (
|
if (
|
||||||
|
|
|
@ -23,5 +23,4 @@
|
||||||
(div
|
(div
|
||||||
("class" "card flex flex_col gap_4")
|
("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 "{% 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 %}")
|
(text "{% endblock %}")
|
||||||
|
|
|
@ -6,41 +6,52 @@
|
||||||
(main
|
(main
|
||||||
("class" "flex flex_col gap_2")
|
("class" "flex flex_col gap_2")
|
||||||
(div
|
(div
|
||||||
("class" "pillmenu")
|
("class" "pillmenu rows w-full")
|
||||||
(a
|
(div
|
||||||
("href" "#/general")
|
("class" "row")
|
||||||
("data-tab-button" "general")
|
(a
|
||||||
("class" "active")
|
("href" "#/general")
|
||||||
(text "{{ icon \"settings\" }}")
|
("data-tab-button" "general")
|
||||||
(span
|
("class" "active")
|
||||||
(text "{{ text \"settings:tab.general\" }}")))
|
(text "{{ icon \"settings\" }}")
|
||||||
(a
|
(span
|
||||||
("href" "#/images")
|
(text "{{ text \"settings:tab.general\" }}")))
|
||||||
("data-tab-button" "images")
|
(a
|
||||||
(text "{{ icon \"image\" }}")
|
("href" "#/images")
|
||||||
(span
|
("data-tab-button" "images")
|
||||||
(text "{{ text \"settings:tab.images\" }}")))
|
(text "{{ icon \"image\" }}")
|
||||||
(a
|
(span
|
||||||
("href" "#/members")
|
(text "{{ text \"settings:tab.images\" }}")))
|
||||||
("data-tab-button" "members")
|
(a
|
||||||
(text "{{ icon \"users-round\" }}")
|
("href" "#/members")
|
||||||
(span
|
("data-tab-button" "members")
|
||||||
(text "{{ text \"communities:tab.members\" }}")))
|
(text "{{ icon \"users-round\" }}")
|
||||||
(text "{% if can_manage_channels -%}")
|
(span
|
||||||
(a
|
(text "{{ text \"communities:tab.members\" }}"))))
|
||||||
("href" "#/channels")
|
(div
|
||||||
("data-tab-button" "channels")
|
("class" "row")
|
||||||
(text "{{ icon \"rss\" }}")
|
(text "{% if can_manage_channels -%}")
|
||||||
(span
|
(a
|
||||||
(text "{{ text \"communities:tab.channels\" }}")))
|
("href" "#/channels")
|
||||||
(text "{%- endif %} {% if can_manage_emojis -%}")
|
("data-tab-button" "channels")
|
||||||
(a
|
(text "{{ icon \"rss\" }}")
|
||||||
("href" "#/emojis")
|
(span
|
||||||
("data-tab-button" "emojis")
|
(text "{{ text \"communities:tab.channels\" }}")))
|
||||||
(text "{{ icon \"smile\" }}")
|
(text "{%- endif %} {% if community.is_forum -%}")
|
||||||
(span
|
(a
|
||||||
(text "{{ text \"communities:tab.emojis\" }}")))
|
("href" "#/topics")
|
||||||
(text "{%- endif %}"))
|
("data-tab-button" "topics")
|
||||||
|
(icon (text "list"))
|
||||||
|
(span
|
||||||
|
(str (text "communities:tab.topics"))))
|
||||||
|
(text "{%- endif %} {% if can_manage_emojis -%}")
|
||||||
|
(a
|
||||||
|
("href" "#/emojis")
|
||||||
|
("data-tab-button" "emojis")
|
||||||
|
(text "{{ icon \"smile\" }}")
|
||||||
|
(span
|
||||||
|
(text "{{ text \"communities:tab.emojis\" }}")))
|
||||||
|
(text "{%- endif %}")))
|
||||||
(div
|
(div
|
||||||
("class" "w_full flex flex_col gap_2")
|
("class" "w_full flex flex_col gap_2")
|
||||||
("data-tab" "general")
|
("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 %}"))
|
(text "{%- endif %}"))
|
||||||
|
|
||||||
(script
|
(script
|
||||||
|
|
42
crates/app/src/public/html/communities/topic.lisp
Normal file
42
crates/app/src/public/html/communities/topic.lisp
Normal 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 %}")
|
19
crates/app/src/public/html/communities/topics.lisp
Normal file
19
crates/app/src/public/html/communities/topics.lisp
Normal 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 %}")
|
|
@ -543,8 +543,14 @@
|
||||||
(text "{{ self::avatar(username=owner.username, size=\"24px\", selector_type=\"username\") }}"))
|
(text "{{ self::avatar(username=owner.username, size=\"24px\", selector_type=\"username\") }}"))
|
||||||
(span
|
(span
|
||||||
("class" "name")
|
("class" "name")
|
||||||
(text "{{ self::full_username(user=owner) }}")))
|
(text "{{ self::full_username(user=owner) }}"))
|
||||||
(text "{{ self::post_info(post=post, community=community) }}"))
|
(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
|
(div
|
||||||
("class" "card_nest_horizontal")
|
("class" "card_nest_horizontal")
|
||||||
; author info
|
; author info
|
||||||
|
@ -2066,6 +2072,7 @@
|
||||||
(text "{{ icon \"message-circle\" }}")
|
(text "{{ icon \"message-circle\" }}")
|
||||||
(span
|
(span
|
||||||
(text "{{ text \"communities:label.chats\" }}")))
|
(text "{{ text \"communities:label.chats\" }}")))
|
||||||
|
(text "{% if not community.is_forum -%}")
|
||||||
(a
|
(a
|
||||||
("href" "/communities/intents/post?community={{ community.id }}")
|
("href" "/communities/intents/post?community={{ community.id }}")
|
||||||
("class" "button lowered")
|
("class" "button lowered")
|
||||||
|
@ -2073,6 +2080,7 @@
|
||||||
(text "{{ icon \"plus\" }}")
|
(text "{{ icon \"plus\" }}")
|
||||||
(span
|
(span
|
||||||
(text "{{ text \"general:action.post\" }}")))
|
(text "{{ text \"general:action.post\" }}")))
|
||||||
|
(text "{%- endif %}")
|
||||||
(text "{%- endif %} {% if can_manage_community or is_manager -%}")
|
(text "{%- endif %} {% if can_manage_community or is_manager -%}")
|
||||||
(a
|
(a
|
||||||
("href" "/community/{{ community.id }}/manage")
|
("href" "/community/{{ community.id }}/manage")
|
||||||
|
@ -2606,3 +2614,39 @@
|
||||||
(icon (text "trash")))
|
(icon (text "trash")))
|
||||||
(text "{%- endif %}"))))
|
(text "{%- endif %}"))))
|
||||||
(text "{%- endmacro %}")
|
(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 %}")
|
||||||
|
|
|
@ -354,6 +354,7 @@
|
||||||
content: e.target.content.value,
|
content: e.target.content.value,
|
||||||
community: \"{{ community.id }}\",
|
community: \"{{ community.id }}\",
|
||||||
stack: \"{{ post.stack }}\",
|
stack: \"{{ post.stack }}\",
|
||||||
|
topic: \"{{ post.topic }}\",
|
||||||
replying_to: \"{{ post.id }}\",
|
replying_to: \"{{ post.id }}\",
|
||||||
poll: poll_data[1],
|
poll: poll_data[1],
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -153,7 +153,8 @@ media_theme_pref();
|
||||||
.replaceAll(" months ago", "m")
|
.replaceAll(" months ago", "m")
|
||||||
.replaceAll(" month ago", "m")
|
.replaceAll(" month ago", "m")
|
||||||
.replaceAll(" years ago", "y")
|
.replaceAll(" years ago", "y")
|
||||||
.replaceAll(" year ago", "y");
|
.replaceAll(" year ago", "y")
|
||||||
|
.replaceAll("just now", "now");
|
||||||
}
|
}
|
||||||
|
|
||||||
element.innerText = !pretty ? then.toLocaleDateString() : pretty;
|
element.innerText = !pretty ? then.toLocaleDateString() : pretty;
|
||||||
|
@ -194,7 +195,8 @@ media_theme_pref();
|
||||||
.replaceAll(" month ago", "m")
|
.replaceAll(" month ago", "m")
|
||||||
.replaceAll(" years ago", "y")
|
.replaceAll(" years ago", "y")
|
||||||
.replaceAll(" year ago", "y")
|
.replaceAll(" year ago", "y")
|
||||||
.replaceAll("Yesterday", "1d") || "";
|
.replaceAll("Yesterday", "1d")
|
||||||
|
.replaceAll("just now", "now") || "";
|
||||||
|
|
||||||
element.innerText = !pretty ? then.toLocaleDateString() : pretty;
|
element.innerText = !pretty ? then.toLocaleDateString() : pretty;
|
||||||
element.style.display = "inline-block";
|
element.style.display = "inline-block";
|
||||||
|
|
|
@ -538,7 +538,7 @@ pub async fn add_topic_request(
|
||||||
None => return Json(Error::NotAllowed.into()),
|
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,
|
Ok(x) => x,
|
||||||
Err(e) => return Json(e.into()),
|
Err(e) => return Json(e.into()),
|
||||||
};
|
};
|
||||||
|
@ -547,8 +547,22 @@ pub async fn add_topic_request(
|
||||||
return Json(Error::DoesNotSupportField("community".to_string()).into());
|
return Json(Error::DoesNotSupportField("community".to_string()).into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let (id, topic) = ForumTopic::new(req.title, req.description, req.color);
|
// check lengths
|
||||||
community.topics.insert(id, topic);
|
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
|
match data
|
||||||
.update_community_topics(id, &user, community.topics)
|
.update_community_topics(id, &user, community.topics)
|
||||||
|
@ -575,7 +589,7 @@ pub async fn update_topic_request(
|
||||||
None => return Json(Error::NotAllowed.into()),
|
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,
|
Ok(x) => x,
|
||||||
Err(e) => return Json(e.into()),
|
Err(e) => return Json(e.into()),
|
||||||
};
|
};
|
||||||
|
@ -584,10 +598,25 @@ pub async fn update_topic_request(
|
||||||
return Json(Error::DoesNotSupportField("community".to_string()).into());
|
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 {
|
let topic = ForumTopic {
|
||||||
title: req.title,
|
title: req.title,
|
||||||
description: req.description,
|
description: req.description,
|
||||||
color: req.color,
|
color: req.color,
|
||||||
|
position: req.position,
|
||||||
};
|
};
|
||||||
|
|
||||||
community.topics.insert(topic_id, topic);
|
community.topics.insert(topic_id, topic);
|
||||||
|
@ -616,7 +645,7 @@ pub async fn delete_topic_request(
|
||||||
None => return Json(Error::NotAllowed.into()),
|
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,
|
Ok(x) => x,
|
||||||
Err(e) => return Json(e.into()),
|
Err(e) => return Json(e.into()),
|
||||||
};
|
};
|
||||||
|
@ -631,11 +660,14 @@ pub async fn delete_topic_request(
|
||||||
.update_community_topics(id, &user, community.topics)
|
.update_community_topics(id, &user, community.topics)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => Json(ApiReturn {
|
Ok(_) => match data.delete_topic_posts(id, topic_id).await {
|
||||||
ok: true,
|
Ok(_) => Json(ApiReturn {
|
||||||
message: "Community updated".to_string(),
|
ok: true,
|
||||||
payload: (),
|
message: "Community updated".to_string(),
|
||||||
}),
|
payload: (),
|
||||||
|
}),
|
||||||
|
Err(e) => Json(e.into()),
|
||||||
|
},
|
||||||
Err(e) => Json(e.into()),
|
Err(e) => Json(e.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -129,6 +129,10 @@ pub async fn create_request(
|
||||||
Ok(x) => x,
|
Ok(x) => x,
|
||||||
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
|
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
|
// check sizes
|
||||||
|
|
|
@ -795,6 +795,8 @@ pub struct AddTopic {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub color: String,
|
pub color: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub position: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
@ -821,6 +823,8 @@ pub struct CreatePost {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub stack: String,
|
pub stack: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub topic: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|
|
@ -16,7 +16,7 @@ use tera::Context;
|
||||||
use tetratto_core::model::{
|
use tetratto_core::model::{
|
||||||
addr::RemoteAddr,
|
addr::RemoteAddr,
|
||||||
auth::User,
|
auth::User,
|
||||||
communities::Community,
|
communities::{Community, ForumTopic},
|
||||||
communities_permissions::CommunityPermission,
|
communities_permissions::CommunityPermission,
|
||||||
permissions::FinePermission,
|
permissions::FinePermission,
|
||||||
stacks::{StackMode, UserStack},
|
stacks::{StackMode, UserStack},
|
||||||
|
@ -424,6 +424,47 @@ pub async fn feed_request(
|
||||||
// check permissions
|
// check permissions
|
||||||
let (can_read, _) = check_community_permissions!(community, jar, data, user);
|
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);
|
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`
|
/// `/community/{title}/questions`
|
||||||
pub async fn questions_request(
|
pub async fn questions_request(
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
|
|
|
@ -99,6 +99,10 @@ pub fn routes() -> Router {
|
||||||
get(communities::create_post_request),
|
get(communities::create_post_request),
|
||||||
)
|
)
|
||||||
.route("/community/{title}", get(communities::feed_request))
|
.route("/community/{title}", get(communities::feed_request))
|
||||||
|
.route(
|
||||||
|
"/community/{title}/topic/{id}",
|
||||||
|
get(communities::topic_feed_request),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/community/{title}/questions",
|
"/community/{title}/questions",
|
||||||
get(communities::questions_request),
|
get(communities::questions_request),
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
[package]
|
[package]
|
||||||
name = "tetratto-core"
|
name = "tetratto-core"
|
||||||
description = "The core behind Tetratto"
|
description = "The core behind Tetratto"
|
||||||
version = "13.0.0"
|
version = "14.0.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
@ -9,7 +9,13 @@ license.workspace = true
|
||||||
homepage.workspace = true
|
homepage.workspace = true
|
||||||
|
|
||||||
[features]
|
[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"]
|
types = ["dep:totp-rs", "dep:paste", "dep:bitflags"]
|
||||||
sdk = ["types", "dep:reqwest"]
|
sdk = ["types", "dep:reqwest"]
|
||||||
default = ["database", "types", "sdk"]
|
default = ["database", "types", "sdk"]
|
||||||
|
@ -21,8 +27,14 @@ toml = "0.9.4"
|
||||||
tetratto-shared = { version = "12.0.6", path = "../shared" }
|
tetratto-shared = { version = "12.0.6", path = "../shared" }
|
||||||
tetratto-l10n = { version = "12.0.0", path = "../l10n" }
|
tetratto-l10n = { version = "12.0.0", path = "../l10n" }
|
||||||
serde_json = "1.0.142"
|
serde_json = "1.0.142"
|
||||||
totp-rs = { version = "5.7.0", features = ["qr", "gen_secret"], optional = true }
|
totp-rs = { version = "5.7.0", features = [
|
||||||
reqwest = { version = "0.12.22", features = ["json", "multipart"], optional = true }
|
"qr",
|
||||||
|
"gen_secret",
|
||||||
|
], optional = true }
|
||||||
|
reqwest = { version = "0.12.22", features = [
|
||||||
|
"json",
|
||||||
|
"multipart",
|
||||||
|
], optional = true }
|
||||||
bitflags = { version = "2.9.1", optional = true }
|
bitflags = { version = "2.9.1", optional = true }
|
||||||
async-recursion = { version = "1.1.1", optional = true }
|
async-recursion = { version = "1.1.1", optional = true }
|
||||||
md-5 = { version = "0.10.6", optional = true }
|
md-5 = { version = "0.10.6", optional = true }
|
||||||
|
|
|
@ -532,6 +532,25 @@ impl DataManager {
|
||||||
Ok(())
|
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_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_read_access(CommunityReadAccess)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET read_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
|
||||||
auto_method!(update_community_write_access(CommunityWriteAccess)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET write_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
|
auto_method!(update_community_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);
|
||||||
|
|
|
@ -18,5 +18,6 @@ CREATE TABLE IF NOT EXISTS posts (
|
||||||
poll_id BIGINT NOT NULL,
|
poll_id BIGINT NOT NULL,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
is_open INT NOT NULL DEFAULT 1,
|
is_open INT NOT NULL DEFAULT 1,
|
||||||
circle BIGINT NOT NULL
|
stack BIGINT NOT NULL,
|
||||||
|
topic BIGINT NOT NULL
|
||||||
)
|
)
|
||||||
|
|
|
@ -21,3 +21,7 @@ ADD COLUMN IF NOT EXISTS is_forum INT DEFAULT 0;
|
||||||
-- communities topics
|
-- communities topics
|
||||||
ALTER TABLE communities
|
ALTER TABLE communities
|
||||||
ADD COLUMN IF NOT EXISTS topics TEXT DEFAULT '{}';
|
ADD COLUMN IF NOT EXISTS topics TEXT DEFAULT '{}';
|
||||||
|
|
||||||
|
-- posts topic
|
||||||
|
ALTER TABLE posts
|
||||||
|
ADD COLUMN IF NOT EXISTS topic BIGINT DEFAULT 0;
|
||||||
|
|
|
@ -116,6 +116,7 @@ impl DataManager {
|
||||||
title: get!(x->14(String)),
|
title: get!(x->14(String)),
|
||||||
is_open: get!(x->15(i32)) as i8 == 1,
|
is_open: get!(x->15(i32)) as i8 == 1,
|
||||||
stack: get!(x->16(i64)) as usize,
|
stack: get!(x->16(i64)) as usize,
|
||||||
|
topic: get!(x->17(i64)) as usize,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1209,6 +1210,60 @@ impl DataManager {
|
||||||
Ok(res.unwrap())
|
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).
|
/// Get all posts from the given stack (from most recent).
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
|
@ -1264,6 +1319,35 @@ impl DataManager {
|
||||||
Ok(res.unwrap())
|
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).
|
/// Get all pinned posts from the given user (from most recent).
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
|
@ -1494,7 +1578,7 @@ impl DataManager {
|
||||||
let res = query_rows!(
|
let res = query_rows!(
|
||||||
&conn,
|
&conn,
|
||||||
&format!(
|
&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 {
|
if before_time > 0 {
|
||||||
format!(" AND created < {before_time}")
|
format!(" AND created < {before_time}")
|
||||||
} else {
|
} else {
|
||||||
|
@ -1748,6 +1832,20 @@ impl DataManager {
|
||||||
self.get_community_by_id(data.community).await?
|
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?;
|
let mut owner = self.get_user_by_id(data.owner).await?;
|
||||||
|
|
||||||
// check values (if this isn't reposting something else)
|
// check values (if this isn't reposting something else)
|
||||||
|
@ -2019,7 +2117,7 @@ impl DataManager {
|
||||||
|
|
||||||
let res = execute!(
|
let res = execute!(
|
||||||
&conn,
|
&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![
|
params![
|
||||||
&(data.id as i64),
|
&(data.id as i64),
|
||||||
&(data.created as i64),
|
&(data.created as i64),
|
||||||
|
@ -2041,6 +2139,7 @@ impl DataManager {
|
||||||
&data.title,
|
&data.title,
|
||||||
&{ if data.is_open { 1 } else { 0 } },
|
&{ if data.is_open { 1 } else { 0 } },
|
||||||
&(data.stack as i64),
|
&(data.stack as i64),
|
||||||
|
&(data.topic as i64),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -280,6 +280,11 @@ pub struct Post {
|
||||||
///
|
///
|
||||||
/// If stack is not 0, community should be 0 (and vice versa).
|
/// If stack is not 0, community should be 0 (and vice versa).
|
||||||
pub stack: usize,
|
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 {
|
impl Post {
|
||||||
|
@ -308,6 +313,7 @@ impl Post {
|
||||||
title: String::new(),
|
title: String::new(),
|
||||||
is_open: true,
|
is_open: true,
|
||||||
stack: 0,
|
stack: 0,
|
||||||
|
topic: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -534,6 +540,7 @@ pub struct ForumTopic {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub color: String,
|
pub color: String,
|
||||||
|
pub position: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ForumTopic {
|
impl ForumTopic {
|
||||||
|
@ -542,13 +549,14 @@ impl ForumTopic {
|
||||||
/// # Returns
|
/// # Returns
|
||||||
/// * ID for [`Community`] hashmap
|
/// * ID for [`Community`] hashmap
|
||||||
/// * [`ForumTopic`]
|
/// * [`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(),
|
Snowflake::new().to_string().parse::<usize>().unwrap(),
|
||||||
Self {
|
Self {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
color,
|
color,
|
||||||
|
position,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue