2 Commits

Author SHA1 Message Date
Priec
2023b24d92 search notify where we are searching
Some checks are pending
CI / Check Style (push) Waiting to run
CI / Run Clippy (push) Waiting to run
CI / Run Tests (push) Waiting to run
2026-06-25 20:46:46 +02:00
Priec
aea4782e68 avatar 2026-06-25 19:24:50 +02:00
16 changed files with 195 additions and 4 deletions

View File

@@ -387,6 +387,11 @@ profile-last-name = Surname
profile-edit = Edit profile
profile-cancel = Cancel
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
account-orders = My orders
account-change-password = Change password
@@ -507,6 +512,8 @@ brand-subtitle = medical supplies
top-contact = Contact
top-sitemap = Sitemap
search-button = Search
search-scope-in = Searching in:
search-scope-all = Search the whole shop
welcome = Welcome
cart-units = items
hotline = +421 903 410 476

View File

@@ -387,6 +387,11 @@ profile-last-name = Priezvisko
profile-edit = Upraviť profil
profile-cancel = Zrušiť
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
account-orders = Moje objednávky
account-change-password = Zmeniť heslo
@@ -507,6 +512,8 @@ brand-subtitle = zdravotnícke potreby
top-contact = Kontakt
top-sitemap = Mapa stránky
search-button = Hľadať
search-scope-in = Hľadáte v kategórii:
search-scope-all = Hľadať v celom obchode
welcome = Vitajte
cart-units = ks
hotline = +421 903 410 476

File diff suppressed because one or more lines are too long

View File

@@ -28,6 +28,45 @@
{{ ui::alert_danger(message=t(key="profile-company-required", lang=lang | default(value='sk')), extra="mt-4") }}
{% 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) -->
<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">

View File

@@ -30,7 +30,7 @@
x-bind:aria-expanded="isOpen || openedWithKeyboard" aria-haspopup="true"
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">
{%- 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>
<!-- Dropdown Menu (positioned like the settings cog: right-0 mt-2) -->
<div x-cloak x-show="isOpen || openedWithKeyboard" x-transition x-trap="openedWithKeyboard"
@@ -40,7 +40,7 @@
<!-- header: avatar + name + account type -->
<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">
{%- 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>
<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>

View File

@@ -47,6 +47,29 @@
</button>
</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 -->
<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">

View File

@@ -49,6 +49,7 @@ mod m20260623_000001_add_short_description_to_products;
mod m20260623_000002_strip_html_from_product_search;
mod m20260623_000003_drop_currency;
mod m20260623_000004_currencies;
mod m20260625_000001_add_avatar_to_users;
pub struct Migrator;
#[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_000003_drop_currency::Migration),
Box::new(m20260623_000004_currencies::Migration),
Box::new(m20260625_000001_add_avatar_to_users::Migration),
// inject-above (do not remove this comment)
]
}

View 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
}
}

View File

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

View File

@@ -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),
}),
)?;

View File

@@ -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),
}),

View File

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

View File

@@ -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),
}),

View File

@@ -250,6 +250,13 @@ async fn run_search(
// Numeric form so the <select> can mark the active option (Tera can't
// compare a string param against a numeric category id).
"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,
"sort": sort,
"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("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 +490,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),
}),

View File

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

View File

@@ -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(),