add: community settings ui

TODO: add community read/write access settings
TODO: add profile settings
TODO: profile following in ui
TODO: community joining and membership management in ui
This commit is contained in:
trisua 2025-03-29 22:27:57 -04:00
parent eecf357325
commit 6413ed09fb
20 changed files with 855 additions and 46 deletions

View file

@ -25,3 +25,4 @@ tetratto-l10n = { path = "../l10n" }
image = "0.25.5"
reqwest = "0.12.15"
regex = "1.11.1"
serde_json = "1.0.140"

View file

@ -11,6 +11,7 @@ version = "1.0.0"
"dialog:action.cancel" = "Cancel"
"dialog:action.yes" = "Yes"
"dialog:action.no" = "No"
"dialog:action.save_and_close" = "Save and close"
"auth:action.login" = "Login"
"auth:action.register" = "Register"

View file

@ -7,15 +7,19 @@ use assets::{init_dirs, write_assets};
pub use tetratto_core::*;
use axum::{Extension, Router};
use tera::Tera;
use tera::{Tera, Value};
use tower_http::trace::{self, TraceLayer};
use tracing::{Level, info};
use std::sync::Arc;
use std::{collections::HashMap, sync::Arc};
use tokio::sync::RwLock;
pub(crate) type State = Arc<RwLock<(DataManager, Tera)>>;
fn render_markdown(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
Ok(tetratto_shared::markdown::render_markdown(&value.as_str().unwrap()).into())
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
@ -33,12 +37,12 @@ async fn main() {
let database = DataManager::new(config.clone()).await.unwrap();
database.init().await.unwrap();
let mut tera = Tera::new(&format!("{html_path}/**/*")).unwrap();
tera.register_filter("markdown", render_markdown);
let app = Router::new()
.merge(routes::routes(&config))
.layer(Extension(Arc::new(RwLock::new((
database,
Tera::new(&format!("{html_path}/**/*")).unwrap(),
)))))
.layer(Extension(Arc::new(RwLock::new((database, tera)))))
.layer(
TraceLayer::new_for_http()
.make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO))

View file

@ -152,7 +152,14 @@ button svg {
}
hr {
border-top: 1px var(--color-super-lowered);
border-top: solid 1px var(--color-super-lowered) !important;
border-left: 0;
border-bottom: 0;
border-right: 0;
}
hr.margin {
margin: 1rem 0;
}
p,

View file

@ -25,6 +25,18 @@
</h3>
<span class="fade">{{ community.title }}</span>
{% if user %}
<div
class="flex gap-1 reactions_box"
hook="check_reactions"
hook-arg:id="{{ community.id }}"
>
{{ components::likes(id=community.id,
asset_type="Community", likes=community.likes,
dislikes=community.dislikes) }}
</div>
{% endif %}
</div>
</div>
@ -41,15 +53,86 @@
<span>{{ text "communities:action.leave" }}</span>
</button>
{% endif %} {% else %}
<a
href="/community/{{ community.title }}/manage"
<button
href="/community/{{ community.title }}"
class="button primary"
onclick="document.getElementById('manage').showModal()"
>
{{ icon "settings" }}
<span
>{{ text "communities:action.configure" }}</span
>
</a>
</button>
<dialog id="manage">
<div class="inner">
<div
id="manage_fields"
class="flex flex-col gap-2"
></div>
<hr class="margin" />
<button
onclick="document.getElementById('manage').close(); save_context()"
>
{{ icon "check" }}
<span
>{{ text "dialog:action.save_and_close"
}}</span
>
</button>
</div>
</dialog>
<script>
setTimeout(() => {
const ui = ns("ui");
const settings = JSON.parse(
"{{ community_context_serde|safe }}",
);
ui.generate_settings_ui(
document.getElementById("manage_fields"),
[
[
["display_name", "Title"],
"{{ community.context.display_name }}",
"input",
],
[
["description", "Description"],
"{{ community.context.description }}",
"textarea",
],
],
settings,
);
window.save_context = () => {
fetch(
`/api/v1/communities/{{ community.id }}/context`,
{
method: "POST",
headers: {
"Content-Type":
"application/json",
},
body: JSON.stringify({
context: settings,
}),
},
)
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
}, 250);
</script>
{% endif %}
</div>
{% endif %}
@ -57,7 +140,7 @@
<div class="card-nest flex flex-col">
<div id="bio" class="card small">
{{ community.context.description }}
{{ community.context.description|markdown|safe }}
</div>
<div class="card flex flex-col gap-2">

View file

@ -38,6 +38,28 @@
{% if user.settings.display_name %} {{ user.settings.display_name }} {% else
%} {{ user.username }} {% endif %}
</div>
{%- endmacro %} {% macro likes(id, asset_type, likes=0, dislikes=0) -%}
<button
title="Like"
class="camo small"
hook_element="reaction.like"
onclick="trigger('me::react', [event.target, '{{ id }}', '{{ asset_type }}', true])"
>
{{ icon "heart" }} {% if likes > 0 %}
<span>{{ likes }}</span>
{% endif %}
</button>
<button
title="Dislike"
class="camo small"
hook_element="reaction.dislike"
onclick="trigger('me::react', [event.target, '{{ id }}', '{{ asset_type }}', false])"
>
{{ icon "heart-crack" }} {% if dislikes > 0 %}
<span>{{ dislikes }}</span>
{% endif %}
</button>
{%- endmacro %} {% macro post(post, owner, secondary=false, community=false,
show_community=true) -%}
<div class="card flex flex-col gap-2 {% if secondary %}secondary{% endif %}">
@ -68,21 +90,23 @@ show_community=true) -%}
{% endif %}
</div>
<span id="post-content:{{ post.id }}">{{ post.content }}</span>
<span id="post-content:{{ post.id }}"
>{{ post.content|markdown|safe }}</span
>
</div>
</div>
<div class="flex justify-between items-center gap-2 w-full">
<div class="flex gap-1 reactions_box">
{% if user %}
<button title="Like" class="primary small">
{{ icon "heart" }}
</button>
<button title="Dislike" class="secondary small">
{{ icon "heart-crack" }}
</button>
{% endif %}
{% if user %}
<div
class="flex gap-1 reactions_box"
hook="check_reactions"
hook-arg:id="{{ post.id }}"
>
{{ components::likes(id=post.id, asset_type="Post",
likes=post.likes, dislikes=post.dislikes) }}
</div>
{% endif %}
<div class="flex gap-1 buttons_box">
<a href="/post/{{ post.id }}" class="button camo small">

