add: ability to edit community title through ui

add: finish README
This commit is contained in:
trisua 2025-04-02 18:44:31 -04:00
parent 7c9d5de535
commit 53cf75b53c
11 changed files with 203 additions and 55 deletions

View file

@ -1,10 +1,59 @@
# 🐐 tetratto! <div align="center">
<h1>🐇 tetratto!</h1>
<p><i>Tetratto</i> is a super simple community-oriented website where users can create various communities and share posts in them!</p>
</div>
Tetratto is your personal journal! !["Docs" workflow badge](https://github.com/trisuaso/tetratto/workflows/Docs/badge.svg)
![GitHub commit activity](https://img.shields.io/github/commit-activity/m/trisuaso/tetratto)
![GitHub last commit](https://img.shields.io/github/last-commit/trisuaso/tetratto)
[![GitHub License](https://img.shields.io/github/license/trisuaso/tetratto)](https://github.com/trisuaso/tetratto/blob/master/LICENSE)
## Features # Usage
- Create new pages in your journal (essentially just posts) Everything Tetratto needs will be built into the main binary. You can build Tetratto with the following command:
- Create new pages in your journal where people can post messages (essentially message boards that you control)
- Follow other people and see their (public) journal entries ```bash
- Journal entries can either be public, unlisted (only accessible via link), and fully private (only accessible to moderators and the owner) cargo build -r --no-default-features features=redis,sqlite
```
You can replace `sqlite` in the above command with `postgres`, if you'd like. It's also acceptable to remove the `redis` part if you don't want to use a cache. <sup>I wouldn't recomment removing cache, though</sup>
You can then take the binary and place it somewhere else (highly recommended; the binary will create a fair number of files!). You can do this to move it to a directory just called "tetratto" in the parent directory:
```bash
mkdir tetratto
mv ./target/release/tetratto ../tetratto
cd ../tetratto
```
Your first start of Tetratto might be a little slow as it's going to download all icon SVGs required for the HTML templates to render properly. These icons will be stored on disk, so there's no need to worry about this time _every_ restart.
## Configuration
In the directory you're running Tetratto from, you should create a `tetratto.toml` file. This file follows the configuration schema defined [here](https://trisuaso.github.io/tetratto/tetratto/config/struct.Config.html)!
## Usage (as a user)
Tetratto is very simple once you get the hang of it! At the top of the page (or bottom if you're on mobile), you'll see the navigation bar. Once logged in, you'll be able to access "Home", "Popular", and "Communities" from there! You can also press your profile picture (on the right) to view your own profile, settings, or log out!
All Tetratto instances support reports for communities and posts through the UI. You can just find the ellipsis icon on either and then press "Report" to file a report!
# Updating
When bumping versions, you _might_ need to run a few SQL scripts in order to get your database up to what the next commit expects. It is recommended to only update from GitHub release, which will list the SQL scripts you need to run to migrate your database.
# Developing
All you really need to develop Tetratto is [Rust](https://rustup.rs/) and [Just](https://just.systems/).
You can fix a lot of weird style issues and stuff using `just fix`. <sup>You need [Clippy](https://doc.rust-lang.org/stable/clippy/installation.html) for this</sup>
You can also automatically bump all dependencies _and_ point out unused ones with `just clean-deps`! <sup>You need [cargo-edit](https://github.com/killercup/cargo-edit) and [cargo-machete](https://github.com/bnjbvr/cargo-machete) for this</sup>
# Contributing
Read the ["Contribution Guidelines"](./.github/CONTRIBUTING.md) before contributing!
# License
Tetratto is licensed under the [AGPL-3.0](./LICENSE).

View file

@ -8,6 +8,7 @@ version = "1.0.0"
"general:link.next" = "Next" "general:link.next" = "Next"
"general:link.previous" = "Previous" "general:link.previous" = "Previous"
"general:link.source_code" = "Source code" "general:link.source_code" = "Source code"
"general:link.reference" = "Reference"
"general:link.audit_log" = "Audit log" "general:link.audit_log" = "Audit log"
"general:link.reports" = "Reports" "general:link.reports" = "Reports"
"general:action.save" = "Save" "general:action.save" = "Save"
@ -64,6 +65,8 @@ version = "1.0.0"
"communities:label.user_id" = "User ID" "communities:label.user_id" = "User ID"
"communities:label.danger_zone" = "Danger zone" "communities:label.danger_zone" = "Danger zone"
"communities:label.delete_community" = "Delete community" "communities:label.delete_community" = "Delete community"
"communities:label.change_title" = "Change title"
"communities:label.new_title" = "New title"
"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"

View file

@ -102,6 +102,17 @@ article {
article { article {
margin-top: 0; margin-top: 0;
} }
main {
padding: 0;
}
body .card:not(.card *),
body .pillmenu:not(.card *) > a,
body .card-nest:not(.card *) > .card,
body .banner {
border-radius: 0 !important;
}
} }
.content_container { .content_container {
@ -138,13 +149,19 @@ article {
} }
/* typo */ /* typo */
pre { pre,
font-family: monospace; code {
font-family: "Jetbrains Mono", "Fire Code", monospace;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
overflow: auto; overflow: auto;
background: var(--color-lowered); background: var(--color-lowered);
border-radius: var(--radius); border-radius: var(--radius);
padding: 0.25rem;
font-size: 0.8rem;
}
pre {
padding: 1rem; padding: 1rem;
} }
@ -765,6 +782,7 @@ dialog::backdrop {
display: none; display: none;
position: absolute; position: absolute;
background: var(--color-raised); background: var(--color-raised);
border: solid 1px var(--color-super-lowered);
z-index: 2; z-index: 2;
border-radius: var(--radius); border-radius: var(--radius);
top: calc(100% + 5px); top: calc(100% + 5px);

View file

@ -98,6 +98,36 @@
</select> </select>
</div> </div>
</div> </div>
<div class="card-nest" ui_ident="change_title">
<div class="card small">
<b>{{ text "communities:label.change_title" }}</b>
</div>
<form
class="card flex flex-col gap-2"
onsubmit="change_title(event)"
>
<div class="flex flex-col gap-1">
<label for="new_title"
>{{ text "communities:label.new_title" }}</label
>
<input
type="text"
name="new_title"
id="new_title"
placeholder="new_title"
required
minlength="2"
/>
</div>
<button class="primary">
{{ icon "check" }}
<span>{{ text "general:action.save" }}</span>
</button>
</form>
</div>
</div> </div>
<div class="card-nest" ui_ident="danger_zone"> <div class="card-nest" ui_ident="danger_zone">
@ -412,6 +442,35 @@
}); });
}; };
globalThis.change_title = async (e) => {
e.preventDefault();
if (
!(await trigger("atto::confirm", [
"Are you sure you would like to do this?",
]))
) {
return;
}
fetch("/api/v1/communities/{{ community.id }}/title", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: e.target.new_title.value,
}),
})
.then((res) => res.json())
.then((res) => {
trigger("atto::toast", [
res.ok ? "success" : "error",
res.message,
]);
});
};
globalThis.delete_community = async () => { globalThis.delete_community = async () => {
if ( if (
!(await trigger("atto::confirm", [ !(await trigger("atto::confirm", [
@ -437,6 +496,7 @@
"read_access", "read_access",
"join_access", "join_access",
"write_access", "write_access",
"change_title",
"change_avatar", "change_avatar",
"change_banner", "change_banner",
]); ]);

View file

@ -88,6 +88,22 @@ community %}
<span>{{ dislikes }}</span> <span>{{ dislikes }}</span>
{% endif %} {% endif %}
</button> </button>
{%- endmacro %} {% macro full_username(user) -%}
<div class="flex">
<a href="/@{{ user.username }}" class="flush" style="font-weight: 600">
{{ components::username(user=user) }}
</a>
{{ components::online_indicator(user=user) }} {% if user.is_verified %}
<span
title="Verified"
style="color: var(--color-primary)"
class="flex items-center"
>
{{ icon "badge-check" }}
</span>
{% endif %}
</div>
{%- endmacro %} {% macro post(post, owner, secondary=false, community=false, {%- endmacro %} {% macro post(post, owner, secondary=false, community=false,
show_community=true) -%} {% if community and show_community %} show_community=true) -%} {% if community and show_community %}
<div class="card-nest"> <div class="card-nest">
@ -113,13 +129,7 @@ show_community=true) -%} {% if community and show_community %}
<div class="flex flex-col w-full gap-1"> <div class="flex flex-col w-full gap-1">
<div class="flex flex-wrap gap-2 items-center"> <div class="flex flex-wrap gap-2 items-center">
<div class="flex"> {{ components::full_username(user=owner) }}
<a href="/@{{ owner.username }}"
>{{ components::username(user=owner) }}</a
>
{{ components::online_indicator(user=owner) }}
</div>
<span class="fade date">{{ post.created }}</span> <span class="fade date">{{ post.created }}</span>

View file

@ -75,11 +75,6 @@ show_lhs=true) -%}
<span>{{ text "auth:link.settings" }}</span> <span>{{ text "auth:link.settings" }}</span>
</a> </a>
<a href="https://github.com/trisuaso/tetratto">
{{ icon "code" }}
<span>{{ text "general:link.source_code" }}</span>
</a>
{% if is_helper %} {% if is_helper %}
<b class="title">{{ text "general:label.mod" }}</b> <b class="title">{{ text "general:label.mod" }}</b>
@ -94,6 +89,18 @@ show_lhs=true) -%}
</a> </a>
{% endif %} {% endif %}
<b class="title">{{ config.name }}</b>
<a href="https://github.com/trisuaso/tetratto">
{{ icon "code" }}
<span>{{ text "general:link.source_code" }}</span>
</a>
<a href="https://trisuaso.github.io/tetratto">
{{ icon "book" }}
<span>{{ text "general:link.reference" }}</span>
</a>
<div class="title"></div> <div class="title"></div>
<button class="red" onclick="trigger('me::logout')"> <button class="red" onclick="trigger('me::logout')">
{{ icon "log-out" }} {{ icon "log-out" }}

