avatar
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
//! on the user — it is shown here read-only and can never be changed. The
|
||||
//! profile only edits the type-specific details (company identity + address).
|
||||
|
||||
use axum::extract::{DefaultBodyLimit, Multipart};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::QueryOrder;
|
||||
@@ -14,7 +15,11 @@ use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
controllers::i18n::current_lang,
|
||||
controllers::{
|
||||
admin_form::{read_multipart_form, store_image},
|
||||
i18n::current_lang,
|
||||
media::IMAGE_MAX_BYTES,
|
||||
},
|
||||
models::{
|
||||
customer_profiles::{self, ProfileFields},
|
||||
order_items, orders, users,
|
||||
@@ -128,6 +133,8 @@ fn profile_view(
|
||||
"account_nav": true,
|
||||
"customer_name": user.name,
|
||||
"customer_account_type": user.account_type,
|
||||
"customer_avatar": user.avatar_id,
|
||||
"avatar_id": user.avatar_id,
|
||||
"saved": saved,
|
||||
"error": error,
|
||||
"name": user.name,
|
||||
@@ -202,6 +209,64 @@ async fn save_profile(
|
||||
profile_view(&v, &jar, &user, &fields, true, false)
|
||||
}
|
||||
|
||||
/// Persist `avatar_id` (a stored image filename, or `None` to clear) on the
|
||||
/// signed-in customer and re-render the profile page with the success banner.
|
||||
async fn set_avatar(
|
||||
v: &TeraView,
|
||||
jar: &CookieJar,
|
||||
ctx: &AppContext,
|
||||
user: users::Model,
|
||||
avatar_id: Option<String>,
|
||||
) -> Result<Response> {
|
||||
let mut active = user.clone().into_active_model();
|
||||
active.avatar_id = ActiveValue::set(avatar_id.clone());
|
||||
let user = active.update(&ctx.db).await?;
|
||||
let profile = customer_profiles::Model::find_for_user(&ctx.db, user.id).await?;
|
||||
profile_view(v, jar, &user, &fields_of(profile.as_ref()), true, false)
|
||||
}
|
||||
|
||||
/// Upload (or replace) the signed-in customer's avatar picture. The single
|
||||
/// `image` file part is validated and stored through the shared image storage,
|
||||
/// then its generated filename is saved as the user's `avatar_id`.
|
||||
#[debug_handler]
|
||||
async fn upload_avatar(
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
multipart: Multipart,
|
||||
) -> Result<Response> {
|
||||
let Some(user) = guard::current_user(&ctx, &jar).await else {
|
||||
return format::redirect("/login");
|
||||
};
|
||||
if guard::is_admin(&ctx, &user) {
|
||||
return format::redirect("/admin/dashboard");
|
||||
}
|
||||
let form = read_multipart_form(multipart).await?;
|
||||
let Some(image) = form.single_image() else {
|
||||
// No file chosen — nothing to do, just re-show the profile.
|
||||
let profile = customer_profiles::Model::find_for_user(&ctx.db, user.id).await?;
|
||||
return profile_view(&v, &jar, &user, &fields_of(profile.as_ref()), false, false);
|
||||
};
|
||||
let filename = store_image(&ctx, image).await?;
|
||||
set_avatar(&v, &jar, &ctx, user, Some(filename)).await
|
||||
}
|
||||
|
||||
/// Remove the signed-in customer's avatar, reverting to the initials fallback.
|
||||
#[debug_handler]
|
||||
async fn remove_avatar(
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let Some(user) = guard::current_user(&ctx, &jar).await else {
|
||||
return format::redirect("/login");
|
||||
};
|
||||
if guard::is_admin(&ctx, &user) {
|
||||
return format::redirect("/admin/dashboard");
|
||||
}
|
||||
set_avatar(&v, &jar, &ctx, user, None).await
|
||||
}
|
||||
|
||||
/// Lists the signed-in customer's orders, split into still-active and past.
|
||||
#[debug_handler]
|
||||
async fn orders_page(
|
||||
@@ -236,6 +301,7 @@ async fn orders_page(
|
||||
"account_nav": true,
|
||||
"customer_name": user.name,
|
||||
"customer_account_type": user.account_type,
|
||||
"customer_avatar": user.avatar_id,
|
||||
"active_orders": shape(active),
|
||||
"past_orders": shape(past),
|
||||
"lang": current_lang(&jar),
|
||||
@@ -278,6 +344,7 @@ async fn order_detail_page(
|
||||
"account_nav": true,
|
||||
"customer_name": user.name,
|
||||
"customer_account_type": user.account_type,
|
||||
"customer_avatar": user.avatar_id,
|
||||
"order": order_view::detail(
|
||||
&order,
|
||||
settings::get(&ctx, "bank_iban").unwrap_or(""),
|
||||
@@ -312,6 +379,7 @@ fn password_view(
|
||||
"account_nav": true,
|
||||
"customer_name": user.name,
|
||||
"customer_account_type": user.account_type,
|
||||
"customer_avatar": user.avatar_id,
|
||||
"changed": changed,
|
||||
"error": error,
|
||||
"lang": current_lang(jar),
|
||||
@@ -406,6 +474,7 @@ fn security_view(
|
||||
"account_nav": true,
|
||||
"customer_name": user.name,
|
||||
"customer_account_type": user.account_type,
|
||||
"customer_avatar": user.avatar_id,
|
||||
"totp_enabled": user.totp_enabled(),
|
||||
"enrolling": enrolling,
|
||||
"qr": qr,
|
||||
@@ -538,6 +607,11 @@ pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.add("/account/profile", get(profile_page))
|
||||
.add("/account/profile", post(save_profile))
|
||||
.add(
|
||||
"/account/profile/avatar",
|
||||
post(upload_avatar).layer(DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024)),
|
||||
)
|
||||
.add("/account/profile/avatar/remove", post(remove_avatar))
|
||||
.add("/account/orders", get(orders_page))
|
||||
.add("/account/orders/{order_number}", get(order_detail_page))
|
||||
.add("/account/password", get(change_password_page))
|
||||
|
||||
@@ -264,6 +264,7 @@ async fn show(
|
||||
"logged_in_customer": c.logged_in_customer,
|
||||
"customer_name": c.customer_name,
|
||||
"customer_account_type": c.customer_account_type,
|
||||
"customer_avatar": c.customer_avatar,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)?;
|
||||
|
||||
@@ -132,6 +132,7 @@ async fn checkout_page(
|
||||
// logged_in_customer is true); None for admins/guests.
|
||||
"customer_name": user.as_ref().filter(|_| is_customer).map(|u| u.name.clone()),
|
||||
"customer_account_type": user.as_ref().filter(|_| is_customer).map(|u| u.account_type.clone()),
|
||||
"customer_avatar": user.as_ref().filter(|_| is_customer).and_then(|u| u.avatar_id.clone()),
|
||||
"profile_filled": profile_filled,
|
||||
// A logged-in customer's account type is fixed; only guests pick it
|
||||
// and may opt to create an account from the order.
|
||||
@@ -375,6 +376,7 @@ async fn order_confirmation(
|
||||
"logged_in_customer": c.logged_in_customer,
|
||||
"customer_name": c.customer_name,
|
||||
"customer_account_type": c.customer_account_type,
|
||||
"customer_avatar": c.customer_avatar,
|
||||
"account_created": account_created,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
|
||||
@@ -28,6 +28,7 @@ async fn index(
|
||||
"logged_in_customer": c.logged_in_customer,
|
||||
"customer_name": c.customer_name,
|
||||
"customer_account_type": c.customer_account_type,
|
||||
"customer_avatar": c.customer_avatar,
|
||||
"currency_symbol": cur.symbol,
|
||||
"lang": current_lang(&jar),
|
||||
// The header search bar only appears on the landing page.
|
||||
|
||||
@@ -25,6 +25,7 @@ async fn render(v: &TeraView, jar: &CookieJar, ctx: &AppContext, page: &str) ->
|
||||
"logged_in_customer": c.logged_in_customer,
|
||||
"customer_name": c.customer_name,
|
||||
"customer_account_type": c.customer_account_type,
|
||||
"customer_avatar": c.customer_avatar,
|
||||
"currency_symbol": cur.symbol,
|
||||
"lang": current_lang(jar),
|
||||
}),
|
||||
|
||||
@@ -350,6 +350,7 @@ fn add_chrome(ctx_value: &mut serde_json::Value, c: &guard::Chrome, lang: &str)
|
||||
map.insert("logged_in_customer".into(), json!(c.logged_in_customer));
|
||||
map.insert("customer_name".into(), json!(c.customer_name));
|
||||
map.insert("customer_account_type".into(), json!(c.customer_account_type));
|
||||
map.insert("customer_avatar".into(), json!(c.customer_avatar));
|
||||
map.insert("lang".into(), json!(lang));
|
||||
}
|
||||
}
|
||||
@@ -482,6 +483,7 @@ async fn show(
|
||||
"logged_in_customer": c.logged_in_customer,
|
||||
"customer_name": c.customer_name,
|
||||
"customer_account_type": c.customer_account_type,
|
||||
"customer_avatar": c.customer_avatar,
|
||||
"currency_symbol": cur.symbol,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
|
||||
@@ -31,6 +31,7 @@ pub struct Model {
|
||||
pub totp_enabled_at: Option<DateTimeWithTimeZone>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub totp_backup_codes: Option<String>,
|
||||
pub avatar_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
|
||||
@@ -56,6 +56,9 @@ pub struct Chrome {
|
||||
pub logged_in_customer: bool,
|
||||
pub customer_name: Option<String>,
|
||||
pub customer_account_type: Option<String>,
|
||||
/// Stored avatar image filename (served via `/images/{filename}`), set only
|
||||
/// for a logged-in customer who uploaded one. `None` -> initials fallback.
|
||||
pub customer_avatar: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn chrome(ctx: &AppContext, jar: &CookieJar) -> Chrome {
|
||||
@@ -74,6 +77,7 @@ pub fn chrome_from(ctx: &AppContext, user: Option<&users::Model>) -> Chrome {
|
||||
logged_in_customer: true,
|
||||
customer_name: Some(user.name.clone()),
|
||||
customer_account_type: Some(user.account_type.clone()),
|
||||
customer_avatar: user.avatar_id.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
None => Chrome::default(),
|
||||
|
||||
Reference in New Issue
Block a user