add: user settings
fix: actually use cached stuff in auto_method macro add: profile ui base
This commit is contained in:
parent
8580e34be2
commit
7d96a3d20f
16 changed files with 222 additions and 8 deletions
|
@ -36,6 +36,9 @@ pub const AUTH_BASE: &str = include_str!("./public/html/auth/base.html");
|
||||||
pub const AUTH_LOGIN: &str = include_str!("./public/html/auth/login.html");
|
pub const AUTH_LOGIN: &str = include_str!("./public/html/auth/login.html");
|
||||||
pub const AUTH_REGISTER: &str = include_str!("./public/html/auth/register.html");
|
pub const AUTH_REGISTER: &str = include_str!("./public/html/auth/register.html");
|
||||||
|
|
||||||
|
pub const PROFILE_BASE: &str = include_str!("./public/html/profile/base.html");
|
||||||
|
pub const PROFILE_POSTS: &str = include_str!("./public/html/profile/posts.html");
|
||||||
|
|
||||||
// langs
|
// langs
|
||||||
pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml");
|
pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml");
|
||||||
|
|
||||||
|
@ -137,6 +140,9 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
|
||||||
write_template!(html_path->"auth/login.html"(crate::assets::AUTH_LOGIN) --config=config);
|
write_template!(html_path->"auth/login.html"(crate::assets::AUTH_LOGIN) --config=config);
|
||||||
write_template!(html_path->"auth/register.html"(crate::assets::AUTH_REGISTER) --config=config);
|
write_template!(html_path->"auth/register.html"(crate::assets::AUTH_REGISTER) --config=config);
|
||||||
|
|
||||||
|
write_template!(html_path->"profile/base.html"(crate::assets::PROFILE_BASE) -d "profile" --config=config);
|
||||||
|
write_template!(html_path->"profile/posts.html"(crate::assets::PROFILE_POSTS) --config=config);
|
||||||
|
|
||||||
html_path
|
html_path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,3 +15,6 @@ version = "1.0.0"
|
||||||
"auth:action.logout" = "Logout"
|
"auth:action.logout" = "Logout"
|
||||||
"auth:link.my_profile" = "My profile"
|
"auth:link.my_profile" = "My profile"
|
||||||
"auth:link.settings" = "Settings"
|
"auth:link.settings" = "Settings"
|
||||||
|
"auth:label.followers" = "Followers"
|
||||||
|
"auth:label.following" = "Following"
|
||||||
|
"auth:label.joined_journals" = "Joined Journals"
|
||||||
|
|
|
@ -88,6 +88,10 @@ footer {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
article {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 900px) {
|
@media screen and (max-width: 900px) {
|
||||||
main,
|
main,
|
||||||
article,
|
article,
|
||||||
|
@ -96,6 +100,10 @@ footer {
|
||||||
footer {
|
footer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
article {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.content_container {
|
.content_container {
|
||||||
|
@ -135,6 +143,8 @@ footer {
|
||||||
svg.icon {
|
svg.icon {
|
||||||
stroke: currentColor;
|
stroke: currentColor;
|
||||||
width: 18px;
|
width: 18px;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
svg.icon.filled {
|
svg.icon.filled {
|
||||||
|
@ -360,6 +370,14 @@ button,
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button.small,
|
||||||
|
.button.small {
|
||||||
|
min-height: max-content;
|
||||||
|
padding: 0.25rem;
|
||||||
|
height: 24px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
button:hover,
|
button:hover,
|
||||||
.button:hover {
|
.button:hover {
|
||||||
background: var(--color-primary-lowered);
|
background: var(--color-primary-lowered);
|
||||||
|
@ -490,6 +508,15 @@ select:focus {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* chip */
|
||||||
|
.chip {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: var(--circle);
|
||||||
|
padding: 0.05rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* nav */
|
/* nav */
|
||||||
nav {
|
nav {
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
|
@ -1041,6 +1068,7 @@ details.accordion .inner {
|
||||||
|
|
||||||
.sm\:w-full {
|
.sm\:w-full {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
|
min-width: 100% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sm\:mt-2 {
|
.sm\:mt-2 {
|
||||||
|
|
|
@ -33,7 +33,7 @@
|
||||||
|
|
||||||
<div class="inner">
|
<div class="inner">
|
||||||
<b class="title">{{ user.username }}</b>
|
<b class="title">{{ user.username }}</b>
|
||||||
<a href="/@{{ user.username }}">
|
<a href="/user/{{ user.username }}">
|
||||||
{{ icon "book-heart" }}
|
{{ icon "book-heart" }}
|
||||||
<span>{{ text "auth:link.my_profile" }}</span>
|
<span>{{ text "auth:link.my_profile" }}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
{% import "macros.html" as macros %} {% extends "root.html" %} {% block body %}
|
{% import "macros.html" as macros %} {% extends "root.html" %} {% block body %}
|
||||||
{{ macros::nav(selected="home") }}
|
{{ macros::nav(selected="home") }}
|
||||||
|
|
||||||
<main class="flex flex-col gap-2">
|
<main class="flex flex-col gap-2">
|
||||||
<div class="card-nest">
|
<div class="card-nest">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
80
crates/app/src/public/html/profile/base.html
Normal file
80
crates/app/src/public/html/profile/base.html
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
{% import "macros.html" as macros %} {% extends "root.html" %} {% block head %}
|
||||||
|
<title>{{ profile.username }} - {{ config.name }}</title>
|
||||||
|
{% endblock %} {% block body %} {{ macros::nav() }}
|
||||||
|
<article>
|
||||||
|
<div class="content_container">
|
||||||
|
<div class="w-full flex gap-4 flex-collapse">
|
||||||
|
<div
|
||||||
|
class="lhs flex flex-col gap-2 sm:w-full"
|
||||||
|
style="min-width: 20rem"
|
||||||
|
>
|
||||||
|
<div class="card-nest w-full">
|
||||||
|
<div class="card flex gap-2" id="user_avatar_and_name">
|
||||||
|
{{ macros::avatar(username=profile.username,size="72px")
|
||||||
|
}}
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<h3 id="username">
|
||||||
|
{% if profile.settings.display_name %} {{
|
||||||
|
profile.settings.display_name }} {% else %} {{
|
||||||
|
profile.username }} {% endif %}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<span class="fade">{{ profile.username }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card flex" id="social">
|
||||||
|
<div
|
||||||
|
class="w-full flex justify-center items-center gap-2"
|
||||||
|
>
|
||||||
|
<h4>{{ profile.follower_count }}</h4>
|
||||||
|
<span>{{ text "auth:label.followers" }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="w-full flex justify-center items-center gap-2"
|
||||||
|
>
|
||||||
|
<h4>{{ profile.following_count }}</h4>
|
||||||
|
<span>{{ text "auth:label.following" }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-nest flex flex-col">
|
||||||
|
<div id="bio" class="card">
|
||||||
|
{{ profile.settings.biography }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card flex flex-col gap-2">
|
||||||
|
<div class="w-full flex justify-between items-center">
|
||||||
|
<span class="notification chip">ID</span>
|
||||||
|
<button
|
||||||
|
title="Copy"
|
||||||
|
onclick="trigger('atto::copy_text', [{{ profile.id }}])"
|
||||||
|
class="camo small"
|
||||||
|
>
|
||||||
|
{{ icon "copy" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full flex justify-between items-center">
|
||||||
|
<span class="notification chip">Joined</span>
|
||||||
|
<span class="date">{{ profile.created }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-nest">
|
||||||
|
<div class="card flex gap-2 items-center">
|
||||||
|
{{ icon "users-round" }}
|
||||||
|
<span>{{ text "auth:label.joined_journals" }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card flex flex-wrap gap-2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rhs sm:w-full">{% block content %}{% endblock %}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
2
crates/app/src/public/html/profile/posts.html
Normal file
2
crates/app/src/public/html/profile/posts.html
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
{% import "macros.html" as macros %} {% extends "profile/base.html" %} {% block
|
||||||
|
content %}<span></span>{% endblock %}
|
|
@ -56,7 +56,9 @@
|
||||||
<body>
|
<body>
|
||||||
<div id="toast_zone"></div>
|
<div id="toast_zone"></div>
|
||||||
|
|
||||||
{% block body %}{% endblock %}
|
<div id="page" style="display: contents">
|
||||||
|
{% block body %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<script data-turbo-permanent="true" id="init-script">
|
<script data-turbo-permanent="true" id="init-script">
|
||||||
document.documentElement.addEventListener("turbo:load", () => {
|
document.documentElement.addEventListener("turbo:load", () => {
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
pub mod images;
|
pub mod images;
|
||||||
|
pub mod profile;
|
||||||
pub mod social;
|
pub mod social;
|
||||||
|
|
||||||
use super::AuthProps;
|
use super::AuthProps;
|
||||||
|
|
36
crates/app/src/routes/api/v1/auth/profile.rs
Normal file
36
crates/app/src/routes/api/v1/auth/profile.rs
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
use crate::{
|
||||||
|
State, get_user_from_token,
|
||||||
|
model::{ApiReturn, Error},
|
||||||
|
};
|
||||||
|
use axum::{Extension, Json, extract::Path, response::IntoResponse};
|
||||||
|
use axum_extra::extract::CookieJar;
|
||||||
|
use tetratto_core::model::{auth::UserSettings, permissions::FinePermission};
|
||||||
|
|
||||||
|
/// Update the settings of the given user.
|
||||||
|
pub async fn update_profile_settings_request(
|
||||||
|
jar: CookieJar,
|
||||||
|
Path(id): Path<usize>,
|
||||||
|
Extension(data): Extension<State>,
|
||||||
|
Json(req): Json<UserSettings>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let data = &(data.read().await).0;
|
||||||
|
let user = match get_user_from_token!(jar, data) {
|
||||||
|
Some(ua) => ua,
|
||||||
|
None => return Json(Error::NotAllowed.into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if user.id != id {
|
||||||
|
if !user.permissions.check(FinePermission::MANAGE_USERS) {
|
||||||
|
return Json(Error::NotAllowed.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match data.update_user_settings(id, req).await {
|
||||||
|
Ok(_) => Json(ApiReturn {
|
||||||
|
ok: true,
|
||||||
|
message: "User unfollowed".to_string(),
|
||||||
|
payload: (),
|
||||||
|
}),
|
||||||
|
Err(e) => Json(e.into()),
|
||||||
|
}
|
||||||
|
}
|
|
@ -78,6 +78,10 @@ pub fn routes() -> Router {
|
||||||
"/auth/profile/{id}/block",
|
"/auth/profile/{id}/block",
|
||||||
post(auth::social::block_request),
|
post(auth::social::block_request),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/auth/profile/{id}/settings",
|
||||||
|
post(auth::profile::update_profile_settings_request),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod misc;
|
pub mod misc;
|
||||||
|
pub mod profile;
|
||||||
|
|
||||||
use axum::{Router, routing::get};
|
use axum::{Router, routing::get};
|
||||||
|
|
||||||
|
@ -10,4 +11,6 @@ pub fn routes() -> Router {
|
||||||
// auth
|
// auth
|
||||||
.route("/auth/register", get(auth::register_request))
|
.route("/auth/register", get(auth::register_request))
|
||||||
.route("/auth/login", get(auth::login_request))
|
.route("/auth/login", get(auth::login_request))
|
||||||
|
// profile
|
||||||
|
.route("/user/{username}", get(profile::posts_request))
|
||||||
}
|
}
|
||||||
|
|
30
crates/app/src/routes/pages/profile.rs
Normal file
30
crates/app/src/routes/pages/profile.rs
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
use crate::{State, assets::initial_context, get_lang, get_user_from_token};
|
||||||
|
use axum::{
|
||||||
|
Extension,
|
||||||
|
extract::Path,
|
||||||
|
response::{Html, IntoResponse},
|
||||||
|
};
|
||||||
|
use axum_extra::extract::CookieJar;
|
||||||
|
|
||||||
|
/// `/user/{username}`
|
||||||
|
pub async fn posts_request(
|
||||||
|
jar: CookieJar,
|
||||||
|
Path(username): Path<String>,
|
||||||
|
Extension(data): Extension<State>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let data = data.read().await;
|
||||||
|
let user = get_user_from_token!(jar, data.0);
|
||||||
|
|
||||||
|
let other_user = match data.0.get_user_by_username(&username).await {
|
||||||
|
Ok(ua) => ua,
|
||||||
|
Err(e) => return Err(Html(e.to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
let lang = get_lang!(jar, data.0);
|
||||||
|
let mut context = initial_context(&data.0.0, lang, &user);
|
||||||
|
context.insert("profile", &other_user);
|
||||||
|
|
||||||
|
Ok(Html(
|
||||||
|
data.1.render("profile/posts.html", &mut context).unwrap(),
|
||||||
|
))
|
||||||
|
}
|
|
@ -2,7 +2,7 @@ use super::*;
|
||||||
use crate::cache::Cache;
|
use crate::cache::Cache;
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
Error, Result,
|
Error, Result,
|
||||||
auth::{Token, User},
|
auth::{Token, User, UserSettings},
|
||||||
permissions::FinePermission,
|
permissions::FinePermission,
|
||||||
};
|
};
|
||||||
use crate::{auto_method, execute, get, query_row};
|
use crate::{auto_method, execute, get, query_row};
|
||||||
|
@ -145,6 +145,7 @@ impl DataManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
auto_method!(update_user_tokens(Vec<Token>) -> "UPDATE users SET tokens = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.user:{}");
|
auto_method!(update_user_tokens(Vec<Token>) -> "UPDATE users SET tokens = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.user:{}");
|
||||||
|
auto_method!(update_user_settings(UserSettings) -> "UPDATE users SET settings = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.user:{}");
|
||||||
|
|
||||||
auto_method!(incr_user_notifications() -> "UPDATE users SET notification_count = notification_count + 1 WHERE id = $1" --cache-key-tmpl="atto.user:{}" --incr);
|
auto_method!(incr_user_notifications() -> "UPDATE users SET notification_count = notification_count + 1 WHERE id = $1" --cache-key-tmpl="atto.user:{}" --incr);
|
||||||
auto_method!(decr_user_notifications() -> "UPDATE users SET notification_count = notification_count - 1 WHERE id = $1" --cache-key-tmpl="atto.user:{}" --decr);
|
auto_method!(decr_user_notifications() -> "UPDATE users SET notification_count = notification_count - 1 WHERE id = $1" --cache-key-tmpl="atto.user:{}" --decr);
|
||||||
|
|
|
@ -50,6 +50,10 @@ macro_rules! auto_method {
|
||||||
|
|
||||||
($name:ident()@$select_fn:ident -> $query:literal --name=$name_:literal --returns=$returns_:tt --cache-key-tmpl=$cache_key_tmpl:literal) => {
|
($name:ident()@$select_fn:ident -> $query:literal --name=$name_:literal --returns=$returns_:tt --cache-key-tmpl=$cache_key_tmpl:literal) => {
|
||||||
pub async fn $name(&self, id: usize) -> Result<$returns_> {
|
pub async fn $name(&self, id: usize) -> Result<$returns_> {
|
||||||
|
if let Some(cached) = self.2.get(format!($cache_key_tmpl, id)).await {
|
||||||
|
return Ok(serde_json::from_str(&cached).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
let conn = match self.connect().await {
|
let conn = match self.connect().await {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||||
|
@ -94,6 +98,10 @@ macro_rules! auto_method {
|
||||||
|
|
||||||
($name:ident($selector_t:ty)@$select_fn:ident -> $query:literal --name=$name_:literal --returns=$returns_:tt --cache-key-tmpl=$cache_key_tmpl:literal) => {
|
($name:ident($selector_t:ty)@$select_fn:ident -> $query:literal --name=$name_:literal --returns=$returns_:tt --cache-key-tmpl=$cache_key_tmpl:literal) => {
|
||||||
pub async fn $name(&self, selector: $selector_t) -> Result<$returns_> {
|
pub async fn $name(&self, selector: $selector_t) -> Result<$returns_> {
|
||||||
|
if let Some(cached) = self.2.get(format!($cache_key_tmpl, selector)).await {
|
||||||
|
return Ok(serde_json::from_str(&cached).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
let conn = match self.connect().await {
|
let conn = match self.connect().await {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||||
|
|
|
@ -9,7 +9,7 @@ use tetratto_shared::{
|
||||||
/// `(ip, token, creation timestamp)`
|
/// `(ip, token, creation timestamp)`
|
||||||
pub type Token = (String, String, usize);
|
pub type Token = (String, String, usize);
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
pub id: usize,
|
pub id: usize,
|
||||||
pub created: usize,
|
pub created: usize,
|
||||||
|
@ -25,11 +25,22 @@ pub struct User {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct UserSettings;
|
pub struct UserSettings {
|
||||||
|
#[serde(default)]
|
||||||
|
pub display_name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub biography: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub private_profile: bool,
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for UserSettings {
|
impl Default for UserSettings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {}
|
Self {
|
||||||
|
display_name: String::new(),
|
||||||
|
biography: String::new(),
|
||||||
|
private_profile: false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -79,7 +90,7 @@ impl User {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct Notification {
|
pub struct Notification {
|
||||||
pub id: usize,
|
pub id: usize,
|
||||||
pub created: usize,
|
pub created: usize,
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue