small fixes

This commit is contained in:
Priec
2026-07-29 20:44:03 +02:00
parent 0e284dbf07
commit aac19831e3
5 changed files with 64 additions and 10 deletions

View File

@@ -29,14 +29,29 @@
if (currentTheme() === 'system') applyTheme('system'); if (currentTheme() === 'system') applyTheme('system');
}); });
// Mark the active top-nav link via aria-current (styled with Tailwind). // Mark the active top-nav link via aria-current (styled with Tailwind).
//
// Pages that live under a category but not at its URL (a product detail
// page) declare it via <main data-active-category>; without that the
// category sidebar would show nothing selected the moment you open a
// product. `window.activeCategory` is read by the sidebar's accordion
// groups, which are hx-preserve'd and so initialise only once — the
// `nav-category` event is what reaches them on later navigations.
function markActiveNav() { function markActiveNav() {
var path = location.pathname; var path = location.pathname;
var main = document.querySelector('main[data-active-category]');
var cat = main ? main.getAttribute('data-active-category') : '';
var catPath = cat ? '/category/' + cat : null;
window.activeCategory = cat;
document.querySelectorAll('a[data-nav]').forEach(function (a) { document.querySelectorAll('a[data-nav]').forEach(function (a) {
var h = a.getAttribute('data-nav'); var h = a.getAttribute('data-nav');
var on = h === path || (h !== '/' && path.indexOf(h) === 0); // A declared category wins outright: on /shop/{slug} the prefix rule
// would otherwise light up "all products" alongside the category.
var on = h === path || h === catPath
|| (!catPath && h !== '/' && path.indexOf(h) === 0);
if (on) a.setAttribute('aria-current', 'page'); if (on) a.setAttribute('aria-current', 'page');
else a.removeAttribute('aria-current'); else a.removeAttribute('aria-current');
}); });
if (cat) window.dispatchEvent(new CustomEvent('nav-category', { detail: cat }));
} }
document.addEventListener('DOMContentLoaded', markActiveNav); document.addEventListener('DOMContentLoaded', markActiveNav);
document.addEventListener('htmx:afterSwap', markActiveNav); document.addEventListener('htmx:afterSwap', markActiveNav);
@@ -250,7 +265,7 @@
</aside> </aside>
{% endif %} {% endif %}
<main class="min-w-0 flex-1"> <main data-active-category="{% block nav_category %}{% endblock nav_category %}" class="min-w-0 flex-1">
{% block content %}{% endblock content %} {% block content %}{% endblock content %}
</main> </main>
</div> </div>

View File

