sidebar and shit
This commit is contained in:
@@ -198,6 +198,8 @@ image = Image
|
||||
slug = URL slug
|
||||
slug-auto = generated automatically
|
||||
position = Position
|
||||
parent-category = Parent category
|
||||
no-parent = — None (top level) —
|
||||
quantity = Quantity
|
||||
add-to-cart = Add to cart
|
||||
in-stock = In stock
|
||||
@@ -206,6 +208,8 @@ confirm-delete = Delete this for good?
|
||||
shop-title = Shop
|
||||
shop-subtitle = browse our products.
|
||||
shop-empty = There are no products here yet.
|
||||
categories = Categories
|
||||
all-products = All products
|
||||
cart-title = Cart
|
||||
cart-empty = Your cart is empty.
|
||||
cart-total = Total
|
||||
|
||||
@@ -198,6 +198,8 @@ image = Obrázok
|
||||
slug = URL adresa
|
||||
slug-auto = vygeneruje sa automaticky
|
||||
position = Poradie
|
||||
parent-category = Nadradená kategória
|
||||
no-parent = — Žiadna (najvyššia úroveň) —
|
||||
quantity = Množstvo
|
||||
add-to-cart = Pridať do košíka
|
||||
in-stock = Na sklade
|
||||
@@ -206,6 +208,8 @@ confirm-delete = Naozaj zmazať?
|
||||
shop-title = Obchod
|
||||
shop-subtitle = prezrite si našu ponuku produktov.
|
||||
shop-empty = Zatiaľ tu nie sú žiadne produkty.
|
||||
categories = Kategórie
|
||||
all-products = Všetky produkty
|
||||
cart-title = Košík
|
||||
cart-empty = Váš košík je prázdny.
|
||||
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">
|
||||
{% for row in categories %}
|
||||
<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">
|
||||
{% if row.category.published %}
|
||||
|
||||
@@ -37,6 +37,19 @@
|
||||
</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">
|
||||
<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"
|
||||
|
||||
@@ -45,10 +45,20 @@
|
||||
<script defer src="/static/vendor/alpine/alpinejs-3.14.9.min.js"></script>
|
||||
</head>
|
||||
<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
|
||||
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="/"
|
||||
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')) }}
|
||||
@@ -169,8 +179,23 @@
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto w-full max-w-6xl flex-1 px-4 py-8">
|
||||
{% block content %}{% endblock content %}
|
||||
</main>
|
||||
<!-- dark overlay behind the category drawer on small screens -->
|
||||
<div x-cloak x-show="cats" x-transition.opacity @click="cats = false" aria-hidden="true"
|
||||
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>
|
||||
</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">
|
||||
<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>
|
||||
{% 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>{{ category.name }}</span>
|
||||
</nav>
|
||||
<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 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>
|
||||
|
||||
{% 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 %}
|
||||
{% include "shop/_card.html" %}
|
||||
{% 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>
|
||||
</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 %}
|
||||
<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 %}
|
||||
{% include "shop/_card.html" %}
|
||||
{% endfor %}
|
||||
|
||||
@@ -26,6 +26,7 @@ mod m20260616_131000_drop_audio_tables;
|
||||
mod m20260616_132000_drop_blog_and_pages;
|
||||
mod m20260616_150755_shipping_methods;
|
||||
mod m20260616_150812_add_shipping_fields_to_orders;
|
||||
mod m20260616_160000_add_parent_to_categories;
|
||||
pub struct Migrator;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -56,6 +57,7 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260616_132000_drop_blog_and_pages::Migration),
|
||||
Box::new(m20260616_150755_shipping_methods::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)
|
||||
]
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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>> {
|
||||
Ok(product_images::Entity::find()
|
||||
.filter(product_images::Column::ProductId.eq(product_id))
|
||||
@@ -490,18 +574,14 @@ async fn admin_categories(
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let list = categories::Entity::find()
|
||||
.order_by_asc(categories::Column::Position)
|
||||
.order_by_asc(categories::Column::Name)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let list = all_categories(&ctx).await?;
|
||||
let mut rows = Vec::new();
|
||||
for category in list {
|
||||
for (category, depth) in category_tree(&list) {
|
||||
let product_count = products::Entity::find()
|
||||
.filter(products::Column::CategoryId.eq(category.id))
|
||||
.count(&ctx.db)
|
||||
.await?;
|
||||
rows.push(json!({ "category": category, "product_count": product_count }));
|
||||
rows.push(json!({ "category": category, "depth": depth, "product_count": product_count }));
|
||||
}
|
||||
format::view(
|
||||
&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]
|
||||
async fn admin_category_new(
|
||||
auth: auth::JWT,
|
||||
@@ -518,18 +623,16 @@ async fn admin_category_new(
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
format::view(
|
||||
&v,
|
||||
"admin/catalog/category_form.html",
|
||||
json!({ "category": serde_json::Value::Null, "lang": current_lang(&jar) }),
|
||||
)
|
||||
let mut context = category_form_context(&ctx, &jar, None).await?;
|
||||
context["category"] = serde_json::Value::Null;
|
||||
format::view(&v, "admin/catalog/category_form.html", context)
|
||||
}
|
||||
|
||||
async fn parse_category_fields(
|
||||
ctx: &AppContext,
|
||||
form: &MultipartForm,
|
||||
current_id: Option<i32>,
|
||||
) -> Result<(String, String, Option<String>, i32, bool)> {
|
||||
) -> Result<(String, String, Option<String>, i32, bool, Option<i32>)> {
|
||||
let name = form
|
||||
.text("name")
|
||||
.ok_or_else(|| Error::BadRequest("category name is required".to_string()))?;
|
||||
@@ -540,6 +643,31 @@ async fn parse_category_fields(
|
||||
.unwrap_or(0);
|
||||
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
|
||||
.text("slug")
|
||||
.map(|s| slugify(&s))
|
||||
@@ -558,7 +686,7 @@ async fn parse_category_fields(
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok((name, slug, description, position, published))
|
||||
Ok((name, slug, description, position, published, parent_id))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
@@ -569,7 +697,7 @@ async fn admin_category_create(
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).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?;
|
||||
let image_id = match form.image {
|
||||
Some(data) => Some(store_image(&ctx, data).await?),
|
||||
@@ -583,6 +711,7 @@ async fn admin_category_create(
|
||||
image_id: Set(image_id),
|
||||
position: Set(position),
|
||||
published: Set(published),
|
||||
parent_id: Set(parent_id),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
@@ -600,11 +729,9 @@ async fn admin_category_edit(
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
format::view(
|
||||
&v,
|
||||
"admin/catalog/category_form.html",
|
||||
json!({ "category": category_by_id(&ctx, id).await?, "lang": current_lang(&jar) }),
|
||||
)
|
||||
let mut context = category_form_context(&ctx, &jar, Some(id)).await?;
|
||||
context["category"] = json!(category_by_id(&ctx, id).await?);
|
||||
format::view(&v, "admin/catalog/category_form.html", context)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
@@ -617,7 +744,7 @@ async fn admin_category_update(
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let existing = category_by_id(&ctx, id).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?;
|
||||
|
||||
let mut category = existing.into_active_model();
|
||||
@@ -626,6 +753,7 @@ async fn admin_category_update(
|
||||
category.description = Set(description);
|
||||
category.position = Set(position);
|
||||
category.published = Set(published);
|
||||
category.parent_id = Set(parent_id);
|
||||
if let Some(data) = form.image {
|
||||
category.image_id = Set(Some(store_image(&ctx, data).await?));
|
||||
}
|
||||
@@ -649,6 +777,28 @@ async fn admin_category_delete(
|
||||
// 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]
|
||||
async fn shop_index(
|
||||
jar: CookieJar,
|
||||
@@ -665,18 +815,12 @@ async fn shop_index(
|
||||
let image = first_image(&ctx, product.id).await?;
|
||||
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(
|
||||
&v,
|
||||
"shop/index.html",
|
||||
json!({
|
||||
"products": rows,
|
||||
"categories": categories,
|
||||
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
@@ -731,15 +875,33 @@ async fn shop_category(
|
||||
Path(slug): Path<String>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let category = categories::Entity::find()
|
||||
.filter(categories::Column::Slug.eq(slug))
|
||||
let published = categories::Entity::find()
|
||||
.filter(categories::Column::Published.eq(true))
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let category = published
|
||||
.iter()
|
||||
.find(|c| c.slug == slug)
|
||||
.cloned()
|
||||
.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()
|
||||
.filter(products::Column::CategoryId.eq(category.id))
|
||||
.filter(products::Column::CategoryId.is_in(category_ids))
|
||||
.filter(products::Column::Published.eq(true))
|
||||
.order_by_desc(products::Column::PublishedAt)
|
||||
.all(&ctx.db)
|
||||
@@ -755,6 +917,8 @@ async fn shop_category(
|
||||
"shop/category.html",
|
||||
json!({
|
||||
"category": category,
|
||||
"breadcrumbs": breadcrumbs,
|
||||
"children": children,
|
||||
"products": rows,
|
||||
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
|
||||
"lang": current_lang(&jar),
|
||||
@@ -769,6 +933,7 @@ pub fn routes() -> Routes {
|
||||
.add("/shop", get(shop_index))
|
||||
.add("/shop/{slug}", get(shop_show))
|
||||
.add("/category/{slug}", get(shop_category))
|
||||
.add("/partials/categories", get(category_sidebar))
|
||||
// admin products
|
||||
.add("/admin/catalog/products", get(admin_products))
|
||||
.add("/admin/catalog/products/new", get(admin_product_new))
|
||||
|
||||
@@ -18,12 +18,21 @@ pub struct Model {
|
||||
pub image_id: Option<String>,
|
||||
pub position: i32,
|
||||
pub published: bool,
|
||||
pub parent_id: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::products::Entity")]
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user