Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2023b24d92 | ||
|
|
aea4782e68 |
@@ -387,6 +387,11 @@ profile-last-name = Surname
|
|||||||
profile-edit = Edit profile
|
profile-edit = Edit profile
|
||||||
profile-cancel = Cancel
|
profile-cancel = Cancel
|
||||||
profile-not-set = Not set
|
profile-not-set = Not set
|
||||||
|
profile-avatar = Profile picture
|
||||||
|
profile-avatar-hint = PNG, JPG, WEBP or GIF, up to 10 MB.
|
||||||
|
profile-avatar-choose = Choose a picture
|
||||||
|
profile-avatar-upload = Upload
|
||||||
|
profile-avatar-remove = Remove picture
|
||||||
nav-account = My account
|
nav-account = My account
|
||||||
account-orders = My orders
|
account-orders = My orders
|
||||||
account-change-password = Change password
|
account-change-password = Change password
|
||||||
@@ -507,6 +512,8 @@ brand-subtitle = medical supplies
|
|||||||
top-contact = Contact
|
top-contact = Contact
|
||||||
top-sitemap = Sitemap
|
top-sitemap = Sitemap
|
||||||
search-button = Search
|
search-button = Search
|
||||||
|
search-scope-in = Searching in:
|
||||||
|
search-scope-all = Search the whole shop
|
||||||
welcome = Welcome
|
welcome = Welcome
|
||||||
cart-units = items
|
cart-units = items
|
||||||
hotline = +421 903 410 476
|
hotline = +421 903 410 476
|
||||||
|
|||||||
@@ -387,6 +387,11 @@ profile-last-name = Priezvisko
|
|||||||
profile-edit = Upraviť profil
|
profile-edit = Upraviť profil
|
||||||
profile-cancel = Zrušiť
|
profile-cancel = Zrušiť
|
||||||
profile-not-set = Neuvedené
|
profile-not-set = Neuvedené
|
||||||
|
profile-avatar = Profilová fotka
|
||||||
|
profile-avatar-hint = PNG, JPG, WEBP alebo GIF, max. 10 MB.
|
||||||
|
profile-avatar-choose = Vybrať fotku
|
||||||
|
profile-avatar-upload = Nahrať
|
||||||
|
profile-avatar-remove = Odstrániť fotku
|
||||||
nav-account = Môj účet
|
nav-account = Môj účet
|
||||||
account-orders = Moje objednávky
|
account-orders = Moje objednávky
|
||||||
account-change-password = Zmeniť heslo
|
account-change-password = Zmeniť heslo
|
||||||
@@ -507,6 +512,8 @@ brand-subtitle = zdravotnícke potreby
|
|||||||
top-contact = Kontakt
|
top-contact = Kontakt
|
||||||
top-sitemap = Mapa stránky
|
top-sitemap = Mapa stránky
|
||||||
search-button = Hľadať
|
search-button = Hľadať
|
||||||
|
search-scope-in = Hľadáte v kategórii:
|
||||||
|
search-scope-all = Hľadať v celom obchode
|
||||||
welcome = Vitajte
|
welcome = Vitajte
|
||||||
cart-units = ks
|
cart-units = ks
|
||||||
hotline = +421 903 410 476
|
hotline = +421 903 410 476
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -28,6 +28,45 @@
|
|||||||
{{ ui::alert_danger(message=t(key="profile-company-required", lang=lang | default(value='sk')), extra="mt-4") }}
|
{{ ui::alert_danger(message=t(key="profile-company-required", lang=lang | default(value='sk')), extra="mt-4") }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# initials fallback when no avatar is set, e.g. "Filip Priec" -> "FP" #}
|
||||||
|
{% set _name = name | default(value='') | trim %}
|
||||||
|
{% set _parts = _name | split(pat=' ') %}
|
||||||
|
{% set _initials = _parts.0 | truncate(length=1, end='') | upper %}
|
||||||
|
{% if _parts | length > 1 %}{% set _second = _parts | last | truncate(length=1, end='') | upper %}{% set _initials = _initials ~ _second %}{% endif %}
|
||||||
|
|
||||||
|
<!-- avatar: upload / replace / remove. Own multipart form, independent of the
|
||||||
|
profile edit toggle below, so it works in both view and edit modes. -->
|
||||||
|
<fieldset class="mt-6 space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt"
|
||||||
|
x-data="{ name: '' }">
|
||||||
|
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="profile-avatar", lang=lang | default(value='sk')) }}</legend>
|
||||||
|
<div class="flex items-center gap-5">
|
||||||
|
<span class="flex size-20 shrink-0 items-center justify-center overflow-hidden rounded-full border border-primary bg-primary text-2xl font-bold tracking-wider text-on-primary/90 dark:border-primary-dark dark:bg-primary-dark dark:text-on-primary-dark/90">
|
||||||
|
{%- if avatar_id %}<img src="/images/{{ avatar_id }}" alt="{{ _name }}" class="size-full object-cover">{% elif _initials %}{{ _initials }}{% endif -%}
|
||||||
|
</span>
|
||||||
|
<div class="min-w-0 space-y-3">
|
||||||
|
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="profile-avatar-hint", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<form method="post" action="/account/profile/avatar" enctype="multipart/form-data" hx-boost="false" class="flex flex-wrap items-center gap-3">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<label class="inline-flex cursor-pointer items-center gap-2 rounded-radius border border-outline bg-surface-alt px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-primary/5 hover:text-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark dark:hover:text-primary-dark">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4 shrink-0" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" /></svg>
|
||||||
|
<span class="truncate max-w-[12rem]" x-text="name || '{{ t(key='profile-avatar-choose', lang=lang | default(value='sk')) }}'">{{ t(key="profile-avatar-choose", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<input type="file" name="image" accept="image/png,image/jpeg,image/webp,image/gif" class="sr-only"
|
||||||
|
@change="name = $event.target.files.length ? $event.target.files[0].name : ''">
|
||||||
|
</label>
|
||||||
|
{{ ui::button(label=t(key="profile-avatar-upload", lang=lang | default(value='sk')), type="submit", size="px-4 py-2 text-sm", attrs='x-show="name" x-cloak') }}
|
||||||
|
</form>
|
||||||
|
{% if avatar_id %}
|
||||||
|
<form method="post" action="/account/profile/avatar/remove" hx-boost="false">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
{{ ui::button(label=t(key="profile-avatar-remove", lang=lang | default(value='sk')), type="submit", variant="outline-secondary", size="px-4 py-2 text-sm") }}
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<!-- read-only view (default) -->
|
<!-- read-only view (default) -->
|
||||||
<div x-show="!editing" class="mt-6 space-y-6">
|
<div x-show="!editing" class="mt-6 space-y-6">
|
||||||
<fieldset class="space-y-2 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
<fieldset class="space-y-2 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
x-bind:aria-expanded="isOpen || openedWithKeyboard" aria-haspopup="true"
|
x-bind:aria-expanded="isOpen || openedWithKeyboard" aria-haspopup="true"
|
||||||
aria-label="{{ t(key='nav-account', lang=lang | default(value='sk')) }}"
|
aria-label="{{ t(key='nav-account', lang=lang | default(value='sk')) }}"
|
||||||
class="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-full border border-primary bg-primary text-sm font-bold tracking-wider text-on-primary/90 transition hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-primary-dark dark:bg-primary-dark dark:text-on-primary-dark/90 dark:focus-visible:outline-primary-dark">
|
class="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-full border border-primary bg-primary text-sm font-bold tracking-wider text-on-primary/90 transition hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-primary-dark dark:bg-primary-dark dark:text-on-primary-dark/90 dark:focus-visible:outline-primary-dark">
|
||||||
{%- if _initials %}{{ _initials }}{% else %}{{ _person_icon | safe }}{% endif -%}
|
{%- if customer_avatar %}<img src="/images/{{ customer_avatar }}" alt="{{ _name }}" class="size-full object-cover">{% elif _initials %}{{ _initials }}{% else %}{{ _person_icon | safe }}{% endif -%}
|
||||||
</button>
|
</button>
|
||||||
<!-- Dropdown Menu (positioned like the settings cog: right-0 mt-2) -->
|
<!-- Dropdown Menu (positioned like the settings cog: right-0 mt-2) -->
|
||||||
<div x-cloak x-show="isOpen || openedWithKeyboard" x-transition x-trap="openedWithKeyboard"
|
<div x-cloak x-show="isOpen || openedWithKeyboard" x-transition x-trap="openedWithKeyboard"
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
<!-- header: avatar + name + account type -->
|
<!-- header: avatar + name + account type -->
|
||||||
<div class="flex items-center gap-3 px-4 py-2.5">
|
<div class="flex items-center gap-3 px-4 py-2.5">
|
||||||
<span class="flex size-11 shrink-0 items-center justify-center overflow-hidden rounded-full border border-primary bg-primary text-base font-bold tracking-wider text-on-primary/90 dark:border-primary-dark dark:bg-primary-dark dark:text-on-primary-dark/90">
|
<span class="flex size-11 shrink-0 items-center justify-center overflow-hidden rounded-full border border-primary bg-primary text-base font-bold tracking-wider text-on-primary/90 dark:border-primary-dark dark:bg-primary-dark dark:text-on-primary-dark/90">
|
||||||
{%- if _initials %}{{ _initials }}{% else %}{{ _person_icon | safe }}{% endif -%}
|
{%- if customer_avatar %}<img src="/images/{{ customer_avatar }}" alt="{{ _name }}" class="size-full object-cover">{% elif _initials %}{{ _initials }}{% else %}{{ _person_icon | safe }}{% endif -%}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex min-w-0 flex-col">
|
<div class="flex min-w-0 flex-col">
|
||||||
<span class="truncate text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ _name }}</span>
|
<span class="truncate text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ _name }}</span>
|
||||||
|
|||||||
@@ -47,6 +47,29 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Scope indicator: when a category is active, make clear the search is
|
||||||
|
limited to it (not the whole shop), with a one-click escape to search
|
||||||
|
everything. Category only changes via full navigation (the sidebar), so
|
||||||
|
this stays accurate across the toolbar's results-only htmx swaps. #}
|
||||||
|
{% if selected_category and selected_category != "all" %}
|
||||||
|
{# set_global so the value survives the nested if (a plain `set` inside a
|
||||||
|
block is scoped to that block in Tera and wouldn't be visible below). #}
|
||||||
|
{% set_global _scope = selected_category_name | default(value="") %}
|
||||||
|
{% if selected_category == "none" %}{% set_global _scope = t(key="uncategorized", lang=L) %}{% endif %}
|
||||||
|
{% if _scope %}
|
||||||
|
<div class="flex max-w-xl flex-wrap items-center gap-2 text-xs">
|
||||||
|
<span class="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1 font-medium text-primary dark:bg-primary-dark/15 dark:text-primary-dark">
|
||||||
|
{{ ui::icon(name="search", size="size-3.5", extra="shrink-0") }}
|
||||||
|
{{ t(key="search-scope-in", lang=L) }} <span class="font-semibold">{{ _scope }}</span>
|
||||||
|
</span>
|
||||||
|
<a href="/search{% if query %}?q={{ query | urlencode }}{% endif %}"
|
||||||
|
class="font-medium text-on-surface/60 underline-offset-2 hover:text-primary hover:underline dark:text-on-surface-dark/60 dark:hover:text-primary-dark">
|
||||||
|
{{ t(key="search-scope-all", lang=L) }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- sort + product card style switch -->
|
<!-- sort + product card style switch -->
|
||||||
<div class="flex flex-wrap items-center justify-end gap-3">
|
<div class="flex flex-wrap items-center justify-end gap-3">
|
||||||
<label class="flex items-center gap-2 text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70">
|
<label class="flex items-center gap-2 text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70">
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ mod m20260623_000001_add_short_description_to_products;
|
|||||||
mod m20260623_000002_strip_html_from_product_search;
|
mod m20260623_000002_strip_html_from_product_search;
|
||||||
mod m20260623_000003_drop_currency;
|
mod m20260623_000003_drop_currency;
|
||||||
mod m20260623_000004_currencies;
|
mod m20260623_000004_currencies;
|
||||||
|
mod m20260625_000001_add_avatar_to_users;
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -102,6 +103,7 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260623_000002_strip_html_from_product_search::Migration),
|
Box::new(m20260623_000002_strip_html_from_product_search::Migration),
|
||||||
Box::new(m20260623_000003_drop_currency::Migration),
|
Box::new(m20260623_000003_drop_currency::Migration),
|
||||||
Box::new(m20260623_000004_currencies::Migration),
|
Box::new(m20260623_000004_currencies::Migration),
|
||||||
|
Box::new(m20260625_000001_add_avatar_to_users::Migration),
|
||||||
// inject-above (do not remove this comment)
|
// inject-above (do not remove this comment)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
20
migration/src/m20260625_000001_add_avatar_to_users.rs
Normal file
20
migration/src/m20260625_000001_add_avatar_to_users.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
use loco_rs::schema::*;
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
// Optional profile avatar. `avatar_id` holds the stored image's filename (the
|
||||||
|
// same `<uuid>.<ext>` scheme as product/category images), served through the
|
||||||
|
// shared `/images/{filename}` route. NULL = no avatar, fall back to initials.
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
add_column(m, "users", "avatar_id", ColType::StringNull).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
remove_column(m, "users", "avatar_id").await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
//! on the user — it is shown here read-only and can never be changed. The
|
//! 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).
|
//! profile only edits the type-specific details (company identity + address).
|
||||||
|
|
||||||
|
use axum::extract::{DefaultBodyLimit, Multipart};
|
||||||
use axum_extra::extract::cookie::CookieJar;
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use sea_orm::QueryOrder;
|
use sea_orm::QueryOrder;
|
||||||
@@ -14,7 +15,11 @@ use serde::Deserialize;
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
controllers::i18n::current_lang,
|
controllers::{
|
||||||
|
admin_form::{read_multipart_form, store_image},
|
||||||
|
i18n::current_lang,
|
||||||
|
media::IMAGE_MAX_BYTES,
|
||||||
|
},
|
||||||
models::{
|
models::{
|
||||||
customer_profiles::{self, ProfileFields},
|
customer_profiles::{self, ProfileFields},
|
||||||
order_items, orders, users,
|
order_items, orders, users,
|
||||||
@@ -128,6 +133,8 @@ fn profile_view(
|
|||||||
"account_nav": true,
|
"account_nav": true,
|
||||||
"customer_name": user.name,
|
"customer_name": user.name,
|
||||||
"customer_account_type": user.account_type,
|
"customer_account_type": user.account_type,
|
||||||
|
"customer_avatar": user.avatar_id,
|
||||||
|
"avatar_id": user.avatar_id,
|
||||||
"saved": saved,
|
"saved": saved,
|
||||||
"error": error,
|
"error": error,
|
||||||
"name": user.name,
|
"name": user.name,
|
||||||
@@ -202,6 +209,64 @@ async fn save_profile(
|
|||||||
profile_view(&v, &jar, &user, &fields, true, false)
|
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.
|
/// Lists the signed-in customer's orders, split into still-active and past.
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
async fn orders_page(
|
async fn orders_page(
|
||||||
@@ -236,6 +301,7 @@ async fn orders_page(
|
|||||||
"account_nav": true,
|
"account_nav": true,
|
||||||
"customer_name": user.name,
|
"customer_name": user.name,
|
||||||
"customer_account_type": user.account_type,
|
"customer_account_type": user.account_type,
|
||||||
|
"customer_avatar": user.avatar_id,
|
||||||
"active_orders": shape(active),
|
"active_orders": shape(active),
|
||||||
"past_orders": shape(past),
|
"past_orders": shape(past),
|
||||||
"lang": current_lang(&jar),
|
"lang": current_lang(&jar),
|
||||||
@@ -278,6 +344,7 @@ async fn order_detail_page(
|
|||||||
"account_nav": true,
|
"account_nav": true,
|
||||||
"customer_name": user.name,
|
"customer_name": user.name,
|
||||||
"customer_account_type": user.account_type,
|
"customer_account_type": user.account_type,
|
||||||
|
"customer_avatar": user.avatar_id,
|
||||||
"order": order_view::detail(
|
"order": order_view::detail(
|
||||||
&order,
|
&order,
|
||||||
settings::get(&ctx, "bank_iban").unwrap_or(""),
|
settings::get(&ctx, "bank_iban").unwrap_or(""),
|
||||||
@@ -312,6 +379,7 @@ fn password_view(
|
|||||||
"account_nav": true,
|
"account_nav": true,
|
||||||
"customer_name": user.name,
|
"customer_name": user.name,
|
||||||
"customer_account_type": user.account_type,
|
"customer_account_type": user.account_type,
|
||||||
|
"customer_avatar": user.avatar_id,
|
||||||
"changed": changed,
|
"changed": changed,
|
||||||
"error": error,
|
"error": error,
|
||||||
"lang": current_lang(jar),
|
"lang": current_lang(jar),
|
||||||
@@ -406,6 +474,7 @@ fn security_view(
|
|||||||
"account_nav": true,
|
"account_nav": true,
|
||||||
"customer_name": user.name,
|
"customer_name": user.name,
|
||||||
"customer_account_type": user.account_type,
|
"customer_account_type": user.account_type,
|
||||||
|
"customer_avatar": user.avatar_id,
|
||||||
"totp_enabled": user.totp_enabled(),
|
"totp_enabled": user.totp_enabled(),
|
||||||
"enrolling": enrolling,
|
"enrolling": enrolling,
|
||||||
"qr": qr,
|
"qr": qr,
|
||||||
@@ -538,6 +607,11 @@ pub fn routes() -> Routes {
|
|||||||
Routes::new()
|
Routes::new()
|
||||||
.add("/account/profile", get(profile_page))
|
.add("/account/profile", get(profile_page))
|
||||||
.add("/account/profile", post(save_profile))
|
.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", get(orders_page))
|
||||||
.add("/account/orders/{order_number}", get(order_detail_page))
|
.add("/account/orders/{order_number}", get(order_detail_page))
|
||||||
.add("/account/password", get(change_password_page))
|
.add("/account/password", get(change_password_page))
|
||||||
|
|||||||
@@ -264,6 +264,7 @@ async fn show(
|
|||||||
"logged_in_customer": c.logged_in_customer,
|
"logged_in_customer": c.logged_in_customer,
|
||||||
"customer_name": c.customer_name,
|
"customer_name": c.customer_name,
|
||||||
"customer_account_type": c.customer_account_type,
|
"customer_account_type": c.customer_account_type,
|
||||||
|
"customer_avatar": c.customer_avatar,
|
||||||
"lang": current_lang(&jar),
|
"lang": current_lang(&jar),
|
||||||
}),
|
}),
|
||||||
)?;
|
)?;
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ async fn checkout_page(
|
|||||||
// logged_in_customer is true); None for admins/guests.
|
// logged_in_customer is true); None for admins/guests.
|
||||||
"customer_name": user.as_ref().filter(|_| is_customer).map(|u| u.name.clone()),
|
"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_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,
|
"profile_filled": profile_filled,
|
||||||
// A logged-in customer's account type is fixed; only guests pick it
|
// A logged-in customer's account type is fixed; only guests pick it
|
||||||
// and may opt to create an account from the order.
|
// 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,
|
"logged_in_customer": c.logged_in_customer,
|
||||||
"customer_name": c.customer_name,
|
"customer_name": c.customer_name,
|
||||||
"customer_account_type": c.customer_account_type,
|
"customer_account_type": c.customer_account_type,
|
||||||
|
"customer_avatar": c.customer_avatar,
|
||||||
"account_created": account_created,
|
"account_created": account_created,
|
||||||
"lang": current_lang(&jar),
|
"lang": current_lang(&jar),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ async fn index(
|
|||||||
"logged_in_customer": c.logged_in_customer,
|
"logged_in_customer": c.logged_in_customer,
|
||||||
"customer_name": c.customer_name,
|
"customer_name": c.customer_name,
|
||||||
"customer_account_type": c.customer_account_type,
|
"customer_account_type": c.customer_account_type,
|
||||||
|
"customer_avatar": c.customer_avatar,
|
||||||
"currency_symbol": cur.symbol,
|
"currency_symbol": cur.symbol,
|
||||||
"lang": current_lang(&jar),
|
"lang": current_lang(&jar),
|
||||||
// The header search bar only appears on the landing page.
|
// 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,
|
"logged_in_customer": c.logged_in_customer,
|
||||||
"customer_name": c.customer_name,
|
"customer_name": c.customer_name,
|
||||||
"customer_account_type": c.customer_account_type,
|
"customer_account_type": c.customer_account_type,
|
||||||
|
"customer_avatar": c.customer_avatar,
|
||||||
"currency_symbol": cur.symbol,
|
"currency_symbol": cur.symbol,
|
||||||
"lang": current_lang(jar),
|
"lang": current_lang(jar),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -250,6 +250,13 @@ async fn run_search(
|
|||||||
// Numeric form so the <select> can mark the active option (Tera can't
|
// Numeric form so the <select> can mark the active option (Tera can't
|
||||||
// compare a string param against a numeric category id).
|
// compare a string param against a numeric category id).
|
||||||
"selected_category_id": selected_category.parse::<i32>().unwrap_or(-1),
|
"selected_category_id": selected_category.parse::<i32>().unwrap_or(-1),
|
||||||
|
// Display name of the active category, so the search bar can show that
|
||||||
|
// the query is scoped to it. `None` for "all"/"none" (the template maps
|
||||||
|
// "none" to the localized "uncategorized" label itself).
|
||||||
|
"selected_category_name": selected_category
|
||||||
|
.parse::<i32>()
|
||||||
|
.ok()
|
||||||
|
.and_then(|id| category_name.get(&id).cloned()),
|
||||||
"uncategorized_count": uncategorized_count,
|
"uncategorized_count": uncategorized_count,
|
||||||
"sort": sort,
|
"sort": sort,
|
||||||
"per_page": per_page,
|
"per_page": per_page,
|
||||||
@@ -350,6 +357,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("logged_in_customer".into(), json!(c.logged_in_customer));
|
||||||
map.insert("customer_name".into(), json!(c.customer_name));
|
map.insert("customer_name".into(), json!(c.customer_name));
|
||||||
map.insert("customer_account_type".into(), json!(c.customer_account_type));
|
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));
|
map.insert("lang".into(), json!(lang));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -482,6 +490,7 @@ async fn show(
|
|||||||
"logged_in_customer": c.logged_in_customer,
|
"logged_in_customer": c.logged_in_customer,
|
||||||
"customer_name": c.customer_name,
|
"customer_name": c.customer_name,
|
||||||
"customer_account_type": c.customer_account_type,
|
"customer_account_type": c.customer_account_type,
|
||||||
|
"customer_avatar": c.customer_avatar,
|
||||||
"currency_symbol": cur.symbol,
|
"currency_symbol": cur.symbol,
|
||||||
"lang": current_lang(&jar),
|
"lang": current_lang(&jar),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ pub struct Model {
|
|||||||
pub totp_enabled_at: Option<DateTimeWithTimeZone>,
|
pub totp_enabled_at: Option<DateTimeWithTimeZone>,
|
||||||
#[sea_orm(column_type = "Text", nullable)]
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
pub totp_backup_codes: Option<String>,
|
pub totp_backup_codes: Option<String>,
|
||||||
|
pub avatar_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ pub struct Chrome {
|
|||||||
pub logged_in_customer: bool,
|
pub logged_in_customer: bool,
|
||||||
pub customer_name: Option<String>,
|
pub customer_name: Option<String>,
|
||||||
pub customer_account_type: 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 {
|
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,
|
logged_in_customer: true,
|
||||||
customer_name: Some(user.name.clone()),
|
customer_name: Some(user.name.clone()),
|
||||||
customer_account_type: Some(user.account_type.clone()),
|
customer_account_type: Some(user.account_type.clone()),
|
||||||
|
customer_avatar: user.avatar_id.clone(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
None => Chrome::default(),
|
None => Chrome::default(),
|
||||||
|
|||||||
Reference in New Issue
Block a user