sidebar and shit
This commit is contained in:
@@ -198,6 +198,8 @@ image = Image
|
|||||||
slug = URL slug
|
slug = URL slug
|
||||||
slug-auto = generated automatically
|
slug-auto = generated automatically
|
||||||
position = Position
|
position = Position
|
||||||
|
parent-category = Parent category
|
||||||
|
no-parent = — None (top level) —
|
||||||
quantity = Quantity
|
quantity = Quantity
|
||||||
add-to-cart = Add to cart
|
add-to-cart = Add to cart
|
||||||
in-stock = In stock
|
in-stock = In stock
|
||||||
@@ -206,6 +208,8 @@ confirm-delete = Delete this for good?
|
|||||||
shop-title = Shop
|
shop-title = Shop
|
||||||
shop-subtitle = browse our products.
|
shop-subtitle = browse our products.
|
||||||
shop-empty = There are no products here yet.
|
shop-empty = There are no products here yet.
|
||||||
|
categories = Categories
|
||||||
|
all-products = All products
|
||||||
cart-title = Cart
|
cart-title = Cart
|
||||||
cart-empty = Your cart is empty.
|
cart-empty = Your cart is empty.
|
||||||
cart-total = Total
|
cart-total = Total
|
||||||
|
|||||||
@@ -198,6 +198,8 @@ image = Obrázok
|
|||||||
slug = URL adresa
|
slug = URL adresa
|
||||||
slug-auto = vygeneruje sa automaticky
|
slug-auto = vygeneruje sa automaticky
|
||||||
position = Poradie
|
position = Poradie
|
||||||
|
parent-category = Nadradená kategória
|
||||||
|
no-parent = — Žiadna (najvyššia úroveň) —
|
||||||
quantity = Množstvo
|
quantity = Množstvo
|
||||||
add-to-cart = Pridať do košíka
|
add-to-cart = Pridať do košíka
|
||||||
in-stock = Na sklade
|
in-stock = Na sklade
|
||||||
@@ -206,6 +208,8 @@ confirm-delete = Naozaj zmazať?
|
|||||||
shop-title = Obchod
|
shop-title = Obchod
|
||||||
shop-subtitle = prezrite si našu ponuku produktov.
|
shop-subtitle = prezrite si našu ponuku produktov.
|
||||||
shop-empty = Zatiaľ tu nie sú žiadne produkty.
|
shop-empty = Zatiaľ tu nie sú žiadne produkty.
|
||||||
|
categories = Kategórie
|
||||||
|
all-products = Všetky produkty
|
||||||
cart-title = Košík
|
cart-title = Košík
|
||||||
cart-empty = Váš košík je prázdny.
|
cart-empty = Váš košík je prázdny.
|
||||||
cart-total = Spolu
|
cart-total = Spolu
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -29,7 +29,12 @@
|
|||||||
<tbody class="divide-y divide-outline dark:divide-outline-dark">
|
<tbody class="divide-y divide-outline dark:divide-outline-dark">
|
||||||
{% for row in categories %}
|
{% for row in categories %}
|
||||||
<tr class="hover:bg-surface-alt dark:hover:bg-surface-dark-alt">
|
<tr class="hover:bg-surface-alt dark:hover:bg-surface-dark-alt">
|
||||||
<td class="px-4 py-3 font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ row.category.name }}</td>
|
<td class="px-4 py-3 font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||||
|
<span style="padding-left: {{ row.depth * 20 }}px" class="inline-flex items-center gap-1.5">
|
||||||
|
{% if row.depth > 0 %}<span class="text-on-surface/40 dark:text-on-surface-dark/40">↳</span>{% endif %}
|
||||||
|
{{ row.category.name }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td class="px-4 py-3 tabular-nums">{{ row.product_count }}</td>
|
<td class="px-4 py-3 tabular-nums">{{ row.product_count }}</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
{% if row.category.published %}
|
{% if row.category.published %}
|
||||||
|
|||||||
@@ -37,6 +37,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="parent_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="parent-category", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<select id="parent_id" name="parent_id"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
<option value="">{{ t(key="no-parent", lang=lang | default(value='sk')) }}</option>
|
||||||
|
{% for parent in parents %}
|
||||||
|
<option value="{{ parent.id }}" {% if editing and category.parent_id == parent.id %}selected{% endif %}>
|
||||||
|
{% if parent.depth > 0 %}{% for _ in range(end=parent.depth) %}— {% endfor %}{% endif %}{{ parent.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label for="description" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="description", lang=lang | default(value='sk')) }}</label>
|
<label for="description" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="description", lang=lang | default(value='sk')) }}</label>
|
||||||
<textarea id="description" name="description" rows="4"
|
<textarea id="description" name="description" rows="4"
|
||||||
|
|||||||
@@ -45,10 +45,20 @@
|
|||||||
<script defer src="/static/vendor/alpine/alpinejs-3.14.9.min.js"></script>
|
<script defer src="/static/vendor/alpine/alpinejs-3.14.9.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body hx-boost="true"
|
<body hx-boost="true"
|
||||||
class="flex min-h-screen flex-col bg-surface text-on-surface antialiased dark:bg-surface-dark dark:text-on-surface-dark">
|
x-data="{ cats: false, lg: window.matchMedia('(min-width: 1024px)').matches }"
|
||||||
|
x-init="window.matchMedia('(min-width: 1024px)').addEventListener('change', e => lg = e.matches)"
|
||||||
|
class="min-h-screen bg-surface text-on-surface antialiased dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
<header
|
<header
|
||||||
class="sticky top-0 z-30 border-b border-outline bg-surface/95 backdrop-blur dark:border-outline-dark dark:bg-surface-dark/95">
|
class="sticky top-0 z-30 border-b border-outline bg-surface/95 backdrop-blur dark:border-outline-dark dark:bg-surface-dark/95">
|
||||||
<nav x-data="{ mobile: false }" class="mx-auto flex max-w-6xl items-center gap-4 px-4 py-3">
|
<nav x-data="{ mobile: false }" class="mx-auto flex max-w-7xl items-center gap-4 px-4 py-3">
|
||||||
|
<!-- category sidebar toggle (mobile only) -->
|
||||||
|
<button type="button" @click="cats = !cats" :aria-expanded="cats"
|
||||||
|
aria-label="{{ t(key='categories', lang=lang | default(value='sk')) }}"
|
||||||
|
class="inline-flex size-9 items-center justify-center rounded-radius text-on-surface transition hover:bg-surface-alt lg:hidden dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<a href="/"
|
<a href="/"
|
||||||
class="text-lg font-bold tracking-tight text-on-surface-strong dark:text-on-surface-dark-strong">
|
class="text-lg font-bold tracking-tight text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||||
{{ t(key="brand", lang=lang | default(value='sk')) }}
|
{{ t(key="brand", lang=lang | default(value='sk')) }}
|
||||||
@@ -169,8 +179,23 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="mx-auto w-full max-w-6xl flex-1 px-4 py-8">
|
<!-- dark overlay behind the category drawer on small screens -->
|
||||||
{% block content %}{% endblock content %}
|
<div x-cloak x-show="cats" x-transition.opacity @click="cats = false" aria-hidden="true"
|
||||||
</main>
|
class="fixed inset-0 z-30 bg-black/50 lg:hidden"></div>
|
||||||
|
|
||||||
|
<div class="mx-auto flex w-full max-w-7xl gap-8 px-4 py-8">
|
||||||
|
<!-- persistent category sidebar (off-canvas drawer on mobile).
|
||||||
|
hx-preserve keeps this node across boosted page swaps, so it is
|
||||||
|
fetched once (hx-trigger=load) and never reloaded on navigation. -->
|
||||||
|
<aside id="category-sidebar" hx-preserve="true"
|
||||||
|
x-cloak x-show="cats || lg" aria-label="{{ t(key='categories', lang=lang | default(value='sk')) }}"
|
||||||
|
hx-get="/partials/categories" hx-trigger="load"
|
||||||
|
class="fixed inset-y-0 left-0 z-40 w-64 overflow-y-auto border-r border-outline bg-surface-alt p-4 lg:static lg:z-auto lg:w-64 lg:shrink-0 lg:self-start lg:overflow-visible lg:rounded-radius lg:border lg:p-3 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="min-w-0 flex-1">
|
||||||
|
{% block content %}{% endblock content %}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
27
assets/views/shop/_sidebar.html
Normal file
27
assets/views/shop/_sidebar.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{# Site-wide category sidebar contents, served as an htmx partial and swapped
|
||||||
|
into the <aside> in base.html. `category_tree` is a depth-ordered flat list
|
||||||
|
of { name, slug, depth }; nesting is shown via left indentation. Active state
|
||||||
|
is set client-side by markActiveNav() via data-nav + aria-current. #}
|
||||||
|
<p class="px-3 pb-2 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
|
||||||
|
{{ t(key="categories", lang=lang | default(value='sk')) }}
|
||||||
|
</p>
|
||||||
|
<ul class="flex flex-col gap-0.5">
|
||||||
|
<li>
|
||||||
|
<a href="/shop" data-nav="/shop"
|
||||||
|
class="block rounded-radius px-3 py-1.5 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
|
||||||
|
{{ t(key="all-products", lang=lang | default(value='sk')) }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% for item in category_tree %}
|
||||||
|
<li>
|
||||||
|
<a href="/category/{{ item.slug }}" data-nav="/category/{{ item.slug }}" style="padding-left: {{ 12 + item.depth * 16 }}px"
|
||||||
|
class="flex items-center gap-1.5 rounded-radius py-1.5 pr-3 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
|
||||||
|
{% if item.depth > 0 %}<span class="text-on-surface/40 dark:text-on-surface-dark/40">↳</span>{% endif %}
|
||||||
|
<span class="truncate">{{ item.name }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% if category_tree | length == 0 %}
|
||||||
|
<p class="px-3 py-2 text-sm text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="shop-empty", lang=lang | default(value='sk')) }}</p>
|
||||||
|
{% endif %}
|
||||||
@@ -7,15 +7,28 @@
|
|||||||
<header class="space-y-2">
|
<header class="space-y-2">
|
||||||
<nav class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">
|
<nav class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">
|
||||||
<a href="/shop" class="hover:text-primary dark:hover:text-primary-dark">{{ t(key="nav-shop", lang=lang | default(value='sk')) }}</a>
|
<a href="/shop" class="hover:text-primary dark:hover:text-primary-dark">{{ t(key="nav-shop", lang=lang | default(value='sk')) }}</a>
|
||||||
|
{% for crumb in breadcrumbs %}
|
||||||
|
<span class="px-1">/</span>
|
||||||
|
<a href="/category/{{ crumb.slug }}" class="hover:text-primary dark:hover:text-primary-dark">{{ crumb.name }}</a>
|
||||||
|
{% endfor %}
|
||||||
<span class="px-1">/</span>
|
<span class="px-1">/</span>
|
||||||
<span>{{ category.name }}</span>
|
<span>{{ category.name }}</span>
|
||||||
</nav>
|
</nav>
|
||||||
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ category.name }}</h1>
|
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ category.name }}</h1>
|
||||||
{% if category.description %}<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ category.description }}</p>{% endif %}
|
{% if category.description %}<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ category.description }}</p>{% endif %}
|
||||||
|
|
||||||
|
{% if children | length > 0 %}
|
||||||
|
<div class="flex flex-wrap gap-2 pt-1">
|
||||||
|
{% for child in children %}
|
||||||
|
<a href="/category/{{ child.slug }}"
|
||||||
|
class="rounded-full border border-outline px-3 py-1 text-sm font-medium text-on-surface transition hover:border-primary hover:text-primary dark:border-outline-dark dark:text-on-surface-dark dark:hover:border-primary-dark dark:hover:text-primary-dark">{{ child.name }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{% if products | length > 0 %}
|
{% if products | length > 0 %}
|
||||||
<div class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
|
<div class="grid grid-cols-2 gap-5 sm:grid-cols-3 xl:grid-cols-4">
|
||||||
{% for product in products %}
|
{% for product in products %}
|
||||||
{% include "shop/_card.html" %}
|
{% include "shop/_card.html" %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -9,17 +9,8 @@
|
|||||||
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="shop-subtitle", lang=lang | default(value='sk')) }}</p>
|
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="shop-subtitle", lang=lang | default(value='sk')) }}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{% if categories | length > 0 %}
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
{% for category in categories %}
|
|
||||||
<a href="/category/{{ category.slug }}"
|
|
||||||
class="rounded-full border border-outline px-3 py-1 text-sm font-medium text-on-surface transition hover:border-primary hover:text-primary dark:border-outline-dark dark:text-on-surface-dark dark:hover:border-primary-dark dark:hover:text-primary-dark">{{ category.name }}</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if products | length > 0 %}
|
{% if products | length > 0 %}
|
||||||
<div class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
|
<div class="grid grid-cols-2 gap-5 sm:grid-cols-3 xl:grid-cols-4">
|
||||||
{% for product in products %}
|
{% for product in products %}
|
||||||
{% include "shop/_card.html" %}
|
{% include "shop/_card.html" %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ mod m20260616_131000_drop_audio_tables;
|
|||||||
mod m20260616_132000_drop_blog_and_pages;
|
mod m20260616_132000_drop_blog_and_pages;
|
||||||
mod m20260616_150755_shipping_methods;
|
mod m20260616_150755_shipping_methods;
|
||||||
mod m20260616_150812_add_shipping_fields_to_orders;
|
mod m20260616_150812_add_shipping_fields_to_orders;
|
||||||
|
mod m20260616_160000_add_parent_to_categories;
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -56,6 +57,7 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260616_132000_drop_blog_and_pages::Migration),
|
Box::new(m20260616_132000_drop_blog_and_pages::Migration),
|
||||||
Box::new(m20260616_150755_shipping_methods::Migration),
|
Box::new(m20260616_150755_shipping_methods::Migration),
|
||||||
Box::new(m20260616_150812_add_shipping_fields_to_orders::Migration),
|
Box::new(m20260616_150812_add_shipping_fields_to_orders::Migration),
|
||||||
|
Box::new(m20260616_160000_add_parent_to_categories::Migration),
|
||||||
// inject-above (do not remove this comment)
|
// inject-above (do not remove this comment)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
55
migration/src/m20260616_160000_add_parent_to_categories.rs
Normal file
55
migration/src/m20260616_160000_add_parent_to_categories.rs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Categories {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
ParentId,
|
||||||
|
}
|
||||||
|
|
||||||
|
const FK_NAME: &str = "fk_categories_parent_id";
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
m.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Categories::Table)
|
||||||
|
.add_column(ColumnDef::new(Categories::ParentId).integer().null())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
m.create_foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name(FK_NAME)
|
||||||
|
.from(Categories::Table, Categories::ParentId)
|
||||||
|
.to(Categories::Table, Categories::Id)
|
||||||
|
.on_delete(ForeignKeyAction::SetNull)
|
||||||
|
.on_update(ForeignKeyAction::Cascade)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
m.drop_foreign_key(
|
||||||
|
ForeignKey::drop()
|
||||||
|
.name(FK_NAME)
|
||||||
|
.table(Categories::Table)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
m.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Categories::Table)
|
||||||
|
.drop_column(Categories::ParentId)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -192,6 +192,90 @@ async fn product_by_id(ctx: &AppContext, id: i32) -> Result<products::Model> {
|
|||||||
.ok_or_else(|| Error::NotFound)
|
.ok_or_else(|| Error::NotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Category hierarchy helpers (adjacency list via `parent_id`)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Flatten the category forest into a depth-first ordered list of
|
||||||
|
/// `(category, depth)`, sorting siblings by position then name. `depth` is 0
|
||||||
|
/// for top-level categories and increases by one per level — templates use it
|
||||||
|
/// to indent.
|
||||||
|
fn category_tree(categories: &[categories::Model]) -> Vec<(categories::Model, usize)> {
|
||||||
|
let mut children: HashMap<Option<i32>, Vec<&categories::Model>> = HashMap::new();
|
||||||
|
for category in categories {
|
||||||
|
children.entry(category.parent_id).or_default().push(category);
|
||||||
|
}
|
||||||
|
for siblings in children.values_mut() {
|
||||||
|
siblings.sort_by(|a, b| a.position.cmp(&b.position).then_with(|| a.name.cmp(&b.name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk(
|
||||||
|
parent: Option<i32>,
|
||||||
|
depth: usize,
|
||||||
|
children: &HashMap<Option<i32>, Vec<&categories::Model>>,
|
||||||
|
out: &mut Vec<(categories::Model, usize)>,
|
||||||
|
) {
|
||||||
|
if let Some(siblings) = children.get(&parent) {
|
||||||
|
for category in siblings {
|
||||||
|
out.push(((*category).clone(), depth));
|
||||||
|
walk(Some(category.id), depth + 1, children, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
walk(None, 0, &children, &mut out);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Depth-ordered list of `{ name, slug, depth }` for the storefront sidebar,
|
||||||
|
/// rendered as an indented flat list.
|
||||||
|
fn category_sidebar_rows(categories: &[categories::Model]) -> Vec<serde_json::Value> {
|
||||||
|
category_tree(categories)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(category, depth)| {
|
||||||
|
json!({ "name": category.name, "slug": category.slug, "depth": depth })
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ids of every descendant of `root` (children, grandchildren, …), not
|
||||||
|
/// including `root` itself.
|
||||||
|
fn descendant_ids(categories: &[categories::Model], root: i32) -> std::collections::HashSet<i32> {
|
||||||
|
let mut set = std::collections::HashSet::new();
|
||||||
|
let mut stack = vec![root];
|
||||||
|
while let Some(id) = stack.pop() {
|
||||||
|
for child in categories.iter().filter(|c| c.parent_id == Some(id)) {
|
||||||
|
if set.insert(child.id) {
|
||||||
|
stack.push(child.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ancestor chain (root first … immediate parent last) for breadcrumbs.
|
||||||
|
fn ancestors(categories: &[categories::Model], start_parent: Option<i32>) -> Vec<categories::Model> {
|
||||||
|
let mut chain = Vec::new();
|
||||||
|
let mut current = start_parent;
|
||||||
|
while let Some(id) = current {
|
||||||
|
match categories.iter().find(|c| c.id == id) {
|
||||||
|
Some(category) => {
|
||||||
|
current = category.parent_id;
|
||||||
|
chain.push(category.clone());
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chain.reverse();
|
||||||
|
chain
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All categories, used as the source for tree building and validation.
|
||||||
|
async fn all_categories(ctx: &AppContext) -> Result<Vec<categories::Model>> {
|
||||||
|
Ok(categories::Entity::find().all(&ctx.db).await?)
|
||||||
|
}
|
||||||
|
|
||||||
async fn first_image(ctx: &AppContext, product_id: i32) -> Result<Option<String>> {
|
async fn first_image(ctx: &AppContext, product_id: i32) -> Result<Option<String>> {
|
||||||
Ok(product_images::Entity::find()
|
Ok(product_images::Entity::find()
|
||||||
.filter(product_images::Column::ProductId.eq(product_id))
|
.filter(product_images::Column::ProductId.eq(product_id))
|
||||||
@@ -490,18 +574,14 @@ async fn admin_categories(
|
|||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
admin::current_admin(auth, &ctx).await?;
|
admin::current_admin(auth, &ctx).await?;
|
||||||
let list = categories::Entity::find()
|
let list = all_categories(&ctx).await?;
|
||||||
.order_by_asc(categories::Column::Position)
|
|
||||||
.order_by_asc(categories::Column::Name)
|
|
||||||
.all(&ctx.db)
|
|
||||||
.await?;
|
|
||||||
let mut rows = Vec::new();
|
let mut rows = Vec::new();
|
||||||
for category in list {
|
for (category, depth) in category_tree(&list) {
|
||||||
let product_count = products::Entity::find()
|
let product_count = products::Entity::find()
|
||||||
.filter(products::Column::CategoryId.eq(category.id))
|
.filter(products::Column::CategoryId.eq(category.id))
|
||||||
.count(&ctx.db)
|
.count(&ctx.db)
|
||||||
.await?;
|
.await?;
|
||||||
rows.push(json!({ "category": category, "product_count": product_count }));
|
rows.push(json!({ "category": category, "depth": depth, "product_count": product_count }));
|
||||||
}
|
}
|
||||||
format::view(
|
format::view(
|
||||||
&v,
|
&v,
|
||||||
@@ -510,6 +590,31 @@ async fn admin_categories(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the parent-category dropdown options for the category form, as a
|
||||||
|
/// depth-ordered list of `{ id, name, depth }`. When editing, the category
|
||||||
|
/// itself and all of its descendants are excluded to keep the tree acyclic.
|
||||||
|
async fn category_form_context(
|
||||||
|
ctx: &AppContext,
|
||||||
|
jar: &CookieJar,
|
||||||
|
editing: Option<i32>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let all = all_categories(ctx).await?;
|
||||||
|
let blocked = match editing {
|
||||||
|
Some(id) => {
|
||||||
|
let mut set = descendant_ids(&all, id);
|
||||||
|
set.insert(id);
|
||||||
|
set
|
||||||
|
}
|
||||||
|
None => std::collections::HashSet::new(),
|
||||||
|
};
|
||||||
|
let parents: Vec<serde_json::Value> = category_tree(&all)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(category, _)| !blocked.contains(&category.id))
|
||||||
|
.map(|(category, depth)| json!({ "id": category.id, "name": category.name, "depth": depth }))
|
||||||
|
.collect();
|
||||||
|
Ok(json!({ "parents": parents, "lang": current_lang(jar) }))
|
||||||
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
async fn admin_category_new(
|
async fn admin_category_new(
|
||||||
auth: auth::JWT,
|
auth: auth::JWT,
|
||||||
@@ -518,18 +623,16 @@ async fn admin_category_new(
|
|||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
admin::current_admin(auth, &ctx).await?;
|
admin::current_admin(auth, &ctx).await?;
|
||||||
format::view(
|
let mut context = category_form_context(&ctx, &jar, None).await?;
|
||||||
&v,
|
context["category"] = serde_json::Value::Null;
|
||||||
"admin/catalog/category_form.html",
|
format::view(&v, "admin/catalog/category_form.html", context)
|
||||||
json!({ "category": serde_json::Value::Null, "lang": current_lang(&jar) }),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn parse_category_fields(
|
async fn parse_category_fields(
|
||||||
ctx: &AppContext,
|
ctx: &AppContext,
|
||||||
form: &MultipartForm,
|
form: &MultipartForm,
|
||||||
current_id: Option<i32>,
|
current_id: Option<i32>,
|
||||||
) -> Result<(String, String, Option<String>, i32, bool)> {
|
) -> Result<(String, String, Option<String>, i32, bool, Option<i32>)> {
|
||||||
let name = form
|
let name = form
|
||||||
.text("name")
|
.text("name")
|
||||||
.ok_or_else(|| Error::BadRequest("category name is required".to_string()))?;
|
.ok_or_else(|| Error::BadRequest("category name is required".to_string()))?;
|
||||||
@@ -540,6 +643,31 @@ async fn parse_category_fields(
|
|||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let published = form.checked("published");
|
let published = form.checked("published");
|
||||||
|
|
||||||
|
// Resolve the chosen parent, rejecting cycles: a category may not be its
|
||||||
|
// own parent nor be re-parented under one of its descendants.
|
||||||
|
let parent_id = match form.text("parent_id").and_then(|s| s.parse::<i32>().ok()) {
|
||||||
|
Some(parent_id) => {
|
||||||
|
categories::Entity::find_by_id(parent_id)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::BadRequest("parent category not found".to_string()))?;
|
||||||
|
if let Some(id) = current_id {
|
||||||
|
if parent_id == id {
|
||||||
|
return Err(Error::BadRequest(
|
||||||
|
"a category cannot be its own parent".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if descendant_ids(&all_categories(ctx).await?, id).contains(&parent_id) {
|
||||||
|
return Err(Error::BadRequest(
|
||||||
|
"a category cannot be moved under its own descendant".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(parent_id)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
let desired = form
|
let desired = form
|
||||||
.text("slug")
|
.text("slug")
|
||||||
.map(|s| slugify(&s))
|
.map(|s| slugify(&s))
|
||||||
@@ -558,7 +686,7 @@ async fn parse_category_fields(
|
|||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok((name, slug, description, position, published))
|
Ok((name, slug, description, position, published, parent_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
@@ -569,7 +697,7 @@ async fn admin_category_create(
|
|||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
admin::current_admin(auth, &ctx).await?;
|
admin::current_admin(auth, &ctx).await?;
|
||||||
let form = read_multipart_form(multipart).await?;
|
let form = read_multipart_form(multipart).await?;
|
||||||
let (name, slug, description, position, published) =
|
let (name, slug, description, position, published, parent_id) =
|
||||||
parse_category_fields(&ctx, &form, None).await?;
|
parse_category_fields(&ctx, &form, None).await?;
|
||||||
let image_id = match form.image {
|
let image_id = match form.image {
|
||||||
Some(data) => Some(store_image(&ctx, data).await?),
|
Some(data) => Some(store_image(&ctx, data).await?),
|
||||||
@@ -583,6 +711,7 @@ async fn admin_category_create(
|
|||||||
image_id: Set(image_id),
|
image_id: Set(image_id),
|
||||||
position: Set(position),
|
position: Set(position),
|
||||||
published: Set(published),
|
published: Set(published),
|
||||||
|
parent_id: Set(parent_id),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.insert(&ctx.db)
|
.insert(&ctx.db)
|
||||||
@@ -600,11 +729,9 @@ async fn admin_category_edit(
|
|||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
admin::current_admin(auth, &ctx).await?;
|
admin::current_admin(auth, &ctx).await?;
|
||||||
format::view(
|
let mut context = category_form_context(&ctx, &jar, Some(id)).await?;
|
||||||
&v,
|
context["category"] = json!(category_by_id(&ctx, id).await?);
|
||||||
"admin/catalog/category_form.html",
|
format::view(&v, "admin/catalog/category_form.html", context)
|
||||||
json!({ "category": category_by_id(&ctx, id).await?, "lang": current_lang(&jar) }),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
@@ -617,7 +744,7 @@ async fn admin_category_update(
|
|||||||
admin::current_admin(auth, &ctx).await?;
|
admin::current_admin(auth, &ctx).await?;
|
||||||
let existing = category_by_id(&ctx, id).await?;
|
let existing = category_by_id(&ctx, id).await?;
|
||||||
let form = read_multipart_form(multipart).await?;
|
let form = read_multipart_form(multipart).await?;
|
||||||
let (name, slug, description, position, published) =
|
let (name, slug, description, position, published, parent_id) =
|
||||||
parse_category_fields(&ctx, &form, Some(id)).await?;
|
parse_category_fields(&ctx, &form, Some(id)).await?;
|
||||||
|
|
||||||
let mut category = existing.into_active_model();
|
let mut category = existing.into_active_model();
|
||||||
@@ -626,6 +753,7 @@ async fn admin_category_update(
|
|||||||
category.description = Set(description);
|
category.description = Set(description);
|
||||||
category.position = Set(position);
|
category.position = Set(position);
|
||||||
category.published = Set(published);
|
category.published = Set(published);
|
||||||
|
category.parent_id = Set(parent_id);
|
||||||
if let Some(data) = form.image {
|
if let Some(data) = form.image {
|
||||||
category.image_id = Set(Some(store_image(&ctx, data).await?));
|
category.image_id = Set(Some(store_image(&ctx, data).await?));
|
||||||
}
|
}
|
||||||
@@ -649,6 +777,28 @@ async fn admin_category_delete(
|
|||||||
// Public storefront
|
// Public storefront
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The site-wide category sidebar, loaded lazily via htmx by the base layout so
|
||||||
|
/// every page gets it without each handler having to supply category data.
|
||||||
|
#[debug_handler]
|
||||||
|
async fn category_sidebar(
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let published = categories::Entity::find()
|
||||||
|
.filter(categories::Column::Published.eq(true))
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"shop/_sidebar.html",
|
||||||
|
json!({
|
||||||
|
"category_tree": category_sidebar_rows(&published),
|
||||||
|
"lang": current_lang(&jar),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
async fn shop_index(
|
async fn shop_index(
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
@@ -665,18 +815,12 @@ async fn shop_index(
|
|||||||
let image = first_image(&ctx, product.id).await?;
|
let image = first_image(&ctx, product.id).await?;
|
||||||
rows.push(product_json(&product, image, None));
|
rows.push(product_json(&product, image, None));
|
||||||
}
|
}
|
||||||
let categories = categories::Entity::find()
|
|
||||||
.filter(categories::Column::Published.eq(true))
|
|
||||||
.order_by_asc(categories::Column::Position)
|
|
||||||
.all(&ctx.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
format::view(
|
format::view(
|
||||||
&v,
|
&v,
|
||||||
"shop/index.html",
|
"shop/index.html",
|
||||||
json!({
|
json!({
|
||||||
"products": rows,
|
"products": rows,
|
||||||
"categories": categories,
|
|
||||||
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
|
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
|
||||||
"lang": current_lang(&jar),
|
"lang": current_lang(&jar),
|
||||||
}),
|
}),
|
||||||
@@ -731,15 +875,33 @@ async fn shop_category(
|
|||||||
Path(slug): Path<String>,
|
Path(slug): Path<String>,
|
||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
let category = categories::Entity::find()
|
let published = categories::Entity::find()
|
||||||
.filter(categories::Column::Slug.eq(slug))
|
|
||||||
.filter(categories::Column::Published.eq(true))
|
.filter(categories::Column::Published.eq(true))
|
||||||
.one(&ctx.db)
|
.all(&ctx.db)
|
||||||
.await?
|
.await?;
|
||||||
|
let category = published
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.slug == slug)
|
||||||
|
.cloned()
|
||||||
.ok_or_else(|| Error::NotFound)?;
|
.ok_or_else(|| Error::NotFound)?;
|
||||||
|
|
||||||
|
// Breadcrumb trail and the (published) direct children shown as sub-nav.
|
||||||
|
let breadcrumbs = ancestors(&published, category.parent_id);
|
||||||
|
let mut children: Vec<categories::Model> = published
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.parent_id == Some(category.id))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
children.sort_by(|a, b| a.position.cmp(&b.position).then_with(|| a.name.cmp(&b.name)));
|
||||||
|
|
||||||
|
// Products listed here span this category and all of its descendants, so a
|
||||||
|
// parent category is never empty just because its products live in leaves.
|
||||||
|
let mut category_ids: Vec<i32> = descendant_ids(&published, category.id)
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
category_ids.push(category.id);
|
||||||
let list = products::Entity::find()
|
let list = products::Entity::find()
|
||||||
.filter(products::Column::CategoryId.eq(category.id))
|
.filter(products::Column::CategoryId.is_in(category_ids))
|
||||||
.filter(products::Column::Published.eq(true))
|
.filter(products::Column::Published.eq(true))
|
||||||
.order_by_desc(products::Column::PublishedAt)
|
.order_by_desc(products::Column::PublishedAt)
|
||||||
.all(&ctx.db)
|
.all(&ctx.db)
|
||||||
@@ -755,6 +917,8 @@ async fn shop_category(
|
|||||||
"shop/category.html",
|
"shop/category.html",
|
||||||
json!({
|
json!({
|
||||||
"category": category,
|
"category": category,
|
||||||
|
"breadcrumbs": breadcrumbs,
|
||||||
|
"children": children,
|
||||||
"products": rows,
|
"products": rows,
|
||||||
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
|
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
|
||||||
"lang": current_lang(&jar),
|
"lang": current_lang(&jar),
|
||||||
@@ -769,6 +933,7 @@ pub fn routes() -> Routes {
|
|||||||
.add("/shop", get(shop_index))
|
.add("/shop", get(shop_index))
|
||||||
.add("/shop/{slug}", get(shop_show))
|
.add("/shop/{slug}", get(shop_show))
|
||||||
.add("/category/{slug}", get(shop_category))
|
.add("/category/{slug}", get(shop_category))
|
||||||
|
.add("/partials/categories", get(category_sidebar))
|
||||||
// admin products
|
// admin products
|
||||||
.add("/admin/catalog/products", get(admin_products))
|
.add("/admin/catalog/products", get(admin_products))
|
||||||
.add("/admin/catalog/products/new", get(admin_product_new))
|
.add("/admin/catalog/products/new", get(admin_product_new))
|
||||||
|
|||||||
@@ -18,12 +18,21 @@ pub struct Model {
|
|||||||
pub image_id: Option<String>,
|
pub image_id: Option<String>,
|
||||||
pub position: i32,
|
pub position: i32,
|
||||||
pub published: bool,
|
pub published: bool,
|
||||||
|
pub parent_id: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
pub enum Relation {
|
pub enum Relation {
|
||||||
#[sea_orm(has_many = "super::products::Entity")]
|
#[sea_orm(has_many = "super::products::Entity")]
|
||||||
Products,
|
Products,
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "Entity",
|
||||||
|
from = "Column::ParentId",
|
||||||
|
to = "Column::Id",
|
||||||
|
on_update = "Cascade",
|
||||||
|
on_delete = "SetNull"
|
||||||
|
)]
|
||||||
|
Parent,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Related<super::products::Entity> for Entity {
|
impl Related<super::products::Entity> for Entity {
|
||||||
|
|||||||
Reference in New Issue
Block a user