View file

@ -121,7 +121,7 @@
<!-- dialogs --> <!-- dialogs -->
<dialog id="link_filter"> <dialog id="link_filter">
<div class="inner"> <div class="inner flex flex-col gap-2">
<p>Pressing continue will bring you to the following URL:</p> <p>Pressing continue will bring you to the following URL:</p>
<pre><code id="link_filter_url"></code></pre> <pre><code id="link_filter_url"></code></pre>
<p>Are sure you want to go there?</p> <p>Are sure you want to go there?</p>
@ -135,14 +135,16 @@
target="_blank" target="_blank"
onclick="document.getElementById('link_filter').close()" onclick="document.getElementById('link_filter').close()"
> >
{{ text "dialog:action.continue" }} {{ icon "external-link" }}
<span>{{ text "dialog:action.continue" }}</span>
</a> </a>
<button <button
class="secondary" class="secondary"
type="button" type="button"
onclick="document.getElementById('link_filter').close()" onclick="document.getElementById('link_filter').close()"
> >
{{ text "dialog:action.cancel" }} {{ icon "x" }}
<span>{{ text "dialog:action.cancel" }}</span>
</button> </button>
</div> </div>
</div> </div>

View file

@ -66,7 +66,7 @@ pub async fn proxy_request(
if let Some(ct) = stream.headers().get("Content-Type") { if let Some(ct) = stream.headers().get("Content-Type") {
let ct = ct.to_str().unwrap(); let ct = ct.to_str().unwrap();
let bad_ct = vec!["text/html", "text/plain"]; let bad_ct = ["text/html", "text/plain"];
if (!ct.starts_with("image/") && !ct.starts_with("font/")) | bad_ct.contains(&ct) { if (!ct.starts_with("image/") && !ct.starts_with("font/")) | bad_ct.contains(&ct) {
// if we got html, return default banner (likely an error page) // if we got html, return default banner (likely an error page)
return ( return (

View file

@ -233,12 +233,10 @@ pub async fn settings_request(
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
}; };
if user.id != community.owner { if user.id != community.owner && !user.permissions.check(FinePermission::MANAGE_COMMUNITIES) {
if !user.permissions.check(FinePermission::MANAGE_COMMUNITIES) { return Err(Html(
return Err(Html( render_error(Error::NotAllowed, &jar, &data, &None).await,
render_error(Error::NotAllowed, &jar, &data, &None).await, ));
));
}
} }
// init context // init context

View file

@ -121,17 +121,14 @@ pub async fn posts_request(
// check for private profile // check for private profile
if other_user.settings.private_profile { if other_user.settings.private_profile {
if let Some(ref ua) = user { if let Some(ref ua) = user {
if (ua.id != other_user.id) && !ua.permissions.check(FinePermission::MANAGE_USERS) { if (ua.id != other_user.id) && !ua.permissions.check(FinePermission::MANAGE_USERS) && data
if data
.0 .0
.get_userfollow_by_initiator_receiver(other_user.id, ua.id) .get_userfollow_by_initiator_receiver(other_user.id, ua.id)
.await .await
.is_err() .is_err() {
{ return Err(Html(
return Err(Html( render_error(Error::NotAllowed, &jar, &data, &user).await,
render_error(Error::NotAllowed, &jar, &data, &user).await, ));
));
}
} }
} else { } else {
return Err(Html( return Err(Html(
@ -246,17 +243,14 @@ pub async fn following_request(
// check for private profile // check for private profile
if other_user.settings.private_profile { if other_user.settings.private_profile {
if let Some(ref ua) = user { if let Some(ref ua) = user {
if ua.id != other_user.id { if ua.id != other_user.id && data
if data
.0 .0
.get_userfollow_by_initiator_receiver(other_user.id, ua.id) .get_userfollow_by_initiator_receiver(other_user.id, ua.id)
.await .await
.is_err() .is_err() {
{ return Err(Html(
return Err(Html( render_error(Error::NotAllowed, &jar, &data, &user).await,
render_error(Error::NotAllowed, &jar, &data, &user).await, ));
));
}
} }
} else { } else {
return Err(Html( return Err(Html(
@ -373,17 +367,14 @@ pub async fn followers_request(
// check for private profile // check for private profile
if other_user.settings.private_profile { if other_user.settings.private_profile {
if let Some(ref ua) = user { if let Some(ref ua) = user {
if ua.id != other_user.id { if ua.id != other_user.id && data
if data
.0 .0
.get_userfollow_by_initiator_receiver(other_user.id, ua.id) .get_userfollow_by_initiator_receiver(other_user.id, ua.id)
.await .await
.is_err() .is_err() {
{ return Err(Html(
return Err(Html( render_error(Error::NotAllowed, &jar, &data, &user).await,
render_error(Error::NotAllowed, &jar, &data, &user).await, ));
));
}
} }
} else { } else {
return Err(Html( return Err(Html(

10
rustfmt.toml Normal file
View file

@ -0,0 +1,10 @@
# indent_style = "Block"
reorder_imports = false
hard_tabs = false
tab_spaces = 4
# enum_discrim_align_threshold = 20
# struct_field_align_threshold = 20
fn_params_layout = "Tall"
max_width = 100
newline_style = "Unix"
use_field_init_shorthand = true