@@ -3,8 +3,10 @@
categories, each `{ name, slug, children: [{ name, slug }] }`. A category categories, each `{ name, slug, children: [{ name, slug }] }`. A category
with children is expandable (accordion); one without is a plain link. with children is expandable (accordion); one without is a plain link.
Active state is set client-side by markActiveNav() via data-nav + Active state is set client-side by markActiveNav() via data-nav +
aria-current; groups auto-expand when the current page is the category or aria-current; groups auto-expand when the current page is the category, one
one of its subcategories. of its subcategories, or a product inside it (which markActiveNav announces
as window.activeCategory + the `nav-category` event, since this partial is
hx-preserve'd and therefore only ever initialises once).
Adapted from the vendored Penguin UI component Adapted from the vendored Penguin UI component
penguinui-components/sidebar/sidebar-with-collapsible-menus.html: Penguin's penguinui-components/sidebar/sidebar-with-collapsible-menus.html: Penguin's
@@ -27,8 +29,10 @@
</a> </a>
{% for group in category_groups %} {% for group in category_groups %}
{% if group.children | length > 0 %} {% if group.children | length > 0 %}
<div x-data="{ open: false }" class="flex flex-col" <div class="flex flex-col"
x-init="open = ['{{ group.slug }}'{% for child in group.children %}, '{{ child.slug }}'{% endfor %}].some(s => location.pathname === '/category/' + s)"> x-data="{ open: false, slugs: ['{{ group.slug }}'{% for child in group.children %}, '{{ child.slug }}'{% endfor %}] }"
x-init="open = slugs.some(s => location.pathname === '/category/' + s) || slugs.includes(window.activeCategory)"
x-on:nav-category.window="if (slugs.includes($event.detail)) open = true">
<div class="flex items-stretch"> <div class="flex items-stretch">
<a href="/category/{{ group.slug }}" data-nav="/category/{{ group.slug }}" <a href="/category/{{ group.slug }}" data-nav="/category/{{ group.slug }}"
class="flex flex-1 items-center gap-2 truncate rounded-l-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-cta aria-[current=page]:text-on-cta aria-[current=page]:font-semibold aria-[current=page]:shadow-sm dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-cta-dark dark:aria-[current=page]:text-on-cta-dark"> class="flex flex-1 items-center gap-2 truncate rounded-l-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-cta aria-[current=page]:text-on-cta aria-[current=page]:font-semibold aria-[current=page]:shadow-sm dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-cta-dark dark:aria-[current=page]:text-on-cta-dark">

View File

@@ -3,6 +3,27 @@
{% block title %}{{ product.name }}{% endblock title %} {% block title %}{{ product.name }}{% endblock title %}
{# Keeps this product's category selected and expanded in the sidebar, which
otherwise matches on the URL alone and so goes blank on /shop/{slug}. #}
{% block nav_category %}{% if category %}{{ category.slug }}{% endif %}{% endblock nav_category %}
{# Same trail as the category listing, extended with the product itself, so the
path back up the tree survives the click into a product. #}
{% block breadcrumbs %}
{% set L = lang | default(value='sk') %}
<nav aria-label="breadcrumb" class="mb-5 text-sm">
<ol class="flex flex-wrap items-center gap-1.5 text-on-surface/60 dark:text-on-surface-dark/60">
{{ ui::crumb(label=t(key="nav-home", lang=L), href="/") }}
{{ ui::crumb(label=t(key="nav-shop", lang=L), href="/shop") }}
{% for crumb in breadcrumbs %}
{{ ui::crumb(label=crumb.name, href="/category/" ~ crumb.slug) }}
{% endfor %}
{% if category %}{{ ui::crumb(label=category.name, href="/category/" ~ category.slug) }}{% endif %}
{{ ui::crumb_current(label=product.name) }}
</ol>
</nav>
{% endblock breadcrumbs %}
{% block content %} {% block content %}
<div class="space-y-12"> <div class="space-y-12">
<div class="grid gap-10 lg:grid-cols-2"> <div class="grid gap-10 lg:grid-cols-2">

View File

@@ -443,6 +443,13 @@ async fn show(
Some(id) => categories::Entity::find_by_id(id).one(&ctx.db).await?, Some(id) => categories::Entity::find_by_id(id).one(&ctx.db).await?,
None => None, None => None,
}; };
// Ancestors of the product's category, so the detail page carries the same
// trail as the listing the customer arrived from. Empty for an
// uncategorized product, which then shows only Home Shop name.
let breadcrumbs = match category.as_ref() {
Some(c) => categories::ancestors(&categories::published(&ctx).await?, c.parent_id),
None => Vec::new(),
};
let user = guard::current_user(&ctx, &jar).await; let user = guard::current_user(&ctx, &jar).await;
let cur = currency::resolve(&ctx, &jar).await; let cur = currency::resolve(&ctx, &jar).await;
@@ -487,6 +494,7 @@ async fn show(
"variants": options, "variants": options,
"images": images.iter().map(|i| i.image_id.clone()).collect::<Vec<_>>(), "images": images.iter().map(|i| i.image_id.clone()).collect::<Vec<_>>(),
"category": category, "category": category,
"breadcrumbs": breadcrumbs,
"logged_in_admin": c.logged_in_admin, "logged_in_admin": c.logged_in_admin,
"logged_in_customer": c.logged_in_customer, "logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name, "customer_name": c.customer_name,

View File

@@ -85,6 +85,10 @@ struct ProductJson {
#[derive(Deserialize)] #[derive(Deserialize)]
struct CrumbJson { struct CrumbJson {
id: i32, id: i32,
/// Ancestor names ending in this category's own — its depth in the tree.
/// Breadcrumb nodes carry a `url` instead of a path, hence the default.
#[serde(default)]
path: Vec<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -244,13 +248,15 @@ async fn import_products(
.await?; .await?;
// The breadcrumb's last node is the product's primary category. A few // The breadcrumb's last node is the product's primary category. A few
// products have no breadcrumb; fall back to the first category they are // products have no breadcrumb; fall back to the deepest category they
// listed in. Extra memberships are dropped — a product holds one // are listed in, not the first — the old shop lists the broad parent
// category here. // first, so taking `first()` filed all three glove products under
// "Zdravotnícke" and left "Rukavice" empty. Extra memberships are
// dropped either way: a product holds one category here.
let legacy_category = item let legacy_category = item
.breadcrumb .breadcrumb
.last() .last()
.or_else(|| item.categories.first()) .or_else(|| item.categories.iter().max_by_key(|c| c.path.len()))
.map(|c| c.id); .map(|c| c.id);
let category_id = legacy_category.and_then(|old| category_ids.get(&old).copied()); let category_id = legacy_category.and_then(|old| category_ids.get(&old).copied());
if item.categories.len() > 1 { if item.categories.len() > 1 {