View file

@ -53,7 +53,7 @@
<div class="card-nest flex flex-col">
<div id="bio" class="card small">
{{ profile.settings.biography }}
{{ profile.settings.biography|markdown|safe }}
</div>
<div class="card flex flex-col gap-2">

View file

@ -455,21 +455,33 @@ media_theme_pref();
self.define("hooks::check_reactions", async ({ $ }) => {
const observer = $.offload_work_to_client_when_in_view(
async (element) => {
const like = element.querySelector(
'[hook_element="reaction.like"]',
);
const dislike = element.querySelector(
'[hook_element="reaction.dislike"]',
);
const reaction = await (
await fetch(
`/api/v1/reactions/${element.getAttribute("hook-arg:id")}`,
)
).json();
if (reaction.success) {
element.classList.add("green");
element.querySelector("svg").classList.add("filled");
if (reaction.ok) {
if (reaction.payload.is_like) {
like.classList.add("green");
like.querySelector("svg").classList.add("filled");
} else {
dislike.classList.add("red");
}
}
},
);
for (const element of Array.from(
document.querySelectorAll("[hook=check_reaction]") || [],
document.querySelectorAll("[hook=check_reactions]") || [],
)) {
observer.observe(element);
}
@ -619,3 +631,44 @@ media_theme_pref();
}
});
})();
// ui ns
(() => {
const self = reg_ns("ui");
self.define("render_settings_ui_field", (_, into_element, option) => {
into_element.innerHTML += `<div class="card-nest">
<div class="card small">
<b>${option.label.replaceAll("_", " ")}</b>
</div>
<div class="card">
<${option.input_element_type || "input"}
type="text"
onchange="window.set_setting_field('${option.key}', event.target.value)"
placeholder="${option.key}"
${option.input_element_type === "input" ? `value="${option.value}"/>` : ">"}
${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
</div>
</div>`;
});
self.define(
"generate_settings_ui",
({ $ }, into_element, options, settings_ref) => {
for (const option of options) {
$.render_settings_ui_field(into_element, {
key: Array.isArray(option[0]) ? option[0][0] : option[0],
label: Array.isArray(option[0]) ? option[0][1] : option[0],
value: option[1],
input_element_type: option[2],
});
}
window.set_setting_field = (key, value) => {
settings_ref[key] = value;
console.log("update", key);
};
},
);
})();

View file

@ -48,4 +48,47 @@
]);
});
});
self.define("react", async (_, element, asset, asset_type, is_like) => {
fetch("/api/v1/reactions", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
asset,
asset_type,
is_like,
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
if (res.ok) {
const like = element.parentElement.querySelector(
'[hook_element="reaction.like"]',
);
const dislike = element.parentElement.querySelector(
'[hook_element="reaction.dislike"]',
);
if (is_like) {
like.classList.add("green");
like.querySelector("svg").classList.add("filled");
dislike.classList.remove("red");
} else {
dislike.classList.add("red");
like.classList.remove("green");
like.querySelector("svg").classList.remove("filled");
}
}
});
});
})();

