add: better user settings page

This commit is contained in:
trisua 2025-08-30 19:30:54 -04:00
parent e8cc541f45
commit 4735832cef
16 changed files with 2398 additions and 2241 deletions

View file

@ -25,6 +25,10 @@ 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.
Tetratto attempts to load `/public/fonts/lexend_variable.woff2` by defualt. This font is not included in the source by default, so you must download it yourself. Download Lexend (available from Google Fonts) as a variable font, and then create a `fonts` directory in the created `public` directory (relative to your configuration file). Place the font file (named "lexend_variable.woff2") in this fonts directory.
Please note that Google Fonts only distributes Lexend Variable as a TTF file. You can use [`woff2_convert`](https://github.com/google/woff2) to convert the TTF into a woff2 file (`woff2_convert lexend_variable.ttf`).
## 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)!

View file

@ -80,6 +80,7 @@ version = "1.0.0"
"auth:label.relationship" = "Relationship"
"auth:label.joined_communities" = "Joined communities"
"auth:label.recent_posts" = "Recent posts"
"auth:label.recent_answers" = "Recent answers"
"auth:label.recent_with_tag" = "Recent posts (with tag)"
"auth:label.recent_replies" = "Recent replies"
"auth:label.recent_posts_with_media" = "Recent posts (with media)"
@ -176,7 +177,7 @@ version = "1.0.0"
"settings:tab.profile" = "Profile"
"settings:tab.theme" = "Theme"
"settings:tab.sessions" = "Sessions"
"settings:tab.connections" = "Connections"
"settings:tab.grants" = "Grants"
"settings:tab.images" = "Images"
"settings:tab.presets" = "Presets"
"settings:label.change_password" = "Change password"

View file

@ -85,13 +85,18 @@
box-sizing: border-box;
}
@font-face {
font-family: "Lexend";
src: url("/public/fonts/lexend_variable.woff2") format("woff2");
}
html,
body {
line-height: 1.5;
letter-spacing: 0.15px;
font-family:
"Inter", "Poppins", "Roboto", ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Lexend", "Inter", "Poppins", "Roboto", ui-sans-serif, system-ui,
sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji";
color: var(--color-text);
background: var(--color-surface);

View file

@ -466,7 +466,7 @@ button.camo:hover,
input,
textarea,
select {
padding: 0.35rem var(--pad-3);
padding: var(--pad-2) var(--pad-3);
border-radius: var(--radius);
outline: none;
transition: background 0.15s;
@ -481,6 +481,10 @@ select {
color: var(--color-text-lowered);
}
input {
height: 32px;
}
textarea {
min-height: 5rem;
}
@ -564,10 +568,27 @@ input[type="checkbox"]:checked {
background-image: url("/icons/check.svg");
}
input[type="file"] {
height: max-content;
}
label {
cursor: pointer;
}
.round_form input,
.round_form .square {
border-radius: var(--circle);
}
.round_form input {
padding: var(--pad-2) var(--pad-4);
}
.round_form input[type="file"] {
padding: 0 var(--pad-4);
}
/* pillmenu */
.pillmenu {
display: flex;
@ -874,12 +895,12 @@ nav .button:not(.title):not(.active):hover {
display: none;
}
.mobile_nav .pillmenu a:first-of-type {
.mobile_nav .pillmenu a:not(.dropdown *):first-of-type {
border-top-left-radius: var(--radius) !important;
border-bottom-left-radius: var(--radius) !important;
}
.mobile_nav .pillmenu a:last-of-type {
.mobile_nav .pillmenu a:not(.dropdown *):last-of-type {
border-top-right-radius: var(--radius) !important;
border-bottom-right-radius: var(--radius) !important;
}
@ -1033,14 +1054,14 @@ dialog:is(.dark *)::backdrop {
}
.dropdown .inner .active::after {
top: 0;
left: 0;
top: 10%;
left: 5px;
width: 5px;
content: "";
height: 100%;
height: 80%;
position: absolute;
background: var(--color-primary);
border-radius: var(--radius);
border-radius: var(--circle);
}
.dropdown:not(nav *):has(.inner.open) button:not(.inner button) {
@ -1085,7 +1106,7 @@ dialog:is(.dark *)::backdrop {
width: max-content;
max-width: calc(100dvw - var(--pad-4));
border-radius: var(--radius);
padding: var(--pad-3) var(--pad-4);
padding: var(--pad-2) var(--pad-3);
animation: popin ease-in-out 1 0.15s running;
display: flex;
justify-content: space-between;
@ -1502,3 +1523,47 @@ details.accordion .inner {
top: 0;
border-radius: var(--radius);
}
/* menus */
menu {
display: flex;
}
menu a {
justify-content: flex-start;
width: 100%;
text-decoration: none !important;
background: var(--color-raised);
color: var(--color-text-raised);
padding: var(--pad-2) var(--pad-3);
font-weight: 500;
display: flex;
align-items: center;
gap: var(--pad-2);
}
menu a:hover {
background: var(--color-super-raised);
}
menu a.active {
background: var(--color-primary);
color: var(--color-text-primary);
}
menu.col {
flex-direction: column;
width: 25rem;
max-width: 100%;
padding: var(--pad-3) 0;
}
menu a:first-child {
border-top-left-radius: var(--radius);
border-top-right-radius: var(--radius);
}
menu a:last-child {
border-bottom-left-radius: var(--radius);
border-bottom-right-radius: var(--radius);
}

View file

@ -45,7 +45,7 @@
`<b>${message}.</b> You can now close this tab.`;
setTimeout(() => {
window.location.href = \"/settings#/connections\";
window.location.href = \"/settings#/grants\";
}, 500);
}, 1000);"))
@ -75,7 +75,7 @@
`<b>${message}.</b> You can now close this tab.`;
setTimeout(() => {
window.location.href = \"/settings#/connections\";
window.location.href = \"/settings#/grants\";
}, 500);
}, 1000);"))

View file

@ -188,7 +188,7 @@
(b
(text "{{ text \"settings:label.change_avatar\" }}")))
(form
("class" "card flex gap_2 flex_row flex_wrap items_center")
("class" "card big_icon flex gap_2 flex_row flex_wrap items_center")
("method" "post")
("enctype" "multipart/form-data")
("onsubmit" "upload_avatar(event)")
@ -199,6 +199,7 @@
("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif")
("class" "w_content"))
(button
("class" "small square big_icon")
(text "{{ icon \"check\" }}"))))
(div
("class" "card_nest")
@ -208,7 +209,7 @@
(b
(text "{{ text \"settings:label.change_banner\" }}")))
(form
("class" "card flex flex_col gap_2")
("class" "card big_icon flex flex_col gap_2")
("method" "post")
("enctype" "multipart/form-data")
("onsubmit" "upload_banner(event)")
@ -221,6 +222,7 @@
("accept" "image/png,image/jpeg,image/avif,image/webp")
("class" "w_content"))
(button
("class" "small square big_icon")
(text "{{ icon \"check\" }}")))
(span
("class" "fade")

View file

@ -18,17 +18,7 @@
(span
("class" "desktop")
(str (text "general:link.home"))))
(text "{% if user -%}")
(a
("href" "/communities")
("class" "button {% if selected == 'communities' -%}active{%- endif %}")
(icon (text "book-heart"))
(span
("class" "desktop")
(str (text "general:link.communities"))))
(text "{%- endif %} {%- endif %}"))
(text "{%- endif %}"))
(div
("class" "flex nav_side")
@ -71,6 +61,10 @@
(div
("class" "inner")
(a
("href" "/communities")
(icon (text "book-heart"))
(str (text "general:link.communities")))
(a
("href" "/chats/0/0")
(icon (text "message-circle"))
@ -385,9 +379,9 @@
(span
(text "{{ text \"settings:tab.sessions\" }}")))
(a
("data-tab-button" "connections")
("href" "#/connections")
("data-tab-button" "grants")
("href" "#/grants")
(text "{{ icon \"cable\" }}")
(span
(text "{{ text \"settings:tab.connections\" }}")))
(text "{{ text \"settings:tab.grants\" }}")))
(text "{%- endmacro %}")

