small fixes
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user