add: better user settings page
This commit is contained in:
parent
e8cc541f45
commit
4735832cef
16 changed files with 2398 additions and 2241 deletions
|
@ -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)!
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);"))
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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 %}")
|
||||
|
|
|
@ -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);"))
|
||||
|
|
|
@ -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
|
@ -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 %}")
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)]
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue