diff --git a/app/assets/styles/composition/_sidebar.scss b/app/assets/styles/composition/_sidebar.scss
index d9d037bc..d409d7bc 100644
--- a/app/assets/styles/composition/_sidebar.scss
+++ b/app/assets/styles/composition/_sidebar.scss
@@ -34,4 +34,23 @@
.current-link {
color: var(--color-brand-primary);
}
+
+ // Recursive nav (categories + pages). Each row carries its own
+ // depth class so we indent visually without manual nested
s.
+ // Depth 0 = the root category heading; depth 1+ are children.
+ .nav-tree {
+ .nav-row {
+ margin-block: var(--spacing-3xs);
+ }
+ .nav-depth-1 { padding-inline-start: var(--spacing-md); }
+ .nav-depth-2 { padding-inline-start: calc(var(--spacing-md) * 2); }
+ .nav-depth-3 { padding-inline-start: calc(var(--spacing-md) * 3); }
+ .nav-depth-4 { padding-inline-start: calc(var(--spacing-md) * 4); }
+
+ // Sub-category headings (depth ≥ 1) get a slightly heavier
+ // weight to visually separate them from page links underneath.
+ .nav-category .category-link {
+ font-weight: 600;
+ }
+ }
}
\ No newline at end of file
diff --git a/app/pages/[...slug].vue b/app/pages/[...slug].vue
index 8a150e57..be9b015f 100644
--- a/app/pages/[...slug].vue
+++ b/app/pages/[...slug].vue
@@ -12,18 +12,45 @@
interface ChildPage {
slug: string
+ url: string
title: string
excerpt: string | null
featured_image: string | null
menu_order: number
}
+interface ChildCategory {
+ slug: string
+ url: string
+ title: string
+ excerpt: string | null
+ featured_image: string | null
+ menu_order: number
+}
+
+interface NavPageNode {
+ kind: 'page'
+ url: string
+ title: string
+ menu_order: number
+}
+interface NavCategoryNode {
+ kind: 'category'
+ url: string
+ title: string
+ menu_order: number
+ items: Array
+}
+type NavNode = NavPageNode | NavCategoryNode
+
type PageTheme = 'default' | 'green'
interface PageResponse {
+ url: string
slug: string
category_id: string | null
category_slug: string | null
+ category_url: string | null
menu_order: number
theme: PageTheme
custom_css: string | null
@@ -37,8 +64,11 @@ interface PageResponse {
og_image: string | null
body_html: string
body_is_empty: boolean
- menu_parent: { slug: string; title: string } | null
+ is_category_landing: boolean
+ menu_parent: { slug: string; url: string; title: string } | null
children: ChildPage[]
+ child_categories: ChildCategory[]
+ nav_tree: NavCategoryNode | null
}
import { h, render, getCurrentInstance } from 'vue'
@@ -82,8 +112,14 @@ if (!data.value && !error.value) {
//
useHead(() => {
if (!data.value) return {}
+ // A category index represents the category itself, so title it by the
+ // category name (menu_parent is the active category on a landing)
+ // rather than the resolved default page's title.
+ const mp = data.value.menu_parent
return {
- title: data.value.meta_title || data.value.title,
+ title: mp && data.value.child_categories.length > 0
+ ? mp.title
+ : (data.value.meta_title || data.value.title),
meta: [
...(data.value.meta_description ? [{ name: 'description', content: data.value.meta_description }] : []),
...(data.value.og_image ? [{ property: 'og:image', content: data.value.og_image }] : [])
@@ -102,7 +138,7 @@ useHead(() => {
// cascade regardless of where main.scss ends up in .
style: data.value.custom_css
? [{
- key: `page-custom-css-${data.value.slug}`,
+ key: `page-custom-css-${data.value.url}`,
tagPosition: 'bodyClose' as const,
innerHTML: data.value.custom_css
}]
@@ -110,19 +146,48 @@ useHead(() => {
}
})
-const hasSidebar = computed(() => (data.value?.children.length ?? 0) > 0)
-// A page renders its children as cards (instead of sidebar + body) when
-// it's the category's default landing AND its own body is empty. Useful
-// for pure index pages like /resources.
-const isCategoryDefault = computed(() =>
- Boolean(data.value && data.value.category_slug && data.value.menu_order === 0)
-)
+// Flatten the recursive nav tree into a list of rows for the sidebar.
+// Each row has a `depth` for visual indent. The root category itself
+// renders as a bold heading; everything below indents one step further.
+interface NavRow {
+ depth: number
+ kind: 'page' | 'category'
+ url: string
+ title: string
+}
+
+function flattenNav(node: NavCategoryNode, depth: number, out: NavRow[]) {
+ out.push({ depth, kind: 'category', url: node.url, title: node.title })
+ for (const item of node.items) {
+ if (item.kind === 'page') {
+ out.push({ depth: depth + 1, kind: 'page', url: item.url, title: item.title })
+ } else {
+ flattenNav(item, depth + 1, out)
+ }
+ }
+}
+
+const navRows = computed(() => {
+ const tree = data.value?.nav_tree
+ if (!tree) return []
+ const out: NavRow[] = []
+ flattenNav(tree, 0, out)
+ return out
+})
+
+const hasSidebar = computed(() => navRows.value.length > 0 || (data.value?.children.length ?? 0) > 0)
+// A category that has subcategories renders as an index: a card grid of
+// its subcategories followed by its own pages (set server-side only on
+// the bare category URL). Categories with just pages fall through to the
+// "open the first page" logic below.
+const showCategoryGrid = computed(() => (data.value?.child_categories.length ?? 0) > 0)
const showChildGrid = computed(() =>
Boolean(
data.value
- && isCategoryDefault.value
+ && data.value.is_category_landing
&& data.value.body_is_empty
&& data.value.children.length > 0
+ && !showCategoryGrid.value
)
)
@@ -226,18 +291,46 @@ onBeforeUnmount(unmountSlots)