sidebar and shit

This commit is contained in:
Priec
2026-06-16 22:02:07 +02:00
parent f0a6f97609
commit b255e95051
13 changed files with 363 additions and 50 deletions

View File

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

View File

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

View File

@@ -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 %}

View File

@@ -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) %}—&nbsp;{% 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"

View File

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

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

View File

@@ -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 %}

View File

@@ -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 %}

View File

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

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

View File

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

View File

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