View file

@ -173,7 +173,7 @@ pub struct UpdatePostContext {
#[derive(Deserialize)]
pub struct CreateReaction {
pub asset: usize,
pub asset: String,
pub asset_type: AssetType,
pub is_like: bool,
}

View file

@ -36,10 +36,34 @@ pub async fn create_request(
None => return Json(Error::NotAllowed.into()),
};
let asset_id = match req.asset.parse::<usize>() {
Ok(n) => n,
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
};
// check for existing reaction
if let Ok(r) = data.get_reaction_by_owner_asset(user.id, asset_id).await {
match data.delete_reaction(r.id, &user).await {
Ok(_) => {
// if we're trying to create a reaction of a DIFFERENT TYPE, then
// we don't need to return here
if r.is_like == req.is_like {
return Json(ApiReturn {
ok: true,
message: "Reaction removed".to_string(),
payload: (),
});
}
}
Err(e) => return Json(e.into()),
};
}
// create reaction
match data
.create_reaction(Reaction::new(
user.id,
req.asset,
asset_id,
req.asset_type,
req.is_like,
))
@ -50,7 +74,7 @@ pub async fn create_request(
message: "Reaction created".to_string(),
payload: (),
}),
Err(e) => return Json(e.into()),
Err(e) => Json(e.into()),
}
}
@ -70,7 +94,7 @@ pub async fn delete_request(
Err(e) => return Json(e.into()),
};
match data.delete_reaction(reaction.id, user).await {
match data.delete_reaction(reaction.id, &user).await {
Ok(_) => Json(ApiReturn {
ok: true,
message: "Reaction deleted".to_string(),

View file

@ -80,6 +80,15 @@ pub fn community_context(
context.insert("community", &community);
context.insert("is_owner", &is_owner);
context.insert("is_joined", &is_joined);
if is_owner {
context.insert(
"community_context_serde",
&serde_json::to_string(&community.context)
.unwrap()
.replace("\"", "\\\""),
);
}
}
/// `/community/{title}`

View file

@ -116,14 +116,14 @@ impl DataManager {
auto_method!(delete_community()@get_community_by_id:MANAGE_COMMUNITIES -> "DELETE communities pages WHERE id = $1" --cache-key-tmpl=cache_clear_community);
auto_method!(update_community_title(String)@get_community_by_id:MANAGE_COMMUNITIES -> "UPDATE communities SET title = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_community);
auto_method!(update_community_context(CommunityContext)@get_community_by_id:MANAGE_COMMUNITIES -> "UPDATE communities SET prompt = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
auto_method!(update_community_context(CommunityContext)@get_community_by_id: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: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:MANAGE_COMMUNITIES -> "UPDATE communities SET write_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
auto_method!(incr_community_likes()@get_community_by_id -> "UPDATE communities SET likes = likes + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr);
auto_method!(incr_community_dislikes()@get_community_by_id -> "UPDATE communities SET likes = dislikes + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr);
auto_method!(incr_community_dislikes()@get_community_by_id -> "UPDATE communities SET dislikes = dislikes + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr);
auto_method!(decr_community_likes()@get_community_by_id -> "UPDATE communities SET likes = likes - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --decr);
auto_method!(decr_community_dislikes()@get_community_by_id -> "UPDATE communities SET likes = dislikes - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --decr);
auto_method!(decr_community_dislikes()@get_community_by_id -> "UPDATE communities SET dislikes = dislikes - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --decr);
auto_method!(incr_community_member_count()@get_community_by_id -> "UPDATE communities SET member_count = member_count + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr);
auto_method!(decr_community_member_count()@get_community_by_id -> "UPDATE communities SET member_count = member_count - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --decr);

View file

@ -287,9 +287,9 @@ impl DataManager {
auto_method!(update_post_context(PostContext)@get_post_by_id:MANAGE_POSTS -> "UPDATE posts SET context = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.post:{}");
auto_method!(incr_post_likes() -> "UPDATE posts SET likes = likes + 1 WHERE id = $1" --cache-key-tmpl="atto.post:{}" --incr);
auto_method!(incr_post_dislikes() -> "UPDATE posts SET likes = dislikes + 1 WHERE id = $1" --cache-key-tmpl="atto.post:{}" --incr);
auto_method!(incr_post_dislikes() -> "UPDATE posts SET dislikes = dislikes + 1 WHERE id = $1" --cache-key-tmpl="atto.post:{}" --incr);
auto_method!(decr_post_likes() -> "UPDATE posts SET likes = likes - 1 WHERE id = $1" --cache-key-tmpl="atto.post:{}" --decr);
auto_method!(decr_post_dislikes() -> "UPDATE posts SET likes = dislikes - 1 WHERE id = $1" --cache-key-tmpl="atto.post:{}" --decr);
auto_method!(decr_post_dislikes() -> "UPDATE posts SET dislikes = dislikes - 1 WHERE id = $1" --cache-key-tmpl="atto.post:{}" --decr);
auto_method!(incr_post_comments() -> "UPDATE posts SET comment_count = comment_count + 1 WHERE id = $1" --cache-key-tmpl="atto.post:{}" --incr);
auto_method!(decr_post_comments() -> "UPDATE posts SET comment_count = comment_count - 1 WHERE id = $1" --cache-key-tmpl="atto.post:{}" --decr);

View file

@ -86,13 +86,25 @@ impl DataManager {
// incr corresponding
match data.asset_type {
AssetType::Journal => {
if let Err(e) = self.incr_community_likes(data.id).await {
AssetType::Community => {
if let Err(e) = {
if data.is_like {
self.incr_community_likes(data.asset).await
} else {
self.incr_community_dislikes(data.asset).await
}
} {
return Err(e);
}
}
AssetType::JournalEntry => {
if let Err(e) = self.incr_post_likes(data.id).await {
AssetType::Post => {
if let Err(e) = {
if data.is_like {
self.incr_post_likes(data.asset).await
} else {
self.incr_post_dislikes(data.asset).await
}
} {
return Err(e);
}
}
@ -102,7 +114,7 @@ impl DataManager {
Ok(())
}
pub async fn delete_reaction(&self, id: usize, user: User) -> Result<()> {
pub async fn delete_reaction(&self, id: usize, user: &User) -> Result<()> {
let reaction = self.get_reaction_by_id(id).await?;
if user.id != reaction.owner {
@ -130,13 +142,25 @@ impl DataManager {
// decr corresponding
match reaction.asset_type {
AssetType::Journal => {
if let Err(e) = self.decr_community_likes(reaction.asset).await {
AssetType::Community => {
if let Err(e) = {
if reaction.is_like {
self.decr_community_likes(reaction.asset).await
} else {
self.decr_community_dislikes(reaction.asset).await
}
} {
return Err(e);
}
}
AssetType::JournalEntry => {
if let Err(e) = self.decr_post_likes(reaction.asset).await {
AssetType::Post => {
if let Err(e) = {
if reaction.is_like {
self.decr_post_likes(reaction.asset).await
} else {
self.decr_post_dislikes(reaction.asset).await
}
} {
return Err(e);
}
}

View file

@ -4,8 +4,8 @@ use tetratto_shared::{snow::AlmostSnowflake, unix_epoch_timestamp};
/// All of the items which support reactions.
#[derive(Serialize, Deserialize)]
pub enum AssetType {
Journal,
JournalEntry,
Community,
Post,
}
#[derive(Serialize, Deserialize)]

View file

@ -7,7 +7,9 @@ repository.workspace = true
license.workspace = true
[dependencies]
ammonia = "4.0.0"
chrono = "0.4.40"
comrak = "0.36.0"
hex_fmt = "0.3.0"
num-bigint = "0.4.6"
rand = "0.9.0"

View file

@ -1,4 +1,5 @@
pub mod hash;
pub mod markdown;
pub mod snow;
pub mod time;

View file

@ -0,0 +1,44 @@
use ammonia::Builder;
use comrak::{Options, markdown_to_html};
use std::collections::HashSet;
/// Render markdown input into HTML
pub fn render_markdown(input: &str) -> String {
let mut options = Options::default();
options.extension.table = true;
options.extension.superscript = true;
options.extension.strikethrough = true;
options.extension.autolink = true;
options.extension.header_ids = Option::Some(String::new());
options.extension.tagfilter = true;
options.render.unsafe_ = true;
// options.render.escape = true;
options.parse.smart = false;
let html = markdown_to_html(input, &options);
let mut allowed_attributes = HashSet::new();
allowed_attributes.insert("id");
allowed_attributes.insert("class");
allowed_attributes.insert("ref");
allowed_attributes.insert("aria-label");
allowed_attributes.insert("lang");
allowed_attributes.insert("title");
allowed_attributes.insert("align");
allowed_attributes.insert("data-color");
allowed_attributes.insert("data-font-family");
Builder::default()
.generic_attributes(allowed_attributes)
.clean(&html)
.to_string()
.replace(
"src=\"",
"loading=\"lazy\" src=\"/api/v1/util/ext/image?img=",
)
.replace("--&gt;", "<align class=\"right\">")
.replace("-&gt;", "<align class=\"center\">")
.replace("&lt;-", "</align>")
}