View file

@ -20,13 +20,36 @@
(b
(text "{{ tag }}")))
(text "{%- endif %}"))
(text "{% if user -%}")
(a
("href" "/search?profile={{ profile.id }}")
("class" "button lowered small")
(text "{{ icon \"search\" }}")
(span
(text "{{ text \"general:link.search\" }}")))
(text "{% if not tag -%}")
(div
("class" "flex gap_2")
(div
("class" "dropdown")
(button
("class" "lowered small")
("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown")
(icon (text "arrow-down-up"))
(text "{{ order }}"))
(div
("class" "inner")
(a
("href" "?o=Recent&f=true")
(icon (text "calendar-arrow-down"))
(span (text "Recent")))
(a
("href" "?o=Popular&f=true")
(icon (text "trending-up"))
(span (text "Popular")))))
(text "{% if user -%}")
(a
("href" "/search?profile={{ profile.id }}")
("class" "button lowered small")
(text "{{ icon \"search\" }}")
(span
(text "{{ text \"general:link.search\" }}")))
(text "{%- endif %}"))
(text "{%- endif %}"))
(div
("class" "card w_full flex flex_col gap_2")
@ -42,7 +65,7 @@
(text "{% set paged = user and user.settings.paged_timelines %}")
(script
(text "setTimeout(async () => {
await trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?user_id={{ profile.id }}&tag={{ tag }}&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
await trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?user_id={{ profile.id }}&tag={{ tag }}&order={{ order }}&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
(await ns(\"ui\")).IO_DATA_DISABLE_RELOAD = true;
console.log(\"created profile timeline\");
}, 1000);"))

View file

@ -13,7 +13,7 @@
("class" "flex gap_2 items_center")
(text "{% if not tag -%} {{ icon \"clock\" }}")
(span
(text "{{ text \"auth:label.recent_posts\" }}"))
(text "{{ text \"auth:label.recent_answers\" }}"))
(text "{% else %} {{ icon \"tag\" }}")
(span
(text "{{ text \"auth:label.recent_with_tag\" }}: ")

File diff suppressed because it is too large Load diff

View file

@ -23,7 +23,7 @@
("class" "card w_full flex flex_col gap_2")
(text "{% if not profile and not user.permissions|has_supporter -%} {{ components::supporter_ad(body=\"Become a supporter for full-site search!\") }} {% else %}")
(form
("class" "flex flex_col gap_2")
("class" "flex flex_col gap_2 round_form")
(div
("class" "flex flex_row gap_2")
(input
@ -57,5 +57,4 @@
(text "{%- endif %}"))))
(text "{%- endif %}")
(text "{% for post in list %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %} {% if profile -%} {{ components::pagination(page=page, items=list|length, key=\"&profile=\" ~ profile.id, value=\"&query=\" ~ query) }} {% else %} {{ components::pagination(page=page, items=list|length, key=\"&query=\" ~ query) }} {%- endif %}"))))
(text "{% endblock %}")

View file

@ -217,21 +217,24 @@ pub async fn add_user_request(
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
};
// check block status
if data
.get_userblock_by_initiator_receiver(other_user.id, user.id)
.await
.is_ok()
{
return Json(Error::NotAllowed.into());
}
// add user
// get stack
let mut stack = match data.get_stack_by_id(id).await {
Ok(s) => s,
Err(e) => return Json(e.into()),
};
// check block status
if stack.mode != StackMode::BlockList {
if data
.get_userblock_by_initiator_receiver(other_user.id, user.id)
.await
.is_ok()
{
return Json(Error::NotAllowed.into());
}
}
// add user
if stack.users.contains(&other_user.id) {
return Json(Error::MiscError("This user is already in this stack".to_string()).into());
}

View file

@ -9,7 +9,7 @@ use axum::{
Extension,
};
use crate::cookie::CookieJar;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use tetratto_core::{
database::FullPost,
model::{
@ -670,6 +670,20 @@ pub async fn search_request(
))
}
#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)]
pub enum TimelineOrderMode {
/// Ordered by creation date.
Recent,
/// Ordered by likes - dislikes.
Popular,
}
impl Default for TimelineOrderMode {
fn default() -> Self {
Self::Recent
}
}
#[derive(Deserialize)]
pub struct TimelineQuery {
#[serde(default)]
@ -688,6 +702,8 @@ pub struct TimelineQuery {
pub before: usize,
#[serde(default)]
pub responses_only: bool,
#[serde(default)]
pub order: TimelineOrderMode,
}
async fn swiss_army_timeline(
@ -737,7 +753,13 @@ async fn swiss_army_timeline(
.get_responses_by_user(req.user_id, 12, req.page)
.await
} else {
data.0.get_posts_by_user(req.user_id, 12, req.page).await
if req.order == TimelineOrderMode::Recent {
data.0.get_posts_by_user(req.user_id, 12, req.page).await
} else {
data.0
.get_popular_posts_by_user(req.user_id, 12, req.page)
.await
}
}
} else {
if req.responses_only {

View file

@ -16,7 +16,7 @@ use axum::{
routing::{get, post},
Router,
};
use crate::cookie::CookieJar;
use crate::{cookie::CookieJar, routes::pages::misc::TimelineOrderMode};
use serde::Deserialize;
use tetratto_core::model::{Error, auth::User};
use crate::{assets::initial_context, get_lang, InnerState};
@ -222,6 +222,8 @@ pub struct ProfileQuery {
pub responses_only: bool,
#[serde(default, alias = "f")]
pub force: bool,
#[serde(default, alias = "o")]
pub order: TimelineOrderMode,
}
#[derive(Deserialize)]

View file

@ -380,6 +380,7 @@ pub async fn posts_request(
context.insert("pinned", &pinned);
context.insert("page", &props.page);
context.insert("tag", &props.tag);
context.insert("order", &props.order);
profile_context(
&mut context,
&user,

View file

@ -783,6 +783,37 @@ impl DataManager {
Ok(res.unwrap())
}
/// Get all posts from the given user (sorted by likes - dislikes).
///
/// # Arguments
/// * `id` - the ID of the user the requested posts belong to
/// * `batch` - the limit of posts in each page
/// * `page` - the page number
pub async fn get_popular_posts_by_user(
&self,
id: usize,
batch: usize,
page: 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 owner = $1 AND replying_to = 0 AND NOT (context::json->>'is_profile_pinned')::boolean AND is_deleted = 0 ORDER BY (likes - dislikes) DESC, created DESC LIMIT $2 OFFSET $3",
&[&(id 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 (that are answering a question) from the given user (from most recent).
///
/// # Arguments