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

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