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

@ -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