Let categories have child categories for nested pages. (#6)
* Let categories have child categories for nested pages. * fixes
This commit is contained in:
parent
594bed8f4c
commit
6f918a9d85
33 changed files with 1612 additions and 501 deletions
|
|
@ -34,4 +34,23 @@
|
||||||
.current-link {
|
.current-link {
|
||||||
color: var(--color-brand-primary);
|
color: var(--color-brand-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recursive nav (categories + pages). Each row carries its own
|
||||||
|
// depth class so we indent visually without manual nested <ul>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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -12,18 +12,45 @@
|
||||||
|
|
||||||
interface ChildPage {
|
interface ChildPage {
|
||||||
slug: string
|
slug: string
|
||||||
|
url: string
|
||||||
title: string
|
title: string
|
||||||
excerpt: string | null
|
excerpt: string | null
|
||||||
featured_image: string | null
|
featured_image: string | null
|
||||||
menu_order: number
|
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<NavPageNode | NavCategoryNode>
|
||||||
|
}
|
||||||
|
type NavNode = NavPageNode | NavCategoryNode
|
||||||
|
|
||||||
type PageTheme = 'default' | 'green'
|
type PageTheme = 'default' | 'green'
|
||||||
|
|
||||||
interface PageResponse {
|
interface PageResponse {
|
||||||
|
url: string
|
||||||
slug: string
|
slug: string
|
||||||
category_id: string | null
|
category_id: string | null
|
||||||
category_slug: string | null
|
category_slug: string | null
|
||||||
|
category_url: string | null
|
||||||
menu_order: number
|
menu_order: number
|
||||||
theme: PageTheme
|
theme: PageTheme
|
||||||
custom_css: string | null
|
custom_css: string | null
|
||||||
|
|
@ -37,8 +64,11 @@ interface PageResponse {
|
||||||
og_image: string | null
|
og_image: string | null
|
||||||
body_html: string
|
body_html: string
|
||||||
body_is_empty: boolean
|
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[]
|
children: ChildPage[]
|
||||||
|
child_categories: ChildCategory[]
|
||||||
|
nav_tree: NavCategoryNode | null
|
||||||
}
|
}
|
||||||
|
|
||||||
import { h, render, getCurrentInstance } from 'vue'
|
import { h, render, getCurrentInstance } from 'vue'
|
||||||
|
|
@ -82,8 +112,14 @@ if (!data.value && !error.value) {
|
||||||
// <head>
|
// <head>
|
||||||
useHead(() => {
|
useHead(() => {
|
||||||
if (!data.value) return {}
|
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 {
|
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: [
|
meta: [
|
||||||
...(data.value.meta_description ? [{ name: 'description', content: data.value.meta_description }] : []),
|
...(data.value.meta_description ? [{ name: 'description', content: data.value.meta_description }] : []),
|
||||||
...(data.value.og_image ? [{ property: 'og:image', content: data.value.og_image }] : [])
|
...(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 <head>.
|
// cascade regardless of where main.scss ends up in <head>.
|
||||||
style: data.value.custom_css
|
style: data.value.custom_css
|
||||||
? [{
|
? [{
|
||||||
key: `page-custom-css-${data.value.slug}`,
|
key: `page-custom-css-${data.value.url}`,
|
||||||
tagPosition: 'bodyClose' as const,
|
tagPosition: 'bodyClose' as const,
|
||||||
innerHTML: data.value.custom_css
|
innerHTML: data.value.custom_css
|
||||||
}]
|
}]
|
||||||
|
|
@ -110,19 +146,48 @@ useHead(() => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasSidebar = computed(() => (data.value?.children.length ?? 0) > 0)
|
// Flatten the recursive nav tree into a list of rows for the sidebar.
|
||||||
// A page renders its children as cards (instead of sidebar + body) when
|
// Each row has a `depth` for visual indent. The root category itself
|
||||||
// it's the category's default landing AND its own body is empty. Useful
|
// renders as a bold heading; everything below indents one step further.
|
||||||
// for pure index pages like /resources.
|
interface NavRow {
|
||||||
const isCategoryDefault = computed(() =>
|
depth: number
|
||||||
Boolean(data.value && data.value.category_slug && data.value.menu_order === 0)
|
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<NavRow[]>(() => {
|
||||||
|
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(() =>
|
const showChildGrid = computed(() =>
|
||||||
Boolean(
|
Boolean(
|
||||||
data.value
|
data.value
|
||||||
&& isCategoryDefault.value
|
&& data.value.is_category_landing
|
||||||
&& data.value.body_is_empty
|
&& data.value.body_is_empty
|
||||||
&& data.value.children.length > 0
|
&& data.value.children.length > 0
|
||||||
|
&& !showCategoryGrid.value
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -226,18 +291,46 @@ onBeforeUnmount(unmountSlots)
|
||||||
<div v-if="data" class="container page-content">
|
<div v-if="data" class="container page-content">
|
||||||
<div :class="hasSidebar ? 'with-sidebar' : ''">
|
<div :class="hasSidebar ? 'with-sidebar' : ''">
|
||||||
<aside v-if="hasSidebar" class="sidebar">
|
<aside v-if="hasSidebar" class="sidebar">
|
||||||
<nav class="stack" aria-label="Child pages navigation">
|
<nav class="stack" aria-label="Section navigation">
|
||||||
<div>
|
<div>
|
||||||
<ul class="stack | max-width-xs" role="list">
|
<!-- Recursive nav (categories + pages). Falls back to the
|
||||||
|
flat siblings list when no tree is available (e.g.
|
||||||
|
uncategorized pages). -->
|
||||||
|
<ul v-if="navRows.length" class="stack | max-width-xs nav-tree" role="list">
|
||||||
|
<li
|
||||||
|
v-for="row in navRows"
|
||||||
|
:key="`${row.kind}-${row.url}`"
|
||||||
|
:class="['nav-row', `nav-depth-${row.depth}`, `nav-${row.kind}`]"
|
||||||
|
>
|
||||||
|
<span v-if="row.kind === 'category' && row.depth === 0" class="font-size-lg category-heading">
|
||||||
|
{{ row.title }}
|
||||||
|
</span>
|
||||||
|
<NuxtLink
|
||||||
|
v-else-if="row.kind === 'category'"
|
||||||
|
:class="['category-link', { 'current-link': data.url === row.url }]"
|
||||||
|
:to="localePath(`/${row.url}`)"
|
||||||
|
>
|
||||||
|
{{ row.title }}
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
v-else
|
||||||
|
:class="{ 'current-link': data.url === row.url }"
|
||||||
|
:to="localePath(`/${row.url}`)"
|
||||||
|
>
|
||||||
|
{{ row.title }}
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<ul v-else class="stack | max-width-xs" role="list">
|
||||||
<li v-if="data.menu_parent">
|
<li v-if="data.menu_parent">
|
||||||
<span class="font-size-lg category-heading">
|
<span class="font-size-lg category-heading">
|
||||||
{{ data.menu_parent.title }}
|
{{ data.menu_parent.title }}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<li v-for="child in data.children" :key="child.slug">
|
<li v-for="child in data.children" :key="child.url">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:class="{ 'current-link': data.slug === child.slug }"
|
:class="{ 'current-link': data.url === child.url }"
|
||||||
:to="localePath(`/${child.slug}`)"
|
:to="localePath(`/${child.url}`)"
|
||||||
>
|
>
|
||||||
{{ child.title }}
|
{{ child.title }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
@ -247,7 +340,7 @@ onBeforeUnmount(unmountSlots)
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<article :id="`page-${data.slug}`" class="page">
|
<article :id="`page-${data.slug || data.category_slug}`" class="page">
|
||||||
<!-- WP page.php emits `page-featured-image` only when a
|
<!-- WP page.php emits `page-featured-image` only when a
|
||||||
post_thumbnail is explicitly set. Matches live behavior. -->
|
post_thumbnail is explicitly set. Matches live behavior. -->
|
||||||
<div v-if="data.featured_image" class="page-featured-image">
|
<div v-if="data.featured_image" class="page-featured-image">
|
||||||
|
|
@ -258,19 +351,66 @@ onBeforeUnmount(unmountSlots)
|
||||||
the top so visitors landing on a sub-page (e.g. /resources/
|
the top so visitors landing on a sub-page (e.g. /resources/
|
||||||
overview) get a clear heading. The document <title> is also
|
overview) get a clear heading. The document <title> is also
|
||||||
set via useHead() above. -->
|
set via useHead() above. -->
|
||||||
<h1 v-if="data.menu_parent" class="page-title">{{ data.title }}</h1>
|
<h1 v-if="showCategoryGrid && data.menu_parent" class="page-title">{{ data.menu_parent.title }}</h1>
|
||||||
<div v-if="!showChildGrid" class="page-body" v-html="data.body_html" />
|
<h1 v-else-if="data.menu_parent && data.title" class="page-title">{{ data.title }}</h1>
|
||||||
|
<div v-if="!showChildGrid && !showCategoryGrid" class="page-body" v-html="data.body_html" />
|
||||||
|
|
||||||
<div v-if="showChildGrid" class="grid">
|
<div v-if="showCategoryGrid" class="grid">
|
||||||
<div v-for="child in data.children" :key="child.slug" class="card" data-variant="secondary">
|
<!-- Subcategories first … -->
|
||||||
|
<div
|
||||||
|
v-for="cat in data.child_categories"
|
||||||
|
:key="cat.url"
|
||||||
|
class="card"
|
||||||
|
data-variant="secondary"
|
||||||
|
>
|
||||||
|
<div v-if="cat.featured_image" class="child-thumbnail">
|
||||||
|
<NuxtLink :to="localePath(`/${cat.url}`)">
|
||||||
|
<img :src="cat.featured_image" :alt="cat.title">
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<div class="stack">
|
||||||
|
<h3>
|
||||||
|
<NuxtLink class="color-white" :to="localePath(`/${cat.url}`)">
|
||||||
|
{{ cat.title }}
|
||||||
|
</NuxtLink>
|
||||||
|
</h3>
|
||||||
|
<div v-if="cat.excerpt" class="child-excerpt" v-html="cat.excerpt" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- … then this category's own pages. -->
|
||||||
|
<div
|
||||||
|
v-for="child in data.children"
|
||||||
|
:key="child.url"
|
||||||
|
class="card"
|
||||||
|
data-variant="secondary"
|
||||||
|
>
|
||||||
<div v-if="child.featured_image" class="child-thumbnail">
|
<div v-if="child.featured_image" class="child-thumbnail">
|
||||||
<NuxtLink :to="localePath(`/${child.slug}`)">
|
<NuxtLink :to="localePath(`/${child.url}`)">
|
||||||
<img :src="child.featured_image" :alt="child.title">
|
<img :src="child.featured_image" :alt="child.title">
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
<h3>
|
<h3>
|
||||||
<NuxtLink class="color-white" :to="localePath(`/${child.slug}`)">
|
<NuxtLink class="color-white" :to="localePath(`/${child.url}`)">
|
||||||
|
{{ child.title }}
|
||||||
|
</NuxtLink>
|
||||||
|
</h3>
|
||||||
|
<div v-if="child.excerpt" class="child-excerpt" v-html="child.excerpt" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="showChildGrid" class="grid">
|
||||||
|
<div v-for="child in data.children" :key="child.url" class="card" data-variant="secondary">
|
||||||
|
<div v-if="child.featured_image" class="child-thumbnail">
|
||||||
|
<NuxtLink :to="localePath(`/${child.url}`)">
|
||||||
|
<img :src="child.featured_image" :alt="child.title">
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<div class="stack">
|
||||||
|
<h3>
|
||||||
|
<NuxtLink class="color-white" :to="localePath(`/${child.url}`)">
|
||||||
{{ child.title }}
|
{{ child.title }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,10 @@ interface Page {
|
||||||
interface CategoryRow {
|
interface CategoryRow {
|
||||||
id: string
|
id: string
|
||||||
slug: string
|
slug: string
|
||||||
|
url: string
|
||||||
|
parent_path: string | null
|
||||||
|
parent_label: string | null
|
||||||
|
parent_id: string | null
|
||||||
menu_order: number
|
menu_order: number
|
||||||
translations: Array<{ locale: string; name: string }>
|
translations: Array<{ locale: string; name: string }>
|
||||||
page_count: number
|
page_count: number
|
||||||
|
|
@ -51,6 +55,8 @@ interface Translation {
|
||||||
interface PageDetail {
|
interface PageDetail {
|
||||||
page: Page
|
page: Page
|
||||||
translations: Translation[]
|
translations: Translation[]
|
||||||
|
url: string
|
||||||
|
category_path: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
@ -59,16 +65,16 @@ const toast = useToast()
|
||||||
|
|
||||||
const pageId = computed(() => String(route.params.id))
|
const pageId = computed(() => String(route.params.id))
|
||||||
|
|
||||||
// Live public URL for the "View page" link in the Publish card. Uses the
|
// Live public URL for the "View page" link in the Publish card. Uses
|
||||||
// committed slug (data) not the in-flight slug ref, so the link always
|
// the server-computed full URL (data.url) so it stays correct as the
|
||||||
// points to an existing route even if the author is editing the slug.
|
// page moves between categories. English is the default locale, so
|
||||||
// English is the default locale, so `prefix_except_default` means no prefix.
|
// `prefix_except_default` means no prefix.
|
||||||
const publicUrl = computed(() => {
|
const publicUrl = computed(() => {
|
||||||
const committedSlug = data.value?.page.slug
|
const committedUrl = data.value?.url
|
||||||
if (!committedSlug) return null
|
if (!committedUrl) return null
|
||||||
return activeLocale.value === 'en'
|
return activeLocale.value === 'en'
|
||||||
? `/${committedSlug}`
|
? `/${committedUrl}`
|
||||||
: `/${activeLocale.value}/${committedSlug}`
|
: `/${activeLocale.value}/${committedUrl}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] }
|
const EMPTY_DOC = { type: 'doc', content: [{ type: 'paragraph' }] }
|
||||||
|
|
@ -82,13 +88,15 @@ const { data: categoriesData } = await useFetch<{ rows: CategoryRow[] }>(
|
||||||
const categories = computed(() => categoriesData.value?.rows ?? [])
|
const categories = computed(() => categoriesData.value?.rows ?? [])
|
||||||
|
|
||||||
function categoryLabel(cat: CategoryRow): string {
|
function categoryLabel(cat: CategoryRow): string {
|
||||||
const en = cat.translations.find(t => t.locale === 'en')?.name
|
const en = cat.translations.find(t => t.locale === 'en')?.name ?? cat.slug
|
||||||
return en ?? cat.slug
|
return cat.parent_label ? `${cat.parent_label} / ${en}` : en
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryItems = computed(() => [
|
const categoryItems = computed(() => [
|
||||||
{ label: '— Uncategorized —', value: null as string | null },
|
{ label: '— Uncategorized —', value: null as string | null },
|
||||||
...categories.value.map(c => ({ label: categoryLabel(c), value: c.id as string | null }))
|
...[...categories.value]
|
||||||
|
.sort((a, b) => a.url.localeCompare(b.url))
|
||||||
|
.map(c => ({ label: categoryLabel(c), value: c.id as string | null }))
|
||||||
])
|
])
|
||||||
|
|
||||||
// Metadata editable in the top bar
|
// Metadata editable in the top bar
|
||||||
|
|
@ -115,6 +123,16 @@ const selectedCategory = computed(() =>
|
||||||
|
|
||||||
const isCategoryChanged = computed(() => categoryId.value !== originalCategoryId.value)
|
const isCategoryChanged = computed(() => categoryId.value !== originalCategoryId.value)
|
||||||
|
|
||||||
|
// Live preview of the full URL — re-derived from the (in-flight) leaf
|
||||||
|
// slug + selected category so the editor sees the new URL before they
|
||||||
|
// save. Used in the Slug field's helper text.
|
||||||
|
const previewFullUrl = computed(() => {
|
||||||
|
const leaf = slug.value.trim().replace(/^\/+|\/+$/g, '')
|
||||||
|
if (!leaf) return ''
|
||||||
|
const prefix = selectedCategory.value?.url
|
||||||
|
return prefix ? `${prefix}/${leaf}` : leaf
|
||||||
|
})
|
||||||
|
|
||||||
const THEME_OPTIONS: Array<{ label: string; value: PageTheme }> = [
|
const THEME_OPTIONS: Array<{ label: string; value: PageTheme }> = [
|
||||||
{ label: 'Default', value: 'default' },
|
{ label: 'Default', value: 'default' },
|
||||||
{ label: 'Green background', value: 'green' }
|
{ label: 'Green background', value: 'green' }
|
||||||
|
|
@ -646,7 +664,7 @@ function formatVersionTime(iso: string): string {
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<UFormField
|
<UFormField
|
||||||
label="Category"
|
label="Category"
|
||||||
:description="isCategoryChanged ? 'Changing the category rewrites the slug prefix — the old URL will 404.' : 'Group this page under a category.'"
|
:description="isCategoryChanged ? 'Moving to a different category will change this page’s URL — the old URL will 404.' : 'Group this page under a category.'"
|
||||||
>
|
>
|
||||||
<USelect
|
<USelect
|
||||||
v-model="categoryId"
|
v-model="categoryId"
|
||||||
|
|
@ -657,9 +675,13 @@ function formatVersionTime(iso: string): string {
|
||||||
</UFormField>
|
</UFormField>
|
||||||
<UFormField
|
<UFormField
|
||||||
label="Slug"
|
label="Slug"
|
||||||
:description="selectedCategory ? `Full URL: /${slug}. Must start with ${selectedCategory.slug}/` : 'URL path (e.g. privacy)'"
|
:description="previewFullUrl ? `Full URL: /${previewFullUrl}` : 'Leaf segment only — lowercase letters, digits, dashes.'"
|
||||||
>
|
>
|
||||||
<UInput v-model="slug" />
|
<div v-if="selectedCategory" class="flex items-center gap-1 min-w-0">
|
||||||
|
<span class="shrink-0 text-(--ui-text-muted) font-mono text-sm truncate">{{ selectedCategory.url }}/</span>
|
||||||
|
<UInput v-model="slug" class="flex-1 min-w-0" />
|
||||||
|
</div>
|
||||||
|
<UInput v-else v-model="slug" class="w-full" />
|
||||||
</UFormField>
|
</UFormField>
|
||||||
<UFormField label="Page theme" description="Applied to <body>.">
|
<UFormField label="Page theme" description="Applied to <body>.">
|
||||||
<USelect v-model="theme" :items="THEME_OPTIONS" class="w-full" />
|
<USelect v-model="theme" :items="THEME_OPTIONS" class="w-full" />
|
||||||
|
|
|
||||||
|
|
@ -17,22 +17,45 @@ definePageMeta({
|
||||||
interface CategoryPage {
|
interface CategoryPage {
|
||||||
id: string
|
id: string
|
||||||
slug: string
|
slug: string
|
||||||
|
url: string
|
||||||
menu_order: number
|
menu_order: number
|
||||||
title_en: string | null
|
title_en: string | null
|
||||||
published_count: number
|
published_count: number
|
||||||
translation_count: number
|
translation_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ChildCategory {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
url: string
|
||||||
|
menu_order: number
|
||||||
|
}
|
||||||
|
|
||||||
interface CategoryDetail {
|
interface CategoryDetail {
|
||||||
category: {
|
category: {
|
||||||
id: string
|
id: string
|
||||||
slug: string
|
slug: string
|
||||||
|
parent_id: string | null
|
||||||
menu_order: number
|
menu_order: number
|
||||||
created: string
|
created: string
|
||||||
updated: string
|
updated: string
|
||||||
}
|
}
|
||||||
|
url: string
|
||||||
|
parent_path: string | null
|
||||||
|
parent_label: string | null
|
||||||
translations: Array<{ locale: string; name: string; updated: string }>
|
translations: Array<{ locale: string; name: string; updated: string }>
|
||||||
pages: CategoryPage[]
|
pages: CategoryPage[]
|
||||||
|
child_categories: ChildCategory[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryRow {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
url: string
|
||||||
|
parent_id: string | null
|
||||||
|
parent_path: string | null
|
||||||
|
parent_label: string | null
|
||||||
|
translations: Array<{ locale: string; name: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
@ -46,6 +69,8 @@ const { data, pending, refresh } = await useFetch<CategoryDetail>(
|
||||||
|
|
||||||
const slug = ref('')
|
const slug = ref('')
|
||||||
const originalSlug = ref('')
|
const originalSlug = ref('')
|
||||||
|
const parentId = ref<string | null>(null)
|
||||||
|
const originalParentId = ref<string | null>(null)
|
||||||
const names = reactive<Record<string, string>>({})
|
const names = reactive<Record<string, string>>({})
|
||||||
for (const l of ENABLED_LANGUAGES) names[l.code] = ''
|
for (const l of ENABLED_LANGUAGES) names[l.code] = ''
|
||||||
|
|
||||||
|
|
@ -59,6 +84,8 @@ watch(data, (value) => {
|
||||||
if (!value) return
|
if (!value) return
|
||||||
slug.value = value.category.slug
|
slug.value = value.category.slug
|
||||||
originalSlug.value = value.category.slug
|
originalSlug.value = value.category.slug
|
||||||
|
parentId.value = value.category.parent_id
|
||||||
|
originalParentId.value = value.category.parent_id
|
||||||
for (const l of ENABLED_LANGUAGES) {
|
for (const l of ENABLED_LANGUAGES) {
|
||||||
const t = value.translations.find(x => x.locale === l.code)
|
const t = value.translations.find(x => x.locale === l.code)
|
||||||
names[l.code] = t?.name ?? ''
|
names[l.code] = t?.name ?? ''
|
||||||
|
|
@ -71,7 +98,51 @@ const activeLocale = ref<string>('en')
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const savingOrder = ref(false)
|
const savingOrder = ref(false)
|
||||||
|
|
||||||
|
const { data: allCategoriesData } = await useFetch<{ rows: CategoryRow[] }>(
|
||||||
|
'/api/admin/categories',
|
||||||
|
{ default: () => ({ rows: [] }) }
|
||||||
|
)
|
||||||
|
|
||||||
|
function categoryLabel(cat: CategoryRow): string {
|
||||||
|
const en = cat.translations.find(t => t.locale === 'en')?.name ?? cat.slug
|
||||||
|
return cat.parent_label ? `${cat.parent_label} / ${en}` : en
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block self/descendants from the parent picker so the user can't
|
||||||
|
// accidentally request a cycle (the server would reject anyway, but
|
||||||
|
// not surfacing the option avoids a confusing failure).
|
||||||
|
const descendantIds = computed(() => {
|
||||||
|
const all = allCategoriesData.value?.rows ?? []
|
||||||
|
const childrenByParent = new Map<string | null, CategoryRow[]>()
|
||||||
|
for (const c of all) {
|
||||||
|
const key = c.parent_id ?? null
|
||||||
|
const list = childrenByParent.get(key) ?? []
|
||||||
|
list.push(c)
|
||||||
|
childrenByParent.set(key, list)
|
||||||
|
}
|
||||||
|
const out = new Set<string>([categoryId.value])
|
||||||
|
const stack = [categoryId.value]
|
||||||
|
while (stack.length) {
|
||||||
|
const id = stack.pop()!
|
||||||
|
for (const c of childrenByParent.get(id) ?? []) {
|
||||||
|
out.add(c.id)
|
||||||
|
stack.push(c.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
const parentItems = computed(() => [
|
||||||
|
{ label: '— Top level —', value: null as string | null },
|
||||||
|
...[...(allCategoriesData.value?.rows ?? [])]
|
||||||
|
.filter(c => !descendantIds.value.has(c.id))
|
||||||
|
.sort((a, b) => a.url.localeCompare(b.url))
|
||||||
|
.map(c => ({ label: categoryLabel(c), value: c.id as string | null }))
|
||||||
|
])
|
||||||
|
|
||||||
const slugWillRename = computed(() => slug.value.trim() !== originalSlug.value)
|
const slugWillRename = computed(() => slug.value.trim() !== originalSlug.value)
|
||||||
|
const parentWillChange = computed(() => parentId.value !== originalParentId.value)
|
||||||
|
const willChangeUrls = computed(() => slugWillRename.value || parentWillChange.value)
|
||||||
|
|
||||||
async function saveDetails() {
|
async function saveDetails() {
|
||||||
const slugValue = slug.value.trim().replace(/^\/+|\/+$/g, '')
|
const slugValue = slug.value.trim().replace(/^\/+|\/+$/g, '')
|
||||||
|
|
@ -92,7 +163,7 @@ async function saveDetails() {
|
||||||
}))
|
}))
|
||||||
await $fetch(`/api/admin/categories/${categoryId.value}`, {
|
await $fetch(`/api/admin/categories/${categoryId.value}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: { slug: slugValue, translations }
|
body: { slug: slugValue, parent_id: parentId.value, translations }
|
||||||
})
|
})
|
||||||
toast.add({ title: 'Category saved', color: 'success' })
|
toast.add({ title: 'Category saved', color: 'success' })
|
||||||
await refresh()
|
await refresh()
|
||||||
|
|
@ -169,8 +240,10 @@ async function deleteCategory() {
|
||||||
variant="outline"
|
variant="outline"
|
||||||
color="error"
|
color="error"
|
||||||
icon="i-lucide-trash-2"
|
icon="i-lucide-trash-2"
|
||||||
:disabled="(data?.pages.length ?? 0) > 0"
|
:disabled="(data?.pages.length ?? 0) > 0 || (data?.child_categories.length ?? 0) > 0"
|
||||||
:title="(data?.pages.length ?? 0) > 0 ? 'Move or delete member pages first' : 'Delete category'"
|
:title="(data?.pages.length ?? 0) > 0
|
||||||
|
? 'Move or delete member pages first'
|
||||||
|
: ((data?.child_categories.length ?? 0) > 0 ? 'Move or delete child categories first' : 'Delete category')"
|
||||||
@click="deleteModalOpen = true"
|
@click="deleteModalOpen = true"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
|
|
@ -188,9 +261,20 @@ async function deleteCategory() {
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
<UFormField
|
||||||
|
label="Parent category"
|
||||||
|
:description="parentWillChange ? 'Moving this category to a different parent will change every URL beneath it. Old URLs will 404.' : 'Leave as Top level for a root category.'"
|
||||||
|
>
|
||||||
|
<USelect
|
||||||
|
v-model="parentId"
|
||||||
|
:items="parentItems"
|
||||||
|
value-key="value"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
<UFormField
|
<UFormField
|
||||||
label="Slug"
|
label="Slug"
|
||||||
:description="slugWillRename ? 'Changing the slug will rewrite every member page URL. Old URLs will 404.' : 'URL prefix for member pages.'"
|
:description="willChangeUrls ? 'Saving will rewrite every URL beneath this category. Old URLs will 404.' : `Current URL: /${data.url}`"
|
||||||
>
|
>
|
||||||
<UInput v-model="slug" />
|
<UInput v-model="slug" />
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
@ -230,6 +314,32 @@ async function deleteCategory() {
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
|
<UCard v-if="data.child_categories?.length">
|
||||||
|
<template #header>
|
||||||
|
<span class="text-sm font-semibold">Child categories ({{ data.child_categories.length }})</span>
|
||||||
|
</template>
|
||||||
|
<ul class="divide-y divide-(--ui-border)">
|
||||||
|
<li
|
||||||
|
v-for="child in data.child_categories"
|
||||||
|
:key="child.id"
|
||||||
|
class="flex items-center gap-3 py-2 px-2"
|
||||||
|
>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm truncate">{{ child.slug }}</div>
|
||||||
|
<div class="text-xs text-(--ui-text-muted) font-mono truncate">/{{ child.url }}</div>
|
||||||
|
</div>
|
||||||
|
<UButton
|
||||||
|
size="xs"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-lucide-pencil"
|
||||||
|
:to="`/admin/pages/categories/${child.id}`"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</UButton>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
<UCard>
|
<UCard>
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
|
|
@ -272,10 +382,10 @@ async function deleteCategory() {
|
||||||
<div class="text-sm truncate">
|
<div class="text-sm truncate">
|
||||||
{{ page.title_en || 'Untitled' }}
|
{{ page.title_en || 'Untitled' }}
|
||||||
<UBadge v-if="idx === 0" size="xs" variant="subtle" color="info" class="ml-2">
|
<UBadge v-if="idx === 0" size="xs" variant="subtle" color="info" class="ml-2">
|
||||||
Default for /{{ data.category.slug }}
|
Default for /{{ data.url }}
|
||||||
</UBadge>
|
</UBadge>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-(--ui-text-muted) font-mono truncate">{{ page.slug }}</div>
|
<div class="text-xs text-(--ui-text-muted) font-mono truncate">/{{ page.url }}</div>
|
||||||
</div>
|
</div>
|
||||||
<UBadge
|
<UBadge
|
||||||
size="xs"
|
size="xs"
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@ definePageMeta({
|
||||||
interface CategoryRow {
|
interface CategoryRow {
|
||||||
id: string
|
id: string
|
||||||
slug: string
|
slug: string
|
||||||
|
url: string
|
||||||
|
parent_id: string | null
|
||||||
|
parent_path: string | null
|
||||||
|
parent_label: string | null
|
||||||
menu_order: number
|
menu_order: number
|
||||||
translations: Array<{ locale: string; name: string; updated: string }>
|
translations: Array<{ locale: string; name: string; updated: string }>
|
||||||
page_count: number
|
page_count: number
|
||||||
|
|
@ -30,6 +34,35 @@ function englishName(row: CategoryRow): string {
|
||||||
return row.translations.find(t => t.locale === 'en')?.name ?? row.slug
|
return row.translations.find(t => t.locale === 'en')?.name ?? row.slug
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Group categories by parent so the table renders a tree (root rows
|
||||||
|
// followed by their children, indented). Walks from each root and
|
||||||
|
// emits descendants depth-first.
|
||||||
|
const sortedRows = computed<Array<CategoryRow & { depth: number }>>(() => {
|
||||||
|
const all = data.value?.rows ?? []
|
||||||
|
if (all.length === 0) return []
|
||||||
|
const byParent = new Map<string | null, CategoryRow[]>()
|
||||||
|
for (const row of all) {
|
||||||
|
const key = row.parent_id ?? null
|
||||||
|
const list = byParent.get(key) ?? []
|
||||||
|
list.push(row)
|
||||||
|
byParent.set(key, list)
|
||||||
|
}
|
||||||
|
for (const list of byParent.values()) {
|
||||||
|
list.sort((a, b) =>
|
||||||
|
a.menu_order - b.menu_order || a.slug.localeCompare(b.slug)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const out: Array<CategoryRow & { depth: number }> = []
|
||||||
|
function walk(parentId: string | null, depth: number) {
|
||||||
|
for (const row of byParent.get(parentId) ?? []) {
|
||||||
|
out.push({ ...row, depth })
|
||||||
|
walk(row.id, depth + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(null, 0)
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
// Delete flow — blocked server-side if any pages still belong to the
|
// Delete flow — blocked server-side if any pages still belong to the
|
||||||
// category, so we surface the 409 message directly.
|
// category, so we surface the 409 message directly.
|
||||||
const deleteModalOpen = ref(false)
|
const deleteModalOpen = ref(false)
|
||||||
|
|
@ -93,7 +126,7 @@ async function confirmDelete() {
|
||||||
<thead class="bg-(--ui-bg-elevated) text-(--ui-text-muted)">
|
<thead class="bg-(--ui-bg-elevated) text-(--ui-text-muted)">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="text-left px-3 py-2 font-medium">Name (EN)</th>
|
<th class="text-left px-3 py-2 font-medium">Name (EN)</th>
|
||||||
<th class="text-left px-3 py-2 font-medium">Slug</th>
|
<th class="text-left px-3 py-2 font-medium">URL</th>
|
||||||
<th class="text-left px-3 py-2 font-medium">Translations</th>
|
<th class="text-left px-3 py-2 font-medium">Translations</th>
|
||||||
<th class="text-left px-3 py-2 font-medium">Pages</th>
|
<th class="text-left px-3 py-2 font-medium">Pages</th>
|
||||||
<th class="text-right px-3 py-2 font-medium">Actions</th>
|
<th class="text-right px-3 py-2 font-medium">Actions</th>
|
||||||
|
|
@ -101,13 +134,18 @@ async function confirmDelete() {
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr
|
<tr
|
||||||
v-for="row in data.rows"
|
v-for="row in sortedRows"
|
||||||
:key="row.id"
|
:key="row.id"
|
||||||
class="border-t border-(--ui-border) hover:bg-(--ui-bg-elevated) cursor-pointer"
|
class="border-t border-(--ui-border) hover:bg-(--ui-bg-elevated) cursor-pointer"
|
||||||
@click="router.push(`/admin/pages/categories/${row.id}`)"
|
@click="router.push(`/admin/pages/categories/${row.id}`)"
|
||||||
>
|
>
|
||||||
<td class="px-3 py-2">{{ englishName(row) }}</td>
|
<td class="px-3 py-2">
|
||||||
<td class="px-3 py-2 font-mono">{{ row.slug }}</td>
|
<span :style="{ paddingLeft: `${row.depth * 1.25}rem` }">
|
||||||
|
<span v-if="row.depth > 0" class="text-(--ui-text-muted) mr-1">↳</span>
|
||||||
|
{{ englishName(row) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 font-mono text-xs">/{{ row.url }}</td>
|
||||||
<td class="px-3 py-2">
|
<td class="px-3 py-2">
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
<UBadge
|
<UBadge
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// Admin: create a new category. Collect the slug and a per-locale name
|
// Admin: create a new category. Collect the slug, an optional parent
|
||||||
// for each enabled language. Server insists on a non-empty English
|
// category, and a per-locale name for each enabled language. Server
|
||||||
// name; the other locales can be filled in here or later on the edit
|
// insists on a non-empty English name.
|
||||||
// screen.
|
|
||||||
|
|
||||||
import { ENABLED_LANGUAGES } from '~~/config/languages'
|
import { ENABLED_LANGUAGES } from '~~/config/languages'
|
||||||
|
|
||||||
|
|
@ -11,16 +10,53 @@ definePageMeta({
|
||||||
middleware: ['auth', 'admin']
|
middleware: ['auth', 'admin']
|
||||||
})
|
})
|
||||||
|
|
||||||
|
interface CategoryRow {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
url: string
|
||||||
|
parent_id: string | null
|
||||||
|
parent_path: string | null
|
||||||
|
parent_label: string | null
|
||||||
|
translations: Array<{ locale: string; name: string }>
|
||||||
|
}
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const slug = ref('')
|
const slug = ref('')
|
||||||
|
const parentId = ref<string | null>(null)
|
||||||
const names = reactive<Record<string, string>>({})
|
const names = reactive<Record<string, string>>({})
|
||||||
for (const l of ENABLED_LANGUAGES) names[l.code] = ''
|
for (const l of ENABLED_LANGUAGES) names[l.code] = ''
|
||||||
|
|
||||||
const activeLocale = ref<string>('en')
|
const activeLocale = ref<string>('en')
|
||||||
const creating = ref(false)
|
const creating = ref(false)
|
||||||
|
|
||||||
|
const { data: categoriesData } = await useFetch<{ rows: CategoryRow[] }>(
|
||||||
|
'/api/admin/categories',
|
||||||
|
{ default: () => ({ rows: [] }) }
|
||||||
|
)
|
||||||
|
|
||||||
|
const categories = computed(() => categoriesData.value?.rows ?? [])
|
||||||
|
|
||||||
|
function categoryLabel(cat: CategoryRow): string {
|
||||||
|
const en = cat.translations.find(t => t.locale === 'en')?.name ?? cat.slug
|
||||||
|
return cat.parent_label ? `${cat.parent_label} / ${en}` : en
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentItems = computed(() => [
|
||||||
|
{ label: '— Top level —', value: null as string | null },
|
||||||
|
...[...categories.value]
|
||||||
|
.sort((a, b) => a.url.localeCompare(b.url))
|
||||||
|
.map(c => ({ label: categoryLabel(c), value: c.id as string | null }))
|
||||||
|
])
|
||||||
|
|
||||||
|
const previewUrl = computed(() => {
|
||||||
|
const leaf = slug.value.trim().replace(/^\/+|\/+$/g, '')
|
||||||
|
if (!leaf) return ''
|
||||||
|
const parent = parentId.value ? categories.value.find(c => c.id === parentId.value) : null
|
||||||
|
return parent ? `${parent.url}/${leaf}` : leaf
|
||||||
|
})
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
const slugValue = slug.value.trim().replace(/^\/+|\/+$/g, '')
|
const slugValue = slug.value.trim().replace(/^\/+|\/+$/g, '')
|
||||||
if (!slugValue) {
|
if (!slugValue) {
|
||||||
|
|
@ -41,7 +77,7 @@ async function submit() {
|
||||||
|
|
||||||
const category = await $fetch<{ id: string }>('/api/admin/categories', {
|
const category = await $fetch<{ id: string }>('/api/admin/categories', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { slug: slugValue, translations }
|
body: { slug: slugValue, parent_id: parentId.value, translations }
|
||||||
})
|
})
|
||||||
toast.add({ title: 'Category created', color: 'success' })
|
toast.add({ title: 'Category created', color: 'success' })
|
||||||
router.push(`/admin/pages/categories/${category.id}`)
|
router.push(`/admin/pages/categories/${category.id}`)
|
||||||
|
|
@ -66,8 +102,21 @@ async function submit() {
|
||||||
|
|
||||||
<UCard>
|
<UCard>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<UFormField label="Slug" required description="Lowercase letters, digits, and dashes. Used as the URL prefix for every page in this category (e.g. about).">
|
<UFormField label="Parent category" description="Leave as Top level for a root category like /resources.">
|
||||||
<UInput v-model="slug" placeholder="about" />
|
<USelect
|
||||||
|
v-model="parentId"
|
||||||
|
:items="parentItems"
|
||||||
|
value-key="value"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField
|
||||||
|
label="Slug"
|
||||||
|
required
|
||||||
|
:description="previewUrl ? `URL will be /${previewUrl}` : 'Lowercase letters, digits, and dashes (no slashes).'"
|
||||||
|
>
|
||||||
|
<UInput v-model="slug" placeholder="adoption" />
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -94,7 +143,7 @@ async function submit() {
|
||||||
:label="`${l.flag} ${l.nativeName}`"
|
:label="`${l.flag} ${l.nativeName}`"
|
||||||
:required="l.code === 'en'"
|
:required="l.code === 'en'"
|
||||||
>
|
>
|
||||||
<UInput v-model="names[l.code]" :placeholder="l.code === 'en' ? 'About' : 'Leave blank to fill later'" />
|
<UInput v-model="names[l.code]" :placeholder="l.code === 'en' ? 'Adoption Resources' : 'Leave blank to fill later'" />
|
||||||
</UFormField>
|
</UFormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ definePageMeta({
|
||||||
interface PageRow {
|
interface PageRow {
|
||||||
id: string
|
id: string
|
||||||
slug: string
|
slug: string
|
||||||
|
url: string
|
||||||
category_id: string | null
|
category_id: string | null
|
||||||
category_slug: string | null
|
category_slug: string | null
|
||||||
category_name_en: string | null
|
category_name_en: string | null
|
||||||
|
|
@ -24,6 +25,10 @@ interface PageRow {
|
||||||
interface CategoryRow {
|
interface CategoryRow {
|
||||||
id: string
|
id: string
|
||||||
slug: string
|
slug: string
|
||||||
|
url: string
|
||||||
|
parent_path: string | null
|
||||||
|
parent_label: string | null
|
||||||
|
parent_id: string | null
|
||||||
menu_order: number
|
menu_order: number
|
||||||
translations: Array<{ locale: string; name: string }>
|
translations: Array<{ locale: string; name: string }>
|
||||||
page_count: number
|
page_count: number
|
||||||
|
|
@ -45,8 +50,8 @@ const { data: categoriesData } = await useFetch<{ rows: CategoryRow[] }>(
|
||||||
const categories = computed(() => categoriesData.value?.rows ?? [])
|
const categories = computed(() => categoriesData.value?.rows ?? [])
|
||||||
|
|
||||||
function categoryLabel(cat: CategoryRow): string {
|
function categoryLabel(cat: CategoryRow): string {
|
||||||
const en = cat.translations.find(t => t.locale === 'en')?.name
|
const en = cat.translations.find(t => t.locale === 'en')?.name ?? cat.slug
|
||||||
return en ?? cat.slug
|
return cat.parent_label ? `${cat.parent_label} / ${en}` : en
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modal state for creating a new page
|
// Modal state for creating a new page
|
||||||
|
|
@ -58,7 +63,7 @@ const creating = ref(false)
|
||||||
const newCategoryPrefix = computed(() => {
|
const newCategoryPrefix = computed(() => {
|
||||||
if (!newCategoryId.value) return ''
|
if (!newCategoryId.value) return ''
|
||||||
const cat = categories.value.find(c => c.id === newCategoryId.value)
|
const cat = categories.value.find(c => c.id === newCategoryId.value)
|
||||||
return cat ? `${cat.slug}/` : ''
|
return cat ? `${cat.url}/` : ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const newFullSlug = computed(() => {
|
const newFullSlug = computed(() => {
|
||||||
|
|
@ -68,7 +73,9 @@ const newFullSlug = computed(() => {
|
||||||
|
|
||||||
const categoryItems = computed(() => [
|
const categoryItems = computed(() => [
|
||||||
{ label: '— Uncategorized —', value: null as string | null },
|
{ label: '— Uncategorized —', value: null as string | null },
|
||||||
...categories.value.map(c => ({ label: categoryLabel(c), value: c.id as string | null }))
|
...[...categories.value]
|
||||||
|
.sort((a, b) => a.url.localeCompare(b.url))
|
||||||
|
.map(c => ({ label: categoryLabel(c), value: c.id as string | null }))
|
||||||
])
|
])
|
||||||
|
|
||||||
function resetCreateForm() {
|
function resetCreateForm() {
|
||||||
|
|
@ -79,8 +86,8 @@ function resetCreateForm() {
|
||||||
async function createPage() {
|
async function createPage() {
|
||||||
creating.value = true
|
creating.value = true
|
||||||
try {
|
try {
|
||||||
const slug = newFullSlug.value
|
const leaf = newSlugLeaf.value.trim().replace(/^\/+|\/+$/g, '')
|
||||||
if (!slug) {
|
if (!leaf) {
|
||||||
toast.add({ title: 'Slug is required', color: 'error' })
|
toast.add({ title: 'Slug is required', color: 'error' })
|
||||||
creating.value = false
|
creating.value = false
|
||||||
return
|
return
|
||||||
|
|
@ -88,7 +95,7 @@ async function createPage() {
|
||||||
const page = await $fetch<{ id: string }>('/api/admin/pages', {
|
const page = await $fetch<{ id: string }>('/api/admin/pages', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
slug,
|
slug: leaf,
|
||||||
category_id: newCategoryId.value
|
category_id: newCategoryId.value
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -226,7 +233,7 @@ const filteredRows = computed<PageRow[]>(() => {
|
||||||
<span v-if="row.title_en">{{ row.title_en }}</span>
|
<span v-if="row.title_en">{{ row.title_en }}</span>
|
||||||
<em v-else class="text-(--ui-text-muted)">Untitled</em>
|
<em v-else class="text-(--ui-text-muted)">Untitled</em>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-2 font-mono">{{ row.slug }}</td>
|
<td class="px-3 py-2 font-mono text-xs">{{ row.url }}</td>
|
||||||
<td class="px-3 py-2">
|
<td class="px-3 py-2">
|
||||||
<span v-if="row.category_slug">{{ row.category_name_en || row.category_slug }}</span>
|
<span v-if="row.category_slug">{{ row.category_name_en || row.category_slug }}</span>
|
||||||
<em v-else class="text-(--ui-text-muted)">—</em>
|
<em v-else class="text-(--ui-text-muted)">—</em>
|
||||||
|
|
@ -264,12 +271,12 @@ const filteredRows = computed<PageRow[]>(() => {
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
<UFormField label="Slug" required :description="newCategoryPrefix ? `Full URL will be /${newFullSlug}` : 'e.g. privacy, contact'">
|
<UFormField label="Slug" required :description="newSlugLeaf ? `Full URL will be /${newFullSlug}` : 'Leaf segment only — lowercase letters, digits, dashes.'">
|
||||||
<UInput v-model="newSlugLeaf" :placeholder="newCategoryId ? 'vision' : 'privacy'">
|
<div v-if="newCategoryPrefix" class="flex items-center gap-1 min-w-0">
|
||||||
<template v-if="newCategoryPrefix" #leading>
|
<span class="shrink-0 text-(--ui-text-muted) font-mono text-sm truncate">{{ newCategoryPrefix }}</span>
|
||||||
<span class="text-(--ui-text-muted) font-mono text-sm">{{ newCategoryPrefix }}</span>
|
<UInput v-model="newSlugLeaf" :placeholder="newCategoryId ? 'vision' : 'privacy'" class="flex-1 min-w-0" />
|
||||||
</template>
|
</div>
|
||||||
</UInput>
|
<UInput v-else v-model="newSlugLeaf" :placeholder="newCategoryId ? 'vision' : 'privacy'" class="w-full" />
|
||||||
</UFormField>
|
</UFormField>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
112
migrations/014_nested_categories_leaf_slugs.ts
Normal file
112
migrations/014_nested_categories_leaf_slugs.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { Kysely, sql } from 'kysely'
|
||||||
|
|
||||||
|
// Switches the CMS to nested categories with leaf-only slugs.
|
||||||
|
//
|
||||||
|
// Before: pages.slug stored the full URL path ("resources/overview");
|
||||||
|
// categories were flat (no parent_id); the slug prefix and category_id
|
||||||
|
// were independent fields that could drift apart.
|
||||||
|
//
|
||||||
|
// After: each category has an optional parent_id forming a tree;
|
||||||
|
// pages.slug stores only the page's own segment ("overview"); the
|
||||||
|
// public URL is derived at request time by walking the category chain
|
||||||
|
// + appending the page slug. Renaming or moving anywhere in the tree
|
||||||
|
// instantly updates every URL underneath.
|
||||||
|
//
|
||||||
|
// Uniqueness:
|
||||||
|
// - categories(parent_id, slug) unique — top-level slugs unique
|
||||||
|
// across NULL parents via a partial index.
|
||||||
|
// - pages(category_id, slug) unique — uncategorized slugs unique via
|
||||||
|
// a partial index. (Postgres treats NULLs as distinct in normal
|
||||||
|
// unique indexes.)
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
// ── 1. Add parent_id to categories ─────────────────────────────
|
||||||
|
await db.schema
|
||||||
|
.alterTable('categories')
|
||||||
|
.addColumn('parent_id', 'uuid', col =>
|
||||||
|
col.references('categories.id').onDelete('restrict')
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createIndex('categories_parent_id_idx')
|
||||||
|
.ifNotExists()
|
||||||
|
.on('categories')
|
||||||
|
.column('parent_id')
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
// ── 2. Drop the old global slug uniqueness on both tables BEFORE
|
||||||
|
// rewriting page slugs — different categories will end up
|
||||||
|
// with the same leaf (e.g. each one's "overview" page).
|
||||||
|
// Drop the constraint first; the implicit unique index goes with
|
||||||
|
// it. (Dropping the index directly fails because the constraint
|
||||||
|
// owns it.) Some Kysely-Postgres versions name the index instead;
|
||||||
|
// try both forms with IF EXISTS so we don't care which one exists.
|
||||||
|
await sql`ALTER TABLE categories DROP CONSTRAINT IF EXISTS categories_slug_key`.execute(db)
|
||||||
|
await db.schema.dropIndex('categories_slug_key').ifExists().execute()
|
||||||
|
|
||||||
|
await sql`ALTER TABLE pages DROP CONSTRAINT IF EXISTS pages_slug_key`.execute(db)
|
||||||
|
await db.schema.dropIndex('pages_slug_key').ifExists().execute()
|
||||||
|
|
||||||
|
// ── 3. Split page slugs to leaves. Every page whose slug starts
|
||||||
|
// with "{category.slug}/" gets its slug rewritten to whatever
|
||||||
|
// follows the prefix.
|
||||||
|
await sql`
|
||||||
|
UPDATE pages p
|
||||||
|
SET slug = substring(p.slug from length(c.slug) + 2)
|
||||||
|
FROM categories c
|
||||||
|
WHERE p.category_id = c.id
|
||||||
|
AND p.slug LIKE c.slug || '/%'
|
||||||
|
`.execute(db)
|
||||||
|
|
||||||
|
// ── 4. Re-add scoped uniqueness on both tables (partial indexes so
|
||||||
|
// NULL parent / NULL category still enforce uniqueness across
|
||||||
|
// the top-level set).
|
||||||
|
await sql`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS categories_parent_slug_uidx
|
||||||
|
ON categories (parent_id, slug)
|
||||||
|
WHERE parent_id IS NOT NULL
|
||||||
|
`.execute(db)
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS categories_root_slug_uidx
|
||||||
|
ON categories (slug)
|
||||||
|
WHERE parent_id IS NULL
|
||||||
|
`.execute(db)
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS pages_category_slug_uidx
|
||||||
|
ON pages (category_id, slug)
|
||||||
|
WHERE category_id IS NOT NULL
|
||||||
|
`.execute(db)
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS pages_root_slug_uidx
|
||||||
|
ON pages (slug)
|
||||||
|
WHERE category_id IS NULL
|
||||||
|
`.execute(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
// Re-prefix every page's slug with its category slug so the old
|
||||||
|
// global-slug-as-path model still resolves URLs.
|
||||||
|
await sql`
|
||||||
|
UPDATE pages p
|
||||||
|
SET slug = c.slug || '/' || p.slug
|
||||||
|
FROM categories c
|
||||||
|
WHERE p.category_id = c.id
|
||||||
|
AND position('/' in p.slug) = 0
|
||||||
|
AND p.slug <> c.slug
|
||||||
|
`.execute(db)
|
||||||
|
|
||||||
|
await sql`DROP INDEX IF EXISTS pages_category_slug_uidx`.execute(db)
|
||||||
|
await sql`DROP INDEX IF EXISTS pages_root_slug_uidx`.execute(db)
|
||||||
|
await sql`DROP INDEX IF EXISTS categories_parent_slug_uidx`.execute(db)
|
||||||
|
await sql`DROP INDEX IF EXISTS categories_root_slug_uidx`.execute(db)
|
||||||
|
|
||||||
|
await sql`ALTER TABLE pages ADD CONSTRAINT pages_slug_key UNIQUE (slug)`.execute(db)
|
||||||
|
await sql`ALTER TABLE categories ADD CONSTRAINT categories_slug_key UNIQUE (slug)`.execute(db)
|
||||||
|
|
||||||
|
await db.schema.dropIndex('categories_parent_id_idx').ifExists().execute()
|
||||||
|
await db.schema.alterTable('categories').dropColumn('parent_id').execute()
|
||||||
|
}
|
||||||
|
|
@ -14,10 +14,14 @@ export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
const result = await deleteCmsCategory(id)
|
const result = await deleteCmsCategory(id)
|
||||||
if ('error' in result) {
|
if ('error' in result) {
|
||||||
|
const message = result.error === 'child_categories_present'
|
||||||
|
? 'This category still has child categories. Move or delete them first.'
|
||||||
|
: 'This category still has pages. Move or delete them first.'
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 409,
|
statusCode: 409,
|
||||||
statusMessage: 'This category still has pages. Move or delete them first.',
|
statusMessage: message,
|
||||||
data: {
|
data: {
|
||||||
|
error: result.error,
|
||||||
attached_page_count: result.attached_page_count,
|
attached_page_count: result.attached_page_count,
|
||||||
sample_slugs: result.sample_slugs
|
sample_slugs: result.sample_slugs
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
// Admin: fetch a single category with translations and its ordered
|
// Admin: fetch a single category with translations, its computed URL
|
||||||
// member pages (each annotated with the English title for the admin
|
// path, parent/children, and its ordered member pages (each annotated
|
||||||
// reorder list).
|
// with the English title for the admin reorder list).
|
||||||
|
|
||||||
import { defineEventHandler, getRouterParam, createError } from 'h3'
|
import { defineEventHandler, getRouterParam, createError } from 'h3'
|
||||||
import { requirePermission } from '../../../utils/rbac'
|
import { requirePermission } from '../../../utils/rbac'
|
||||||
import { db } from '../../../utils/database'
|
import { db } from '../../../utils/database'
|
||||||
import { getCategory, getCategoryPages } from '../../../database/categories'
|
import { getCategoryPages } from '../../../database/categories'
|
||||||
|
import { getCmsCategory } from '../../../services/cmsCategories'
|
||||||
|
import { loadCategoryTree, categoryUrlPath, pageUrlPath } from '../../../database/categoryTree'
|
||||||
import { sql } from 'kysely'
|
import { sql } from 'kysely'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
|
|
@ -13,12 +15,20 @@ export default defineEventHandler(async (event) => {
|
||||||
const id = getRouterParam(event, 'id')
|
const id = getRouterParam(event, 'id')
|
||||||
if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' })
|
if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' })
|
||||||
|
|
||||||
const record = await getCategory(id)
|
const record = await getCmsCategory(id)
|
||||||
if (!record) throw createError({ statusCode: 404, statusMessage: 'Category not found' })
|
if (!record) throw createError({ statusCode: 404, statusMessage: 'Category not found' })
|
||||||
|
|
||||||
|
const tree = await loadCategoryTree()
|
||||||
|
const childCategories = (tree.childrenByParent.get(id) ?? []).map(c => ({
|
||||||
|
id: c.id,
|
||||||
|
slug: c.slug,
|
||||||
|
menu_order: c.menu_order,
|
||||||
|
url: categoryUrlPath(tree, c.id)
|
||||||
|
}))
|
||||||
|
|
||||||
const pages = await getCategoryPages(id)
|
const pages = await getCategoryPages(id)
|
||||||
if (pages.length === 0) {
|
if (pages.length === 0) {
|
||||||
return { ...record, pages: [] }
|
return { ...record, child_categories: childCategories, pages: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageIds = pages.map(p => p.id)
|
const pageIds = pages.map(p => p.id)
|
||||||
|
|
@ -45,9 +55,11 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...record,
|
...record,
|
||||||
|
child_categories: childCategories,
|
||||||
pages: pages.map(p => ({
|
pages: pages.map(p => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
slug: p.slug,
|
slug: p.slug,
|
||||||
|
url: pageUrlPath(tree, p),
|
||||||
menu_order: p.menu_order,
|
menu_order: p.menu_order,
|
||||||
title_en: byId.get(p.id)?.title_en ?? null,
|
title_en: byId.get(p.id)?.title_en ?? null,
|
||||||
published_count: byId.get(p.id)?.published_count ?? 0,
|
published_count: byId.get(p.id)?.published_count ?? 0,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { ENABLED_LANGUAGE_CODES } from '../../../../config/languages'
|
||||||
|
|
||||||
interface Body {
|
interface Body {
|
||||||
slug?: string
|
slug?: string
|
||||||
|
parent_id?: string | null
|
||||||
menu_order?: number
|
menu_order?: number
|
||||||
translations?: Record<string, { name?: string }> | Array<{ locale: string; name: string }>
|
translations?: Record<string, { name?: string }> | Array<{ locale: string; name: string }>
|
||||||
}
|
}
|
||||||
|
|
@ -45,6 +46,7 @@ export default defineEventHandler(async (event) => {
|
||||||
const result = await updateCmsCategory({
|
const result = await updateCmsCategory({
|
||||||
id,
|
id,
|
||||||
slug: body?.slug != null ? String(body.slug) : undefined,
|
slug: body?.slug != null ? String(body.slug) : undefined,
|
||||||
|
parent_id: body?.parent_id === undefined ? undefined : (body.parent_id ? String(body.parent_id) : null),
|
||||||
menu_order: body?.menu_order !== undefined ? (Number(body.menu_order) || 0) : undefined,
|
menu_order: body?.menu_order !== undefined ? (Number(body.menu_order) || 0) : undefined,
|
||||||
translations
|
translations
|
||||||
})
|
})
|
||||||
|
|
@ -56,7 +58,7 @@ export default defineEventHandler(async (event) => {
|
||||||
userId: authUser.userId,
|
userId: authUser.userId,
|
||||||
userAgent: getHeader(event, 'user-agent') || undefined,
|
userAgent: getHeader(event, 'user-agent') || undefined,
|
||||||
metadata: {
|
metadata: {
|
||||||
changes: (['slug', 'menu_order', 'translations'] as const).filter(k => body?.[k] !== undefined),
|
changes: (['slug', 'parent_id', 'menu_order', 'translations'] as const).filter(k => body?.[k] !== undefined),
|
||||||
slugsPurged: result.slugsToPurge.length,
|
slugsPurged: result.slugsToPurge.length,
|
||||||
source: 'admin-ui'
|
source: 'admin-ui'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
import { defineEventHandler, getRouterParam, readBody, createError } from 'h3'
|
import { defineEventHandler, getRouterParam, readBody, createError } from 'h3'
|
||||||
import { requirePermission } from '../../../../utils/rbac'
|
import { requirePermission } from '../../../../utils/rbac'
|
||||||
import { reorderCategoryPages, getCategoryPages, purgeSlugs } from '../../../../database/categories'
|
import { reorderCategoryPages, getCategoryPages, purgeSlugs } from '../../../../database/categories'
|
||||||
|
import { loadCategoryTree, pageUrlPath } from '../../../../database/categoryTree'
|
||||||
import { logUpdate } from '../../../../utils/activity-logger'
|
import { logUpdate } from '../../../../utils/activity-logger'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
|
|
@ -37,7 +38,8 @@ export default defineEventHandler(async (event) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const pages = await getCategoryPages(id)
|
const pages = await getCategoryPages(id)
|
||||||
await purgeSlugs(pages.map(p => p.slug))
|
const tree = await loadCategoryTree()
|
||||||
|
await purgeSlugs(pages.map(p => pageUrlPath(tree, p)))
|
||||||
logUpdate('categories', id, event, { event: 'reorder', pageCount: pageIds.length })
|
logUpdate('categories', id, event, { event: 'reorder', pageCount: pageIds.length })
|
||||||
return { ok: true, pageIds }
|
return { ok: true, pageIds }
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
// Admin: list every category with its per-locale names and the number
|
// Admin: list every category with its per-locale names and the number
|
||||||
// of pages currently attached. Powers the category dropdown on the
|
// of pages currently attached, plus the computed URL path for each
|
||||||
// page editor and the categories admin list.
|
// (so the admin UI can render a tree with breadcrumbs/parent labels
|
||||||
|
// without re-walking the chain client-side).
|
||||||
|
|
||||||
import { defineEventHandler } from 'h3'
|
import { defineEventHandler } from 'h3'
|
||||||
import { requirePermission } from '../../../utils/rbac'
|
import { requirePermission } from '../../../utils/rbac'
|
||||||
import { listCategoriesWithTranslations } from '../../../database/categories'
|
import { listCmsCategories } from '../../../services/cmsCategories'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
await requirePermission(event, 'pages.view')
|
await requirePermission(event, 'pages.view')
|
||||||
const rows = await listCategoriesWithTranslations()
|
const rows = await listCmsCategories()
|
||||||
return { rows }
|
return { rows }
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { logEvent } from '../../../utils/activity-logger'
|
||||||
|
|
||||||
interface Body {
|
interface Body {
|
||||||
slug?: string
|
slug?: string
|
||||||
|
parent_id?: string | null
|
||||||
menu_order?: number
|
menu_order?: number
|
||||||
translations?: Record<string, { name?: string }> | Array<{ locale: string; name: string }>
|
translations?: Record<string, { name?: string }> | Array<{ locale: string; name: string }>
|
||||||
}
|
}
|
||||||
|
|
@ -42,6 +43,7 @@ export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
const category = await createCmsCategory({
|
const category = await createCmsCategory({
|
||||||
slug: body?.slug ?? '',
|
slug: body?.slug ?? '',
|
||||||
|
parent_id: body?.parent_id ? String(body.parent_id) : null,
|
||||||
menu_order: Number.isFinite(body?.menu_order) ? Number(body.menu_order) : 0,
|
menu_order: Number.isFinite(body?.menu_order) ? Number(body.menu_order) : 0,
|
||||||
translations
|
translations
|
||||||
})
|
})
|
||||||
|
|
@ -51,7 +53,12 @@ export default defineEventHandler(async (event) => {
|
||||||
recordId: category.id,
|
recordId: category.id,
|
||||||
userId: authUser.userId,
|
userId: authUser.userId,
|
||||||
userAgent: getHeader(event, 'user-agent') || undefined,
|
userAgent: getHeader(event, 'user-agent') || undefined,
|
||||||
metadata: { slug: category.slug, translations: translations.length, source: 'admin-ui' }
|
metadata: {
|
||||||
|
slug: category.slug,
|
||||||
|
parent_id: category.parent_id,
|
||||||
|
translations: translations.length,
|
||||||
|
source: 'admin-ui'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return category
|
return category
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|
|
||||||
|
|
@ -14,16 +14,16 @@ export default defineEventHandler(async (event) => {
|
||||||
if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' })
|
if (!id) throw createError({ statusCode: 400, statusMessage: 'id is required' })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { slug, categoryId } = await deleteCmsPage(id)
|
const { slug, url, categoryId } = await deleteCmsPage(id)
|
||||||
logEvent({
|
logEvent({
|
||||||
eventType: 'DELETE',
|
eventType: 'DELETE',
|
||||||
tableName: 'pages',
|
tableName: 'pages',
|
||||||
recordId: id,
|
recordId: id,
|
||||||
userId: authUser.userId,
|
userId: authUser.userId,
|
||||||
userAgent: getHeader(event, 'user-agent') || undefined,
|
userAgent: getHeader(event, 'user-agent') || undefined,
|
||||||
metadata: { slug, source: 'admin-ui' }
|
metadata: { slug, url, source: 'admin-ui' }
|
||||||
})
|
})
|
||||||
await applyPageInvalidations([slug], categoryId ? [categoryId] : [])
|
await applyPageInvalidations([url], categoryId ? [categoryId] : [])
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.statusCode) {
|
if (e?.statusCode) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
// Admin: fetch a single page with all its translations (any status).
|
// Admin: fetch a single page with all its translations (any status),
|
||||||
|
// plus the computed full public URL.
|
||||||
|
|
||||||
import { defineEventHandler, getRouterParam, createError } from 'h3'
|
import { defineEventHandler, getRouterParam, createError } from 'h3'
|
||||||
import { requirePermission } from '../../../utils/rbac'
|
import { requirePermission } from '../../../utils/rbac'
|
||||||
|
|
@ -12,8 +13,10 @@ export default defineEventHandler(async (event) => {
|
||||||
const found = await getCmsPage({ id })
|
const found = await getCmsPage({ id })
|
||||||
if (!found) throw createError({ statusCode: 404, statusMessage: 'Page not found' })
|
if (!found) throw createError({ statusCode: 404, statusMessage: 'Page not found' })
|
||||||
|
|
||||||
// Match the legacy admin contract: `{page, translations}`. The
|
return {
|
||||||
// service additionally returns `category_slug`, but the Vue admin
|
page: found.page,
|
||||||
// doesn't read that field — drop it from the response.
|
translations: found.translations,
|
||||||
return { page: found.page, translations: found.translations }
|
url: found.url,
|
||||||
|
category_path: found.category_path
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ export default defineEventHandler(async (event) => {
|
||||||
source: 'admin-ui'
|
source: 'admin-ui'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
await applyTranslationInvalidations(result.pageSlug, result.categoryId, locale)
|
await applyTranslationInvalidations(result.pageUrl, result.categoryId, locale)
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.statusCode) {
|
if (e?.statusCode) {
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ export default defineEventHandler(async (event) => {
|
||||||
metadata: { page_id: id, locale, status: result.translation.status, source: 'admin-ui' }
|
metadata: { page_id: id, locale, status: result.translation.status, source: 'admin-ui' }
|
||||||
})
|
})
|
||||||
|
|
||||||
await applyTranslationInvalidations(result.pageSlug, result.categoryId, locale)
|
await applyTranslationInvalidations(result.pageUrl, result.categoryId, locale)
|
||||||
return {
|
return {
|
||||||
...result.translation,
|
...result.translation,
|
||||||
sanitization_warnings: result.sanitizationWarnings
|
sanitization_warnings: result.sanitizationWarnings
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
// Admin: list all CMS pages with counts of published/draft translations
|
// Admin: list all CMS pages with counts of published/draft translations,
|
||||||
// plus category slug + English name for the category column.
|
// the page's category (if any), and the computed full URL path.
|
||||||
|
|
||||||
import { defineEventHandler } from 'h3'
|
import { defineEventHandler } from 'h3'
|
||||||
import { requirePermission } from '../../../utils/rbac'
|
import { requirePermission } from '../../../utils/rbac'
|
||||||
import { db } from '../../../utils/database'
|
import { db } from '../../../utils/database'
|
||||||
import { sql } from 'kysely'
|
import { sql } from 'kysely'
|
||||||
|
import { loadCategoryTree, pageUrlPath } from '../../../database/categoryTree'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
await requirePermission(event, 'pages.view')
|
await requirePermission(event, 'pages.view')
|
||||||
|
|
@ -43,5 +44,11 @@ export default defineEventHandler(async (event) => {
|
||||||
.orderBy('pages.slug', 'asc')
|
.orderBy('pages.slug', 'asc')
|
||||||
.execute()
|
.execute()
|
||||||
|
|
||||||
return { rows }
|
const tree = await loadCategoryTree()
|
||||||
|
const decorated = rows.map(r => ({
|
||||||
|
...r,
|
||||||
|
url: pageUrlPath(tree, { slug: r.slug, category_id: r.category_id })
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { rows: decorated }
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,132 +1,329 @@
|
||||||
// Public CMS page read endpoint. Mirrors the plan:
|
// Public CMS page read endpoint. Resolves a "/"-separated URL path
|
||||||
// - picks the translation for the requested locale
|
// against the category tree:
|
||||||
// - falls back to English if that locale isn't published
|
|
||||||
// - 404s if neither are published
|
|
||||||
//
|
//
|
||||||
// Returns the translation (title, excerpt, featured_image, meta_*,
|
// /resources → top-level category "resources"
|
||||||
// status) plus pre-rendered HTML body and the set of sibling pages
|
// /resources/adoption → child category "adoption" under "resources"
|
||||||
// that live in the same category (used for the sidebar nav).
|
// /resources/overview → page "overview" inside "resources"
|
||||||
|
// /privacy → uncategorized page "privacy"
|
||||||
//
|
//
|
||||||
// When the slug matches a category's slug exactly (e.g. `/about`),
|
// Walks every URL segment against the categories table from the root
|
||||||
// the endpoint transparently resolves to the category's first page
|
// down. Whatever doesn't match a category is treated as a page leaf
|
||||||
// (lowest menu_order with a published translation).
|
// inside the deepest matched category. Bare-category URLs render the
|
||||||
|
// category's default page (lowest menu_order with a published
|
||||||
|
// translation).
|
||||||
|
//
|
||||||
|
// The response includes a sidebar of sibling pages, sibling
|
||||||
|
// categories below the active one (so /resources can render a card
|
||||||
|
// grid of "Adoption / Training / Mobilization"), and the menu parent
|
||||||
|
// for breadcrumbs.
|
||||||
|
|
||||||
// defineCachedEventHandler + getCookie are Nitro globals (auto-imported);
|
|
||||||
// no h3 import needed for them.
|
|
||||||
import { getRouterParam, getQuery, createError } from 'h3'
|
import { getRouterParam, getQuery, createError } from 'h3'
|
||||||
import { getPageBySlug } from '../../database/pages'
|
|
||||||
import {
|
import {
|
||||||
getCategoryBySlug,
|
findPageInCategory,
|
||||||
|
getLocalizedTranslation
|
||||||
|
} from '../../database/pages'
|
||||||
|
import {
|
||||||
getCategoryDefaultPage,
|
getCategoryDefaultPage,
|
||||||
getCategoryName,
|
getCategoryName,
|
||||||
getCategoryPageTranslations
|
getCategoryPageTranslations,
|
||||||
|
categoryIdsWithPublishedPages
|
||||||
} from '../../database/categories'
|
} from '../../database/categories'
|
||||||
import { db } from '../../utils/database'
|
|
||||||
import { renderTiptap } from '../../utils/renderTiptap'
|
import { renderTiptap } from '../../utils/renderTiptap'
|
||||||
import { ENABLED_LANGUAGE_CODES } from '../../../config/languages'
|
import { ENABLED_LANGUAGE_CODES } from '../../../config/languages'
|
||||||
|
import {
|
||||||
|
loadCategoryTree,
|
||||||
|
walkCategorySegments,
|
||||||
|
pageUrlPath,
|
||||||
|
categoryUrlPath,
|
||||||
|
descendantCategoryIds
|
||||||
|
} from '../../database/categoryTree'
|
||||||
|
import type { LocalizedPage } from '../../database/pages'
|
||||||
|
import type { TreeCategory, CategoryTree } from '../../database/categoryTree'
|
||||||
|
|
||||||
const ENABLED_LOCALES = new Set(ENABLED_LANGUAGE_CODES)
|
const ENABLED_LOCALES = new Set(ENABLED_LANGUAGE_CODES)
|
||||||
|
|
||||||
|
interface ChildCategorySummary {
|
||||||
|
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<NavPageNode | NavCategoryNode>
|
||||||
|
}
|
||||||
|
|
||||||
|
type NavNode = NavPageNode | NavCategoryNode
|
||||||
|
|
||||||
|
// Builds the recursive sidebar tree rooted at `categoryId`. Each
|
||||||
|
// category becomes a heading whose `items` are its direct pages plus
|
||||||
|
// each child category's own subtree, ordered by menu_order then slug.
|
||||||
|
// Returns null when the category has no pages anywhere in its subtree —
|
||||||
|
// an empty heading isn't worth showing in the menu.
|
||||||
|
async function buildNavTree(
|
||||||
|
categoryId: string,
|
||||||
|
locale: string,
|
||||||
|
fallbackLocale: string,
|
||||||
|
tree: CategoryTree,
|
||||||
|
visited: Set<string> = new Set()
|
||||||
|
): Promise<NavCategoryNode | null> {
|
||||||
|
// Mark before any await so concurrent sibling recursions can't
|
||||||
|
// re-enter this node, and a back-edge in malformed data (a child
|
||||||
|
// re-parented onto an ancestor) is dropped instead of recursing
|
||||||
|
// forever.
|
||||||
|
visited.add(categoryId)
|
||||||
|
const node = tree.byId.get(categoryId)
|
||||||
|
const [name, pageRows] = await Promise.all([
|
||||||
|
getCategoryName(categoryId, locale, fallbackLocale),
|
||||||
|
getCategoryPageTranslations(categoryId, locale, fallbackLocale)
|
||||||
|
])
|
||||||
|
const url = categoryUrlPath(tree, categoryId)
|
||||||
|
|
||||||
|
const pageItems: NavPageNode[] = pageRows.map(({ page, translation }) => ({
|
||||||
|
kind: 'page' as const,
|
||||||
|
url: pageUrlPath(tree, page),
|
||||||
|
title: translation.title,
|
||||||
|
menu_order: page.menu_order
|
||||||
|
}))
|
||||||
|
|
||||||
|
const childCats = (tree.childrenByParent.get(categoryId) ?? []).filter(c => !visited.has(c.id))
|
||||||
|
const childSections = (await Promise.all(
|
||||||
|
childCats.map(c => buildNavTree(c.id, locale, fallbackLocale, tree, visited))
|
||||||
|
)).filter((s): s is NavCategoryNode => s !== null)
|
||||||
|
|
||||||
|
// Drop a category with no published pages of its own and no
|
||||||
|
// page-bearing descendants — it would render as an empty heading.
|
||||||
|
if (pageItems.length === 0 && childSections.length === 0) return null
|
||||||
|
|
||||||
|
// Subcategories first, then this category's own pages — each group
|
||||||
|
// ordered by menu_order (ties broken by title).
|
||||||
|
const byOrder = (a: NavNode, b: NavNode) =>
|
||||||
|
a.menu_order - b.menu_order || a.title.localeCompare(b.title)
|
||||||
|
const items: NavNode[] = [
|
||||||
|
...childSections.sort(byOrder),
|
||||||
|
...pageItems.sort(byOrder)
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'category' as const,
|
||||||
|
url,
|
||||||
|
title: name ?? node?.slug ?? '',
|
||||||
|
menu_order: node?.menu_order ?? 0,
|
||||||
|
items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function summarizeChildCategories(
|
||||||
|
parents: TreeCategory[],
|
||||||
|
locale: string,
|
||||||
|
fallbackLocale: string,
|
||||||
|
basePath: string
|
||||||
|
): Promise<ChildCategorySummary[]> {
|
||||||
|
const out: ChildCategorySummary[] = []
|
||||||
|
for (const child of parents) {
|
||||||
|
const [name, defaultPage] = await Promise.all([
|
||||||
|
getCategoryName(child.id, locale, fallbackLocale),
|
||||||
|
getCategoryDefaultPage(child.id, locale, fallbackLocale)
|
||||||
|
])
|
||||||
|
if (!name) continue
|
||||||
|
out.push({
|
||||||
|
slug: child.slug,
|
||||||
|
url: basePath ? `${basePath}/${child.slug}` : child.slug,
|
||||||
|
title: name,
|
||||||
|
excerpt: defaultPage?.translation.excerpt ?? null,
|
||||||
|
featured_image: defaultPage?.translation.featured_image ?? null,
|
||||||
|
menu_order: child.menu_order
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
export default defineCachedEventHandler(async (event) => {
|
export default defineCachedEventHandler(async (event) => {
|
||||||
const raw = getRouterParam(event, 'slug')
|
const raw = getRouterParam(event, 'slug')
|
||||||
const slugParam = Array.isArray(raw) ? raw.join('/') : (raw ?? '')
|
const slugParam = Array.isArray(raw) ? raw.join('/') : (raw ?? '')
|
||||||
const slug = slugParam.replace(/^\/+|\/+$/g, '')
|
const fullPath = slugParam.replace(/^\/+|\/+$/g, '')
|
||||||
if (!slug) {
|
if (!fullPath) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Missing slug' })
|
throw createError({ statusCode: 400, statusMessage: 'Missing slug' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = getQuery(event)
|
const query = getQuery(event)
|
||||||
const requested = typeof query.locale === 'string' ? query.locale : 'en'
|
const requested = typeof query.locale === 'string' ? query.locale : 'en'
|
||||||
const locale = ENABLED_LOCALES.has(requested) ? requested : 'en'
|
const locale = ENABLED_LOCALES.has(requested) ? requested : 'en'
|
||||||
|
const fallbackLocale = 'en'
|
||||||
|
|
||||||
// Try the slug as a page first. If no page row matches, see if the
|
const segments = fullPath.split('/').filter(Boolean)
|
||||||
// slug identifies a category, and render its default page.
|
const tree = await loadCategoryTree()
|
||||||
let result = await getPageBySlug(slug, locale, { fallback: 'en' })
|
const { category: deepestCategory, remaining } = walkCategorySegments(tree, segments)
|
||||||
let bareCategorySlug: string | null = null
|
|
||||||
|
|
||||||
if (!result) {
|
// Resolve to either a page (in deepestCategory) or a category landing.
|
||||||
const category = await getCategoryBySlug(slug)
|
let result: LocalizedPage | null = null
|
||||||
if (category) {
|
let resolvedCategory: TreeCategory | null = deepestCategory
|
||||||
const defaultPage = await getCategoryDefaultPage(category.id, locale, 'en')
|
let bareCategory: TreeCategory | null = null
|
||||||
if (defaultPage) {
|
|
||||||
result = {
|
if (remaining.length === 0 && deepestCategory) {
|
||||||
page: defaultPage.page,
|
// Bare category URL like /resources or /resources/adoption.
|
||||||
translation: defaultPage.translation,
|
const def = await getCategoryDefaultPage(deepestCategory.id, locale, fallbackLocale)
|
||||||
resolvedLocale: defaultPage.translation.locale,
|
if (def) {
|
||||||
requestedLocale: locale
|
result = {
|
||||||
}
|
page: def.page,
|
||||||
bareCategorySlug = category.slug
|
translation: def.translation,
|
||||||
|
resolvedLocale: def.translation.locale,
|
||||||
|
requestedLocale: locale
|
||||||
|
}
|
||||||
|
bareCategory = deepestCategory
|
||||||
|
resolvedCategory = deepestCategory
|
||||||
|
} else {
|
||||||
|
// Empty category — still render a landing if it has child
|
||||||
|
// categories. Use a synthetic empty translation.
|
||||||
|
const childCats = tree.childrenByParent.get(deepestCategory.id) ?? []
|
||||||
|
if (childCats.length === 0) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Page not found' })
|
||||||
|
}
|
||||||
|
bareCategory = deepestCategory
|
||||||
|
resolvedCategory = deepestCategory
|
||||||
|
}
|
||||||
|
} else if (remaining.length === 1) {
|
||||||
|
// Final segment is a page leaf inside `deepestCategory` (or
|
||||||
|
// uncategorized when no category matched at all).
|
||||||
|
const parentCategoryId = deepestCategory?.id ?? null
|
||||||
|
const page = await findPageInCategory(parentCategoryId, remaining[0]!)
|
||||||
|
if (page) {
|
||||||
|
const localized = await getLocalizedTranslation(page.id, locale, { fallback: fallbackLocale })
|
||||||
|
if (localized) {
|
||||||
|
result = localized
|
||||||
|
resolvedCategory = deepestCategory
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Anything deeper than one trailing leaf can't resolve — slugs are
|
||||||
|
// single segments now.
|
||||||
|
|
||||||
if (!result) {
|
if (!result && !bareCategory) {
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Page not found' })
|
throw createError({ statusCode: 404, statusMessage: 'Page not found' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { page, translation, resolvedLocale, requestedLocale } = result
|
const renderedLocale = result?.resolvedLocale ?? fallbackLocale
|
||||||
const bodyHtml = renderTiptap(translation.body_json)
|
const requestedLocale = locale
|
||||||
|
const bodyHtml = result ? renderTiptap(result.translation.body_json) : ''
|
||||||
|
|
||||||
let menuParent: { slug: string; title: string } | null = null
|
// Build the navigation sidebar: siblings within the resolved category.
|
||||||
let categorySlug: string | null = bareCategorySlug
|
let menuParent: { slug: string; url: string; title: string } | null = null
|
||||||
|
let categorySlug: string | null = null
|
||||||
|
let categoryUrl: string | null = null
|
||||||
let children: Array<{
|
let children: Array<{
|
||||||
slug: string
|
slug: string
|
||||||
|
url: string
|
||||||
title: string
|
title: string
|
||||||
excerpt: string | null
|
excerpt: string | null
|
||||||
featured_image: string | null
|
featured_image: string | null
|
||||||
menu_order: number
|
menu_order: number
|
||||||
}> = []
|
}> = []
|
||||||
|
|
||||||
if (page.category_id) {
|
const navCategoryId = result?.page.category_id ?? bareCategory?.id ?? null
|
||||||
const [categoryRow, categoryName, siblingRows] = await Promise.all([
|
let navTree: NavCategoryNode | null = null
|
||||||
db
|
if (navCategoryId) {
|
||||||
.selectFrom('categories')
|
const [categoryName, siblingRows] = await Promise.all([
|
||||||
.select('slug')
|
getCategoryName(navCategoryId, renderedLocale, fallbackLocale),
|
||||||
.where('id', '=', page.category_id)
|
getCategoryPageTranslations(navCategoryId, renderedLocale, fallbackLocale)
|
||||||
.executeTakeFirst(),
|
|
||||||
getCategoryName(page.category_id, resolvedLocale, 'en'),
|
|
||||||
getCategoryPageTranslations(page.category_id, resolvedLocale, 'en')
|
|
||||||
])
|
])
|
||||||
|
const navCategory = tree.byId.get(navCategoryId) ?? null
|
||||||
if (categoryRow) categorySlug = categoryRow.slug
|
categorySlug = navCategory?.slug ?? null
|
||||||
if (categorySlug && categoryName) {
|
categoryUrl = categoryUrlPath(tree, navCategoryId) || null
|
||||||
menuParent = { slug: categorySlug, title: categoryName }
|
if (categoryUrl && categoryName) {
|
||||||
|
menuParent = { slug: categorySlug ?? '', url: categoryUrl, title: categoryName }
|
||||||
}
|
}
|
||||||
children = siblingRows.map(({ page: c, translation: t }) => ({
|
children = siblingRows.map(({ page: c, translation: t }) => ({
|
||||||
slug: c.slug,
|
slug: c.slug,
|
||||||
|
url: pageUrlPath(tree, c),
|
||||||
title: t.title,
|
title: t.title,
|
||||||
excerpt: t.excerpt,
|
excerpt: t.excerpt,
|
||||||
featured_image: t.featured_image,
|
featured_image: t.featured_image,
|
||||||
menu_order: c.menu_order
|
menu_order: c.menu_order
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Build the recursive sidebar rooted at the top-level ancestor
|
||||||
|
// of the current category. Every page in the subtree shares the
|
||||||
|
// same sidebar — visiting /resources/adoption/foo gives the same
|
||||||
|
// nav as /resources/training/bar.
|
||||||
|
let navRootId = navCategoryId
|
||||||
|
while (true) {
|
||||||
|
const ancestor = tree.byId.get(navRootId)
|
||||||
|
if (!ancestor?.parent_id) break
|
||||||
|
navRootId = ancestor.parent_id
|
||||||
|
}
|
||||||
|
navTree = await buildNavTree(navRootId, renderedLocale, fallbackLocale, tree)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Child categories below this one — surfaced so archive landing
|
||||||
|
// pages can render a card grid of subsections.
|
||||||
|
let childCategories: ChildCategorySummary[] = []
|
||||||
|
// The index/grid layout belongs to the bare category URL only
|
||||||
|
// (`/resources`), not to a page's own URL (`/resources/overview`) —
|
||||||
|
// even when that page is the category default (menu_order 0). This
|
||||||
|
// keeps every page reachable as an article when its card is clicked.
|
||||||
|
const isCategoryLanding = Boolean(bareCategory)
|
||||||
|
if (isCategoryLanding && resolvedCategory) {
|
||||||
|
const cats = tree.childrenByParent.get(resolvedCategory.id) ?? []
|
||||||
|
if (cats.length > 0) {
|
||||||
|
// Hide subcategories with no published pages anywhere in their
|
||||||
|
// subtree — matches the menu so empty categories aren't surfaced
|
||||||
|
// as dead-end cards.
|
||||||
|
const withPages = await categoryIdsWithPublishedPages(renderedLocale, fallbackLocale)
|
||||||
|
const nonEmpty = cats.filter(c =>
|
||||||
|
descendantCategoryIds(tree, c.id).some(id => withPages.has(id))
|
||||||
|
)
|
||||||
|
if (nonEmpty.length > 0) {
|
||||||
|
const basePath = categoryUrlPath(tree, resolvedCategory.id)
|
||||||
|
childCategories = await summarizeChildCategories(nonEmpty, renderedLocale, fallbackLocale, basePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = result ? pageUrlPath(tree, result.page) : (bareCategory ? categoryUrlPath(tree, bareCategory.id) : fullPath)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
slug: page.slug,
|
url,
|
||||||
category_id: page.category_id,
|
slug: result?.page.slug ?? bareCategory?.slug ?? '',
|
||||||
|
category_id: result?.page.category_id ?? bareCategory?.id ?? null,
|
||||||
category_slug: categorySlug,
|
category_slug: categorySlug,
|
||||||
menu_order: page.menu_order,
|
category_url: categoryUrl,
|
||||||
theme: page.theme,
|
menu_order: result?.page.menu_order ?? 0,
|
||||||
custom_css: page.custom_css,
|
theme: result?.page.theme ?? 'default',
|
||||||
|
custom_css: result?.page.custom_css ?? null,
|
||||||
requested_locale: requestedLocale,
|
requested_locale: requestedLocale,
|
||||||
resolved_locale: resolvedLocale,
|
resolved_locale: renderedLocale,
|
||||||
title: translation.title,
|
title: result?.translation.title ?? '',
|
||||||
excerpt: translation.excerpt,
|
excerpt: result?.translation.excerpt ?? null,
|
||||||
featured_image: translation.featured_image,
|
featured_image: result?.translation.featured_image ?? null,
|
||||||
meta_title: translation.meta_title,
|
meta_title: result?.translation.meta_title ?? null,
|
||||||
meta_description: translation.meta_description,
|
meta_description: result?.translation.meta_description ?? null,
|
||||||
og_image: translation.og_image,
|
og_image: result?.translation.og_image ?? null,
|
||||||
body_html: bodyHtml,
|
body_html: bodyHtml,
|
||||||
body_is_empty: bodyHtml.trim() === '',
|
body_is_empty: bodyHtml.trim() === '',
|
||||||
|
is_category_landing: isCategoryLanding,
|
||||||
menu_parent: menuParent,
|
menu_parent: menuParent,
|
||||||
children
|
children,
|
||||||
|
child_categories: childCategories,
|
||||||
|
nav_tree: navTree
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
// Keyed by (locale, slug). Purged from mutation endpoints via
|
// Keyed by (locale, full URL path). The slug is hex-encoded because
|
||||||
// purgeCmsPage() in server/utils/cmsCache.ts. The slug is hex-encoded
|
// Nitro's escapeKey() strips non-word characters (`:`, `/`, `-`),
|
||||||
// because Nitro's internal escapeKey() strips non-word chars (`:`,
|
// which would otherwise collide nested paths like `about/team` with
|
||||||
// `/`, `-`), which would otherwise collide nested slugs like
|
// `about-team`.
|
||||||
// `about/team` with `about-team`.
|
|
||||||
name: 'cms',
|
name: 'cms',
|
||||||
getKey: (event) => {
|
getKey: (event) => {
|
||||||
const raw = getRouterParam(event, 'slug')
|
const raw = getRouterParam(event, 'slug')
|
||||||
|
|
@ -139,6 +336,5 @@ export default defineCachedEventHandler(async (event) => {
|
||||||
},
|
},
|
||||||
maxAge: 60 * 60,
|
maxAge: 60 * 60,
|
||||||
swr: true,
|
swr: true,
|
||||||
// Admins editing content must never see — or poison — cached output.
|
|
||||||
shouldBypassCache: event => !!getCookie(event, 'auth-token')
|
shouldBypassCache: event => !!getCookie(event, 'auth-token')
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
// Query helpers for the `categories` + `category_translations` tables.
|
// Query helpers for the `categories` + `category_translations` tables.
|
||||||
// Categories are a first-class replacement for the old
|
// Categories form a tree (`parent_id`) and provide the URL prefix for
|
||||||
// `pages.parent_slug` hierarchy: purely organizational groups that
|
// every page beneath them. Pages store only their own leaf slug; the
|
||||||
// expose a shared URL prefix, a translated sidebar heading, and a
|
// full URL is computed at request time by walking the chain.
|
||||||
// drag-and-drop ordering for their member pages.
|
|
||||||
|
|
||||||
import { db } from '../utils/database'
|
import { db } from '../utils/database'
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -14,6 +13,12 @@ import type {
|
||||||
import type { Selectable } from 'kysely'
|
import type { Selectable } from 'kysely'
|
||||||
import { sql } from 'kysely'
|
import { sql } from 'kysely'
|
||||||
import { purgeCmsPage } from '../utils/cmsCache'
|
import { purgeCmsPage } from '../utils/cmsCache'
|
||||||
|
import {
|
||||||
|
loadCategoryTree,
|
||||||
|
categoryUrlPath,
|
||||||
|
pageUrlPath,
|
||||||
|
descendantCategoryIds
|
||||||
|
} from './categoryTree'
|
||||||
|
|
||||||
export type Category = Selectable<CategoriesTable>
|
export type Category = Selectable<CategoriesTable>
|
||||||
export type CategoryTranslation = Selectable<CategoryTranslationsTable>
|
export type CategoryTranslation = Selectable<CategoryTranslationsTable>
|
||||||
|
|
@ -91,15 +96,6 @@ export async function getCategory(id: string): Promise<CategoryWithTranslations
|
||||||
return { category, translations }
|
return { category, translations }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCategoryBySlug(slug: string): Promise<Category | null> {
|
|
||||||
const row = await db
|
|
||||||
.selectFrom('categories')
|
|
||||||
.selectAll()
|
|
||||||
.where('slug', '=', slug)
|
|
||||||
.executeTakeFirst()
|
|
||||||
return row ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCategoryName(
|
export async function getCategoryName(
|
||||||
categoryId: string,
|
categoryId: string,
|
||||||
locale: string,
|
locale: string,
|
||||||
|
|
@ -126,8 +122,7 @@ export async function getCategoryName(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sidebar siblings for a category: every page in the category,
|
// Sidebar siblings for a category: every page in the category,
|
||||||
// ordered. Matches the old parent_slug-based sibling lookup but keyed
|
// ordered by menu_order then leaf slug.
|
||||||
// on category_id.
|
|
||||||
export async function getCategoryPages(categoryId: string): Promise<Page[]> {
|
export async function getCategoryPages(categoryId: string): Promise<Page[]> {
|
||||||
return db
|
return db
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
|
|
@ -177,10 +172,34 @@ export async function getCategoryPageTranslations(
|
||||||
.filter(entry => Boolean(entry.translation))
|
.filter(entry => Boolean(entry.translation))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public URL resolution for `/about` → first page in About. Picks
|
// Public URL resolution for a bare category path (e.g. `/resources`).
|
||||||
// whichever page has the lowest menu_order (ties broken by slug) and
|
// Picks whichever page has the lowest menu_order (ties broken by leaf
|
||||||
// has at least one published translation, so bare category slugs never
|
// slug) and has at least one published translation, so bare category
|
||||||
// resolve to something the visitor can't read.
|
// slugs never resolve to something the visitor can't read.
|
||||||
|
// Set of category ids that directly hold at least one page with a
|
||||||
|
// published translation in `locale` (or the fallback). Callers combine
|
||||||
|
// it with `descendantCategoryIds` to decide whether a category subtree
|
||||||
|
// has any visible pages — e.g. to hide empty categories from the menu
|
||||||
|
// and the landing-page grid.
|
||||||
|
export async function categoryIdsWithPublishedPages(
|
||||||
|
locale: string,
|
||||||
|
fallbackLocale = 'en'
|
||||||
|
): Promise<Set<string>> {
|
||||||
|
const locales = locale === fallbackLocale ? [locale] : [locale, fallbackLocale]
|
||||||
|
const rows = await db
|
||||||
|
.selectFrom('pages')
|
||||||
|
.innerJoin('page_translations', 'page_translations.page_id', 'pages.id')
|
||||||
|
.select('pages.category_id')
|
||||||
|
.where('pages.category_id', 'is not', null)
|
||||||
|
.where('page_translations.status', '=', 'published')
|
||||||
|
.where('page_translations.locale', 'in', locales)
|
||||||
|
.distinct()
|
||||||
|
.execute()
|
||||||
|
const set = new Set<string>()
|
||||||
|
for (const r of rows) if (r.category_id) set.add(r.category_id)
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
export async function getCategoryDefaultPage(
|
export async function getCategoryDefaultPage(
|
||||||
categoryId: string,
|
categoryId: string,
|
||||||
locale: string,
|
locale: string,
|
||||||
|
|
@ -192,6 +211,7 @@ export async function getCategoryDefaultPage(
|
||||||
|
|
||||||
export interface CreateCategoryInput {
|
export interface CreateCategoryInput {
|
||||||
slug: string
|
slug: string
|
||||||
|
parent_id?: string | null
|
||||||
menu_order?: number
|
menu_order?: number
|
||||||
translations: Array<{ locale: string; name: string }>
|
translations: Array<{ locale: string; name: string }>
|
||||||
}
|
}
|
||||||
|
|
@ -202,6 +222,7 @@ export async function createCategory(input: CreateCategoryInput): Promise<Catego
|
||||||
.insertInto('categories')
|
.insertInto('categories')
|
||||||
.values({
|
.values({
|
||||||
slug: input.slug,
|
slug: input.slug,
|
||||||
|
parent_id: input.parent_id ?? null,
|
||||||
menu_order: input.menu_order ?? 0
|
menu_order: input.menu_order ?? 0
|
||||||
})
|
})
|
||||||
.returningAll()
|
.returningAll()
|
||||||
|
|
@ -225,12 +246,15 @@ export async function createCategory(input: CreateCategoryInput): Promise<Catego
|
||||||
|
|
||||||
export interface UpdateCategoryInput {
|
export interface UpdateCategoryInput {
|
||||||
slug?: string
|
slug?: string
|
||||||
|
parent_id?: string | null
|
||||||
menu_order?: number
|
menu_order?: number
|
||||||
translations?: Array<{ locale: string; name: string }>
|
translations?: Array<{ locale: string; name: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the set of slugs whose cached rendering needs busting (old
|
// A slug or parent_id change rewrites the URL of every page in the
|
||||||
// and new URLs for every renamed member page).
|
// category subtree. We compute the *old* URL for each affected page
|
||||||
|
// before committing, so cache-purge can invalidate the keys that
|
||||||
|
// existed prior to the change.
|
||||||
export async function updateCategory(
|
export async function updateCategory(
|
||||||
id: string,
|
id: string,
|
||||||
input: UpdateCategoryInput
|
input: UpdateCategoryInput
|
||||||
|
|
@ -246,36 +270,44 @@ export async function updateCategory(
|
||||||
}
|
}
|
||||||
|
|
||||||
const slugsToPurge: string[] = []
|
const slugsToPurge: string[] = []
|
||||||
|
const structureChanged =
|
||||||
|
(input.slug !== undefined && input.slug !== existing.slug) ||
|
||||||
|
(input.parent_id !== undefined && (input.parent_id ?? null) !== existing.parent_id)
|
||||||
|
|
||||||
// Slug rename cascades to every member page so their stored slugs
|
// Snapshot the current full URL for every page beneath this
|
||||||
// stay `{category.slug}/...` — the app relies on that prefix for
|
// category before we change anything. After commit the same pages
|
||||||
// URL resolution.
|
// will live at new URLs; the old keys still need to be purged.
|
||||||
if (input.slug && input.slug !== existing.slug) {
|
if (structureChanged) {
|
||||||
const newSlug = input.slug
|
const treeBefore = await loadCategoryTree(trx)
|
||||||
|
const subtreeIds = descendantCategoryIds(treeBefore, id)
|
||||||
const memberPages = await trx
|
const pages = await trx
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
.select(['id', 'slug'])
|
.select(['slug', 'category_id'])
|
||||||
.where('category_id', '=', id)
|
.where('category_id', 'in', subtreeIds)
|
||||||
.execute()
|
.execute()
|
||||||
|
for (const p of pages) {
|
||||||
for (const page of memberPages) {
|
slugsToPurge.push(pageUrlPath(treeBefore, p))
|
||||||
const suffix = page.slug.startsWith(`${existing.slug}/`)
|
|
||||||
? page.slug.slice(existing.slug.length + 1)
|
|
||||||
: page.slug
|
|
||||||
const updatedSlug = `${newSlug}/${suffix}`
|
|
||||||
slugsToPurge.push(page.slug)
|
|
||||||
slugsToPurge.push(updatedSlug)
|
|
||||||
await trx
|
|
||||||
.updateTable('pages')
|
|
||||||
.set({ slug: updatedSlug, updated: sql`now()` })
|
|
||||||
.where('id', '=', page.id)
|
|
||||||
.execute()
|
|
||||||
}
|
}
|
||||||
|
// Also bust the bare-category landing URL for every node in the
|
||||||
|
// subtree so /resources keeps working after a rename.
|
||||||
|
for (const cid of subtreeIds) {
|
||||||
|
const path = categoryUrlPath(treeBefore, cid)
|
||||||
|
if (path) slugsToPurge.push(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.slug !== undefined && input.slug !== existing.slug) {
|
||||||
await trx
|
await trx
|
||||||
.updateTable('categories')
|
.updateTable('categories')
|
||||||
.set({ slug: newSlug, updated: sql`now()` })
|
.set({ slug: input.slug, updated: sql`now()` })
|
||||||
|
.where('id', '=', id)
|
||||||
|
.execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.parent_id !== undefined && (input.parent_id ?? null) !== existing.parent_id) {
|
||||||
|
await trx
|
||||||
|
.updateTable('categories')
|
||||||
|
.set({ parent_id: input.parent_id ?? null, updated: sql`now()` })
|
||||||
.where('id', '=', id)
|
.where('id', '=', id)
|
||||||
.execute()
|
.execute()
|
||||||
}
|
}
|
||||||
|
|
@ -329,20 +361,41 @@ export async function updateCategory(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every member page's cached response embeds the category's name
|
// Every member page's cached response embeds the category's name
|
||||||
// as its `menu_parent.title`. When the category name changes in
|
// as its `menu_parent.title`. When the name changes in any
|
||||||
// any locale, purge every member so readers see the new label.
|
// locale, purge every member so readers see the new label.
|
||||||
|
// Use the post-change tree since the slug/parent (if changed)
|
||||||
|
// already settled above.
|
||||||
if (translationsChanged) {
|
if (translationsChanged) {
|
||||||
const memberSlugs = await trx
|
const treeAfter = await loadCategoryTree(trx)
|
||||||
|
const subtreeIds = descendantCategoryIds(treeAfter, id)
|
||||||
|
const pages = await trx
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
.select('slug')
|
.select(['slug', 'category_id'])
|
||||||
.where('category_id', '=', id)
|
.where('category_id', 'in', subtreeIds)
|
||||||
.execute()
|
.execute()
|
||||||
for (const { slug } of memberSlugs) {
|
for (const p of pages) {
|
||||||
slugsToPurge.push(slug)
|
slugsToPurge.push(pageUrlPath(treeAfter, p))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The parent category's landing page renders a grid of its child
|
||||||
|
// categories (each child's url, title, and order). Any edit this
|
||||||
|
// grid reflects — slug, parent, menu_order, or name — leaves the
|
||||||
|
// old and new parents' landing pages stale, so purge them too. The
|
||||||
|
// parents' own paths are unaffected by this category's change, so a
|
||||||
|
// single post-change tree resolves both correctly.
|
||||||
|
const parentIdsToPurge = new Set<string>()
|
||||||
|
if (existing.parent_id) parentIdsToPurge.add(existing.parent_id)
|
||||||
|
if (input.parent_id) parentIdsToPurge.add(input.parent_id)
|
||||||
|
if (parentIdsToPurge.size > 0) {
|
||||||
|
const parentTree = await loadCategoryTree(trx)
|
||||||
|
for (const pid of parentIdsToPurge) {
|
||||||
|
const path = categoryUrlPath(parentTree, pid)
|
||||||
|
if (path) slugsToPurge.push(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fresh = await trx
|
const fresh = await trx
|
||||||
.selectFrom('categories')
|
.selectFrom('categories')
|
||||||
.selectAll()
|
.selectAll()
|
||||||
|
|
@ -362,6 +415,14 @@ export async function deleteCategory(id: string): Promise<void> {
|
||||||
if (pageCount.count > 0) {
|
if (pageCount.count > 0) {
|
||||||
throw Object.assign(new Error('Category still has pages'), { statusCode: 409 })
|
throw Object.assign(new Error('Category still has pages'), { statusCode: 409 })
|
||||||
}
|
}
|
||||||
|
const childCount = await db
|
||||||
|
.selectFrom('categories')
|
||||||
|
.select(sql<number>`COUNT(*)::int`.as('count'))
|
||||||
|
.where('parent_id', '=', id)
|
||||||
|
.executeTakeFirstOrThrow()
|
||||||
|
if (childCount.count > 0) {
|
||||||
|
throw Object.assign(new Error('Category still has child categories'), { statusCode: 409 })
|
||||||
|
}
|
||||||
await db.deleteFrom('categories').where('id', '=', id).execute()
|
await db.deleteFrom('categories').where('id', '=', id).execute()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
155
server/database/categoryTree.ts
Normal file
155
server/database/categoryTree.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
// Category tree helpers. The CMS stores only the leaf segment of a
|
||||||
|
// page's URL on `pages.slug`; the full URL is derived by walking the
|
||||||
|
// category's ancestor chain at request time. These helpers keep that
|
||||||
|
// derivation in one place so the lookup endpoint, link builders, cache
|
||||||
|
// purgers, and admin previews all agree on what `/resources/adoption/foo`
|
||||||
|
// actually resolves to.
|
||||||
|
|
||||||
|
import type { Kysely } from 'kysely'
|
||||||
|
import { db } from '../utils/database'
|
||||||
|
import type { Database } from './schema'
|
||||||
|
|
||||||
|
export interface TreeCategory {
|
||||||
|
id: string
|
||||||
|
parent_id: string | null
|
||||||
|
slug: string
|
||||||
|
menu_order: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryTree {
|
||||||
|
byId: Map<string, TreeCategory>
|
||||||
|
childrenByParent: Map<string | null, TreeCategory[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load every category row into an in-memory tree. Categories are
|
||||||
|
// shallow (dozens of rows), so a single SELECT-all + Map build is
|
||||||
|
// cheaper than recursive CTE walks per page request.
|
||||||
|
export async function loadCategoryTree(
|
||||||
|
executor: Kysely<Database> = db
|
||||||
|
): Promise<CategoryTree> {
|
||||||
|
const rows = await executor
|
||||||
|
.selectFrom('categories')
|
||||||
|
.select(['id', 'parent_id', 'slug', 'menu_order'])
|
||||||
|
.execute()
|
||||||
|
|
||||||
|
const byId = new Map<string, TreeCategory>()
|
||||||
|
const childrenByParent = new Map<string | null, TreeCategory[]>()
|
||||||
|
for (const r of rows) {
|
||||||
|
byId.set(r.id, r)
|
||||||
|
const key = r.parent_id ?? null
|
||||||
|
const list = childrenByParent.get(key) ?? []
|
||||||
|
list.push(r)
|
||||||
|
childrenByParent.set(key, list)
|
||||||
|
}
|
||||||
|
// Stable sibling ordering so callers don't have to re-sort.
|
||||||
|
for (const list of childrenByParent.values()) {
|
||||||
|
list.sort((a, b) =>
|
||||||
|
a.menu_order - b.menu_order || a.slug.localeCompare(b.slug)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return { byId, childrenByParent }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the chain of categories from root → leaf for the given id,
|
||||||
|
// or [] if the id isn't in the tree. Empty when called with null.
|
||||||
|
export function categoryChain(
|
||||||
|
tree: CategoryTree,
|
||||||
|
categoryId: string | null
|
||||||
|
): TreeCategory[] {
|
||||||
|
if (!categoryId) return []
|
||||||
|
const chain: TreeCategory[] = []
|
||||||
|
let id: string | null = categoryId
|
||||||
|
// Hard cap protects against accidental cycles in malformed data.
|
||||||
|
for (let i = 0; i < 32 && id; i++) {
|
||||||
|
const node = tree.byId.get(id)
|
||||||
|
if (!node) break
|
||||||
|
chain.unshift(node)
|
||||||
|
id = node.parent_id
|
||||||
|
}
|
||||||
|
return chain
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slug path for a category, e.g. "resources/adoption". Empty string
|
||||||
|
// when the category isn't in the tree.
|
||||||
|
export function categoryUrlPath(
|
||||||
|
tree: CategoryTree,
|
||||||
|
categoryId: string | null
|
||||||
|
): string {
|
||||||
|
return categoryChain(tree, categoryId).map(c => c.slug).join('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full URL path for a page given its (leaf) slug + category. Examples:
|
||||||
|
// page leaf "overview", category "resources" → "resources/overview"
|
||||||
|
// page leaf "privacy", no category → "privacy"
|
||||||
|
export function pageUrlPath(
|
||||||
|
tree: CategoryTree,
|
||||||
|
page: { slug: string; category_id: string | null }
|
||||||
|
): string {
|
||||||
|
const prefix = categoryUrlPath(tree, page.category_id)
|
||||||
|
return prefix ? `${prefix}/${page.slug}` : page.slug
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk a "/"-separated path through the category tree as far as it
|
||||||
|
// matches. Returns the deepest category we could match, plus the
|
||||||
|
// segments that come after it (zero or one segment for a page leaf,
|
||||||
|
// or more if the path doesn't fully resolve).
|
||||||
|
export interface ResolvedSegments {
|
||||||
|
category: TreeCategory | null
|
||||||
|
remaining: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function walkCategorySegments(
|
||||||
|
tree: CategoryTree,
|
||||||
|
segments: string[]
|
||||||
|
): ResolvedSegments {
|
||||||
|
let parentId: string | null = null
|
||||||
|
let current: TreeCategory | null = null
|
||||||
|
let i = 0
|
||||||
|
for (; i < segments.length; i++) {
|
||||||
|
const seg = segments[i]!
|
||||||
|
const siblings: TreeCategory[] = tree.childrenByParent.get(parentId) ?? []
|
||||||
|
const match: TreeCategory | undefined = siblings.find((c: TreeCategory) => c.slug === seg)
|
||||||
|
if (!match) break
|
||||||
|
current = match
|
||||||
|
parentId = match.id
|
||||||
|
}
|
||||||
|
return { category: current, remaining: segments.slice(i) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the descendant category id set (including the root id),
|
||||||
|
// e.g. for cache purges that need to enumerate every page beneath a
|
||||||
|
// category that just got renamed or moved.
|
||||||
|
export function descendantCategoryIds(
|
||||||
|
tree: CategoryTree,
|
||||||
|
rootId: string
|
||||||
|
): string[] {
|
||||||
|
const out: string[] = []
|
||||||
|
// `seen` guards against a back-edge in malformed data (a descendant
|
||||||
|
// re-parented onto an ancestor). Without it the stack would cycle
|
||||||
|
// forever and grow `out` unbounded.
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const stack: string[] = [rootId]
|
||||||
|
while (stack.length) {
|
||||||
|
const id = stack.pop()!
|
||||||
|
if (seen.has(id)) continue
|
||||||
|
seen.add(id)
|
||||||
|
out.push(id)
|
||||||
|
const children = tree.childrenByParent.get(id) ?? []
|
||||||
|
for (const c of children) stack.push(c.id)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catches a self-or-ancestor cycle when re-parenting a category. Pass
|
||||||
|
// the candidate parent id; returns true when assigning it would create
|
||||||
|
// a loop (parent is the category itself or one of its descendants).
|
||||||
|
export function wouldCreateCycle(
|
||||||
|
tree: CategoryTree,
|
||||||
|
categoryId: string,
|
||||||
|
newParentId: string | null
|
||||||
|
): boolean {
|
||||||
|
if (!newParentId) return false
|
||||||
|
if (newParentId === categoryId) return true
|
||||||
|
const descendants = new Set(descendantCategoryIds(tree, categoryId))
|
||||||
|
return descendants.has(newParentId)
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
// Query helpers for the `pages` + `page_translations` tables (migration
|
// Query helpers for the `pages` + `page_translations` tables.
|
||||||
// 005). All functions use Kysely against the shared `db` instance.
|
// `pages.slug` stores only the leaf segment of the URL; the full URL
|
||||||
|
// is derived by walking `pages.category_id` up the category tree.
|
||||||
//
|
//
|
||||||
// Lookup semantics (`getPageBySlug`) mirror the plan: prefer the
|
// Translation lookup semantics: prefer the requested locale's
|
||||||
// requested locale's published translation, fall back to English if
|
// published translation, fall back to English if that locale is
|
||||||
// that locale is missing/draft, return null when neither is published.
|
// missing/draft, return null when neither is published.
|
||||||
|
|
||||||
import { db } from '../utils/database'
|
import { db } from '../utils/database'
|
||||||
import type { PagesTable, PageTranslationsTable } from './schema'
|
import type { PagesTable, PageTranslationsTable } from './schema'
|
||||||
|
|
@ -39,8 +40,28 @@ export async function listPages(): Promise<Page[]> {
|
||||||
.execute()
|
.execute()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPageBySlug(
|
// Find a page by its leaf slug + parent category. Pass `null` for
|
||||||
slug: string,
|
// `categoryId` to look up an uncategorized (top-level) page. Returns
|
||||||
|
// the page row regardless of translation state — translation lookup
|
||||||
|
// is split out into `getLocalizedTranslation` so callers can decide
|
||||||
|
// whether to surface unpublished pages (the public endpoint won't).
|
||||||
|
export async function findPageInCategory(
|
||||||
|
categoryId: string | null,
|
||||||
|
leafSlug: string
|
||||||
|
): Promise<Page | null> {
|
||||||
|
let q = db
|
||||||
|
.selectFrom('pages')
|
||||||
|
.selectAll()
|
||||||
|
.where('slug', '=', leafSlug)
|
||||||
|
q = categoryId === null
|
||||||
|
? q.where('category_id', 'is', null)
|
||||||
|
: q.where('category_id', '=', categoryId)
|
||||||
|
const row = await q.executeTakeFirst()
|
||||||
|
return row ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLocalizedTranslation(
|
||||||
|
pageId: string,
|
||||||
locale: string,
|
locale: string,
|
||||||
options: { fallback?: string } = {}
|
options: { fallback?: string } = {}
|
||||||
): Promise<LocalizedPage | null> {
|
): Promise<LocalizedPage | null> {
|
||||||
|
|
@ -49,7 +70,7 @@ export async function getPageBySlug(
|
||||||
const page = await db
|
const page = await db
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
.selectAll()
|
.selectAll()
|
||||||
.where('slug', '=', slug)
|
.where('id', '=', pageId)
|
||||||
.executeTakeFirst()
|
.executeTakeFirst()
|
||||||
if (!page) return null
|
if (!page) return null
|
||||||
|
|
||||||
|
|
@ -65,7 +86,6 @@ export async function getPageBySlug(
|
||||||
return { page, translation, resolvedLocale: locale, requestedLocale: locale }
|
return { page, translation, resolvedLocale: locale, requestedLocale: locale }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to published English if the requested locale isn't published
|
|
||||||
if (locale !== fallbackLocale) {
|
if (locale !== fallbackLocale) {
|
||||||
const fallback = await db
|
const fallback = await db
|
||||||
.selectFrom('page_translations')
|
.selectFrom('page_translations')
|
||||||
|
|
@ -103,72 +123,6 @@ export async function deletePage(id: string): Promise<void> {
|
||||||
await db.deleteFrom('pages').where('id', '=', id).execute()
|
await db.deleteFrom('pages').where('id', '=', id).execute()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Moves a page into a new category (or detaches it by passing null)
|
|
||||||
// and rewrites its slug so the `{category.slug}/...` prefix stays
|
|
||||||
// consistent. Returns the set of slugs that need cache purging (old
|
|
||||||
// slug + new slug).
|
|
||||||
export async function setPageCategory(
|
|
||||||
pageId: string,
|
|
||||||
newCategoryId: string | null
|
|
||||||
): Promise<{ page: Page; slugsToPurge: string[] }> {
|
|
||||||
return db.transaction().execute(async trx => {
|
|
||||||
const page = await trx
|
|
||||||
.selectFrom('pages')
|
|
||||||
.selectAll()
|
|
||||||
.where('id', '=', pageId)
|
|
||||||
.executeTakeFirst()
|
|
||||||
if (!page) {
|
|
||||||
throw Object.assign(new Error('Page not found'), { statusCode: 404 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldSlug = page.slug
|
|
||||||
const oldCategoryId = page.category_id
|
|
||||||
|
|
||||||
// Figure out the slug leaf (everything after the current category
|
|
||||||
// prefix) so we can re-prefix it under the new category.
|
|
||||||
let leaf = oldSlug
|
|
||||||
if (oldCategoryId) {
|
|
||||||
const oldCategory = await trx
|
|
||||||
.selectFrom('categories')
|
|
||||||
.select('slug')
|
|
||||||
.where('id', '=', oldCategoryId)
|
|
||||||
.executeTakeFirst()
|
|
||||||
if (oldCategory && oldSlug.startsWith(`${oldCategory.slug}/`)) {
|
|
||||||
leaf = oldSlug.slice(oldCategory.slug.length + 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let newSlug: string
|
|
||||||
if (newCategoryId) {
|
|
||||||
const newCategory = await trx
|
|
||||||
.selectFrom('categories')
|
|
||||||
.select('slug')
|
|
||||||
.where('id', '=', newCategoryId)
|
|
||||||
.executeTakeFirst()
|
|
||||||
if (!newCategory) {
|
|
||||||
throw Object.assign(new Error('Category not found'), { statusCode: 404 })
|
|
||||||
}
|
|
||||||
newSlug = `${newCategory.slug}/${leaf}`
|
|
||||||
} else {
|
|
||||||
newSlug = leaf
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await trx
|
|
||||||
.updateTable('pages')
|
|
||||||
.set({
|
|
||||||
category_id: newCategoryId,
|
|
||||||
slug: newSlug,
|
|
||||||
updated: sql`now()`
|
|
||||||
})
|
|
||||||
.where('id', '=', pageId)
|
|
||||||
.returningAll()
|
|
||||||
.executeTakeFirstOrThrow()
|
|
||||||
|
|
||||||
const slugsToPurge = oldSlug === newSlug ? [oldSlug] : [oldSlug, newSlug]
|
|
||||||
return { page: updated, slugsToPurge }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function upsertTranslation(input: {
|
export async function upsertTranslation(input: {
|
||||||
page_id: string
|
page_id: string
|
||||||
locale: string
|
locale: string
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ export interface PagesTable {
|
||||||
export interface CategoriesTable {
|
export interface CategoriesTable {
|
||||||
id: Generated<string>
|
id: Generated<string>
|
||||||
slug: string
|
slug: string
|
||||||
|
parent_id: string | null
|
||||||
menu_order: Generated<number>
|
menu_order: Generated<number>
|
||||||
created: ColumnType<Date, Date | string | undefined, Date | string>
|
created: ColumnType<Date, Date | string | undefined, Date | string>
|
||||||
updated: ColumnType<Date, Date | string | undefined, Date | string>
|
updated: ColumnType<Date, Date | string | undefined, Date | string>
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,7 @@ export const listCategoriesInput = z.object({}).strict()
|
||||||
export const createCategoryInput = z
|
export const createCategoryInput = z
|
||||||
.object({
|
.object({
|
||||||
slug: slugString,
|
slug: slugString,
|
||||||
|
parent_id: idString.nullish(),
|
||||||
menu_order: z.number().int().min(0).optional(),
|
menu_order: z.number().int().min(0).optional(),
|
||||||
translations: categoryTranslationsInput
|
translations: categoryTranslationsInput
|
||||||
})
|
})
|
||||||
|
|
@ -176,6 +177,7 @@ export const updateCategoryInput = z
|
||||||
.object({
|
.object({
|
||||||
id: idString,
|
id: idString,
|
||||||
slug: slugString.optional(),
|
slug: slugString.optional(),
|
||||||
|
parent_id: idString.nullish(),
|
||||||
menu_order: z.number().int().min(0).optional(),
|
menu_order: z.number().int().min(0).optional(),
|
||||||
translations: categoryTranslationsInput.optional()
|
translations: categoryTranslationsInput.optional()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import {
|
||||||
|
|
||||||
export const listCategoriesTool = defineMcpTool({
|
export const listCategoriesTool = defineMcpTool({
|
||||||
name: 'list_categories',
|
name: 'list_categories',
|
||||||
description: 'List all CMS categories with their per-locale names, menu_order, and the number of pages currently attached.',
|
description: 'List all CMS categories with their per-locale names, menu_order, parent_id, computed URL path, and the number of pages currently attached.',
|
||||||
scope: 'pages.view',
|
scope: 'pages.view',
|
||||||
input: listCategoriesInput,
|
input: listCategoriesInput,
|
||||||
async handler() {
|
async handler() {
|
||||||
|
|
@ -33,6 +33,8 @@ export const listCategoriesTool = defineMcpTool({
|
||||||
categories: rows.map(row => ({
|
categories: rows.map(row => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
slug: row.slug,
|
slug: row.slug,
|
||||||
|
parent_id: row.parent_id,
|
||||||
|
url: row.url,
|
||||||
menu_order: row.menu_order,
|
menu_order: row.menu_order,
|
||||||
page_count: row.page_count,
|
page_count: row.page_count,
|
||||||
translations: row.translations.map(t => ({
|
translations: row.translations.map(t => ({
|
||||||
|
|
@ -47,17 +49,19 @@ export const listCategoriesTool = defineMcpTool({
|
||||||
|
|
||||||
export const createCategoryTool = defineMcpTool({
|
export const createCategoryTool = defineMcpTool({
|
||||||
name: 'create_category',
|
name: 'create_category',
|
||||||
description: 'Create a new CMS category. Slug must be lowercase letters/digits/dashes (no slashes). Requires at least an English (locale "en") translation entry.',
|
description: 'Create a new CMS category. Slug must be lowercase letters/digits/dashes (no slashes). Pass `parent_id` to nest under another category (omit/null for top level). Requires at least an English (locale "en") translation entry.',
|
||||||
scope: 'pages.write',
|
scope: 'pages.write',
|
||||||
input: createCategoryInput,
|
input: createCategoryInput,
|
||||||
async handler(input, ctx) {
|
async handler(input, ctx) {
|
||||||
const category = await createCmsCategory({
|
const category = await createCmsCategory({
|
||||||
slug: input.slug,
|
slug: input.slug,
|
||||||
|
parent_id: input.parent_id ?? null,
|
||||||
menu_order: input.menu_order,
|
menu_order: input.menu_order,
|
||||||
translations: input.translations
|
translations: input.translations
|
||||||
})
|
})
|
||||||
await mcpLog('CREATE', 'categories', category.id, ctx, {
|
await mcpLog('CREATE', 'categories', category.id, ctx, {
|
||||||
slug: input.slug,
|
slug: input.slug,
|
||||||
|
parent_id: category.parent_id,
|
||||||
translation_count: input.translations.length
|
translation_count: input.translations.length
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
|
|
@ -69,13 +73,14 @@ export const createCategoryTool = defineMcpTool({
|
||||||
|
|
||||||
export const updateCategoryTool = defineMcpTool({
|
export const updateCategoryTool = defineMcpTool({
|
||||||
name: 'update_category',
|
name: 'update_category',
|
||||||
description: 'Update a CMS category — slug, menu_order, or per-locale translations. Slug renames cascade to every member page and bust the affected cache entries.',
|
description: 'Update a CMS category — slug, parent_id, menu_order, or per-locale translations. A slug or parent change cascades to every URL in the subtree and busts the affected cache entries.',
|
||||||
scope: 'pages.write',
|
scope: 'pages.write',
|
||||||
input: updateCategoryInput,
|
input: updateCategoryInput,
|
||||||
async handler(input, ctx) {
|
async handler(input, ctx) {
|
||||||
const result = await updateCmsCategory({
|
const result = await updateCmsCategory({
|
||||||
id: input.id,
|
id: input.id,
|
||||||
slug: input.slug,
|
slug: input.slug,
|
||||||
|
parent_id: input.parent_id === undefined ? undefined : (input.parent_id ?? null),
|
||||||
menu_order: input.menu_order,
|
menu_order: input.menu_order,
|
||||||
translations: input.translations
|
translations: input.translations
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ export const listPagesTool = defineMcpTool({
|
||||||
|
|
||||||
export const getPageTool = defineMcpTool({
|
export const getPageTool = defineMcpTool({
|
||||||
name: 'get_page',
|
name: 'get_page',
|
||||||
description: 'Fetch a single CMS page by id or slug, including all translations (any status). Each translation includes body_json (Tiptap JSON), a Markdown rendering, and a body_is_lossy_in_markdown flag.',
|
description: 'Fetch a single CMS page by id or slug, including all translations (any status). The `slug` lookup takes the full URL path (e.g. "resources/overview", as returned in `url`); a bare leaf ("privacy") only matches an uncategorized page. Each translation includes body_json (Tiptap JSON), a Markdown rendering, and a body_is_lossy_in_markdown flag.',
|
||||||
scope: 'pages.view',
|
scope: 'pages.view',
|
||||||
input: getPageInput,
|
input: getPageInput,
|
||||||
async handler(input) {
|
async handler(input) {
|
||||||
|
|
@ -82,13 +82,15 @@ export const getPageTool = defineMcpTool({
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text', text: `Page "${found.page.slug}" (${translations.length} translation(s))` }],
|
content: [{ type: 'text', text: `Page "${found.url}" (${translations.length} translation(s))` }],
|
||||||
structuredContent: {
|
structuredContent: {
|
||||||
page: {
|
page: {
|
||||||
id: found.page.id,
|
id: found.page.id,
|
||||||
slug: found.page.slug,
|
slug: found.page.slug,
|
||||||
|
url: found.url,
|
||||||
category_id: found.page.category_id,
|
category_id: found.page.category_id,
|
||||||
category_slug: found.category_slug,
|
category_slug: found.category_slug,
|
||||||
|
category_path: found.category_path,
|
||||||
menu_order: found.page.menu_order,
|
menu_order: found.page.menu_order,
|
||||||
theme: found.page.theme,
|
theme: found.page.theme,
|
||||||
custom_css: found.page.custom_css
|
custom_css: found.page.custom_css
|
||||||
|
|
@ -101,7 +103,7 @@ export const getPageTool = defineMcpTool({
|
||||||
|
|
||||||
export const createPageTool = defineMcpTool({
|
export const createPageTool = defineMcpTool({
|
||||||
name: 'create_page',
|
name: 'create_page',
|
||||||
description: 'Create a new CMS page shell, optionally with an inline initial translation. The page shell + translation are committed atomically. Slug rules: under a category the slug must start with "<category_slug>/"; uncategorized slugs cannot collide with category slugs. If translation.body_markdown is supplied it is converted to Tiptap JSON server-side; if translation.body_json is supplied it is validated against the schema/attribute allowlist.',
|
description: 'Create a new CMS page shell, optionally with an inline initial translation. The page shell + translation are committed atomically. Slug rules: leaf-only (lowercase letters, digits, dashes — no slashes). The page\'s URL is derived from its category chain + leaf, so a page in category X with slug "foo" lives at /<X-url>/foo. Uniqueness is enforced per-category, and the leaf cannot collide with a sibling category at the same level. If translation.body_markdown is supplied it is converted to Tiptap JSON server-side; if translation.body_json is supplied it is validated against the schema/attribute allowlist.',
|
||||||
scope: 'pages.write',
|
scope: 'pages.write',
|
||||||
input: createPageInput,
|
input: createPageInput,
|
||||||
async handler(input, ctx) {
|
async handler(input, ctx) {
|
||||||
|
|
@ -170,7 +172,7 @@ export const createPageTool = defineMcpTool({
|
||||||
|
|
||||||
export const updatePageTool = defineMcpTool({
|
export const updatePageTool = defineMcpTool({
|
||||||
name: 'update_page',
|
name: 'update_page',
|
||||||
description: 'Update a page\'s metadata: slug, category_id, menu_order, theme, custom_css. Translation edits go through upsert_page_translation. Changing the category rewrites the slug prefix; cache for the old + new slug + categories is purged.',
|
description: 'Update a page\'s metadata: slug (leaf only), category_id, menu_order, theme, custom_css. Translation edits go through upsert_page_translation. Changing slug or category changes the public URL — cache for the old and new URLs is purged.',
|
||||||
scope: 'pages.write',
|
scope: 'pages.write',
|
||||||
input: updatePageInput,
|
input: updatePageInput,
|
||||||
async handler(input, ctx) {
|
async handler(input, ctx) {
|
||||||
|
|
@ -200,11 +202,11 @@ export const deletePageTool = defineMcpTool({
|
||||||
destructive: true,
|
destructive: true,
|
||||||
input: deletePageInput,
|
input: deletePageInput,
|
||||||
async handler(input, ctx) {
|
async handler(input, ctx) {
|
||||||
const { slug, categoryId } = await deleteCmsPage(input.id)
|
const { slug, url, categoryId } = await deleteCmsPage(input.id)
|
||||||
await mcpLog('DELETE', 'pages', input.id, ctx, { slug })
|
await mcpLog('DELETE', 'pages', input.id, ctx, { slug, url })
|
||||||
await applyPageInvalidations([slug], categoryId ? [categoryId] : [])
|
await applyPageInvalidations([url], categoryId ? [categoryId] : [])
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text', text: `Deleted page "${slug}"` }],
|
content: [{ type: 'text', text: `Deleted page "${url}"` }],
|
||||||
structuredContent: { ok: true }
|
structuredContent: { ok: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@ export const upsertPageTranslationTool = defineMcpTool({
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
await applyTranslationInvalidations(result.pageSlug, result.categoryId, input.locale)
|
await applyTranslationInvalidations(result.pageUrl, result.categoryId, input.locale)
|
||||||
|
|
||||||
const warningCount = result.sanitizationWarnings.length
|
const warningCount = result.sanitizationWarnings.length
|
||||||
const warningSuffix = warningCount > 0
|
const warningSuffix = warningCount > 0
|
||||||
|
|
@ -153,7 +153,7 @@ export const publishTranslationTool = defineMcpTool({
|
||||||
to: 'published',
|
to: 'published',
|
||||||
locale: input.locale
|
locale: input.locale
|
||||||
})
|
})
|
||||||
await applyTranslationInvalidations(result.pageSlug, result.categoryId, input.locale)
|
await applyTranslationInvalidations(result.pageUrl, result.categoryId, input.locale)
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text', text: `Published ${result.translation.locale} translation` }],
|
content: [{ type: 'text', text: `Published ${result.translation.locale} translation` }],
|
||||||
structuredContent: { translation: result.translation }
|
structuredContent: { translation: result.translation }
|
||||||
|
|
@ -177,7 +177,7 @@ export const unpublishTranslationTool = defineMcpTool({
|
||||||
to: 'draft',
|
to: 'draft',
|
||||||
locale: input.locale
|
locale: input.locale
|
||||||
})
|
})
|
||||||
await applyTranslationInvalidations(result.pageSlug, result.categoryId, input.locale)
|
await applyTranslationInvalidations(result.pageUrl, result.categoryId, input.locale)
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text', text: `Unpublished ${result.translation.locale} translation` }],
|
content: [{ type: 'text', text: `Unpublished ${result.translation.locale} translation` }],
|
||||||
structuredContent: { translation: result.translation }
|
structuredContent: { translation: result.translation }
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
// Category service. List/get reuse existing helpers; the writes
|
// Category service. Categories form a tree (`parent_id`); a slug or
|
||||||
// implement the validation + cache-purge contract MCP and admin both
|
// parent change cascades to every URL beneath the affected node.
|
||||||
// rely on. Delete encodes the "refuse if pages still attached" rule
|
|
||||||
// from plans/mcp-project/mcp-project.md (count + sample-slugs surface
|
|
||||||
// on the structuredContent the tool returns).
|
|
||||||
|
|
||||||
import type { H3Error } from 'h3'
|
import type { H3Error } from 'h3'
|
||||||
import { sql } from 'kysely'
|
import { sql } from 'kysely'
|
||||||
|
|
@ -16,6 +13,11 @@ import {
|
||||||
purgeSlugs
|
purgeSlugs
|
||||||
} from '../database/categories'
|
} from '../database/categories'
|
||||||
import type { Category, CategoryTranslation } from '../database/categories'
|
import type { Category, CategoryTranslation } from '../database/categories'
|
||||||
|
import {
|
||||||
|
loadCategoryTree,
|
||||||
|
categoryUrlPath,
|
||||||
|
wouldCreateCycle
|
||||||
|
} from '../database/categoryTree'
|
||||||
|
|
||||||
function err(statusCode: number, message: string, extra?: Record<string, unknown>): H3Error {
|
function err(statusCode: number, message: string, extra?: Record<string, unknown>): H3Error {
|
||||||
return Object.assign(new Error(message), {
|
return Object.assign(new Error(message), {
|
||||||
|
|
@ -28,14 +30,79 @@ function err(statusCode: number, message: string, extra?: Record<string, unknown
|
||||||
export interface CategoryListItem extends Category {
|
export interface CategoryListItem extends Category {
|
||||||
translations: CategoryTranslation[]
|
translations: CategoryTranslation[]
|
||||||
page_count: number
|
page_count: number
|
||||||
|
url: string
|
||||||
|
// Slug-joined ancestor URL (e.g. "resources" — usable in href).
|
||||||
|
parent_path: string | null
|
||||||
|
// Human-readable ancestor chain using each ancestor's English name
|
||||||
|
// (e.g. "Resources" — for labels and dropdowns). Falls back to the
|
||||||
|
// slug for any ancestor missing an EN translation.
|
||||||
|
parent_label: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Joins a category's ancestor chain (root → immediate parent) using
|
||||||
|
// each ancestor's English translation, falling back to the slug.
|
||||||
|
function ancestorLabel(
|
||||||
|
tree: Awaited<ReturnType<typeof loadCategoryTree>>,
|
||||||
|
parentId: string | null,
|
||||||
|
enNameByCategory: Map<string, string>
|
||||||
|
): string | null {
|
||||||
|
if (!parentId) return null
|
||||||
|
const labels: string[] = []
|
||||||
|
let id: string | null = parentId
|
||||||
|
for (let i = 0; i < 32 && id; i++) {
|
||||||
|
const node = tree.byId.get(id)
|
||||||
|
if (!node) break
|
||||||
|
labels.unshift(enNameByCategory.get(id) ?? node.slug)
|
||||||
|
id = node.parent_id
|
||||||
|
}
|
||||||
|
return labels.length ? labels.join(' / ') : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listCmsCategories(): Promise<CategoryListItem[]> {
|
export async function listCmsCategories(): Promise<CategoryListItem[]> {
|
||||||
return dbListCategoriesWithTranslations()
|
const rows = await dbListCategoriesWithTranslations()
|
||||||
|
if (rows.length === 0) return []
|
||||||
|
const tree = await loadCategoryTree()
|
||||||
|
const enByCategory = new Map<string, string>()
|
||||||
|
for (const row of rows) {
|
||||||
|
const en = row.translations.find(t => t.locale === 'en')?.name
|
||||||
|
if (en) enByCategory.set(row.id, en)
|
||||||
|
}
|
||||||
|
return rows.map(row => ({
|
||||||
|
...row,
|
||||||
|
url: categoryUrlPath(tree, row.id),
|
||||||
|
parent_path: row.parent_id ? categoryUrlPath(tree, row.parent_id) : null,
|
||||||
|
parent_label: ancestorLabel(tree, row.parent_id, enByCategory)
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCmsCategory(id: string) {
|
export async function getCmsCategory(id: string) {
|
||||||
return dbGetCategory(id)
|
const record = await dbGetCategory(id)
|
||||||
|
if (!record) return null
|
||||||
|
const tree = await loadCategoryTree()
|
||||||
|
// Walk every ancestor's EN translation in one bulk query so the
|
||||||
|
// label chain doesn't fan out into per-ancestor SELECTs.
|
||||||
|
const ancestorIds: string[] = []
|
||||||
|
let cursor: string | null = record.category.parent_id
|
||||||
|
for (let i = 0; i < 32 && cursor; i++) {
|
||||||
|
ancestorIds.push(cursor)
|
||||||
|
cursor = tree.byId.get(cursor)?.parent_id ?? null
|
||||||
|
}
|
||||||
|
const enByCategory = new Map<string, string>()
|
||||||
|
if (ancestorIds.length > 0) {
|
||||||
|
const rows = await db
|
||||||
|
.selectFrom('category_translations')
|
||||||
|
.select(['category_id', 'name'])
|
||||||
|
.where('category_id', 'in', ancestorIds)
|
||||||
|
.where('locale', '=', 'en')
|
||||||
|
.execute()
|
||||||
|
for (const r of rows) enByCategory.set(r.category_id, r.name)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...record,
|
||||||
|
url: categoryUrlPath(tree, id),
|
||||||
|
parent_path: record.category.parent_id ? categoryUrlPath(tree, record.category.parent_id) : null,
|
||||||
|
parent_label: ancestorLabel(tree, record.category.parent_id, enByCategory)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateCategorySlug(slug: string): string {
|
function validateCategorySlug(slug: string): string {
|
||||||
|
|
@ -57,8 +124,43 @@ function normalizeTranslations(
|
||||||
.map(t => ({ locale: t.locale, name: t.name.trim() }))
|
.map(t => ({ locale: t.locale, name: t.name.trim() }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sibling-collision check shared by create + update. A category and a
|
||||||
|
// page at the same level can't share a leaf slug (the URL would be
|
||||||
|
// ambiguous), nor can two sibling categories.
|
||||||
|
async function ensureNoSiblingCollision(
|
||||||
|
slug: string,
|
||||||
|
parentId: string | null,
|
||||||
|
excludeCategoryId?: string
|
||||||
|
): Promise<void> {
|
||||||
|
let catQ = db
|
||||||
|
.selectFrom('categories')
|
||||||
|
.select('id')
|
||||||
|
.where('slug', '=', slug)
|
||||||
|
catQ = parentId === null
|
||||||
|
? catQ.where('parent_id', 'is', null)
|
||||||
|
: catQ.where('parent_id', '=', parentId)
|
||||||
|
if (excludeCategoryId) catQ = catQ.where('id', '!=', excludeCategoryId)
|
||||||
|
const collidingCat = await catQ.executeTakeFirst()
|
||||||
|
if (collidingCat) {
|
||||||
|
throw err(409, 'A category with that slug already exists at this level')
|
||||||
|
}
|
||||||
|
|
||||||
|
let pageQ = db
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select('id')
|
||||||
|
.where('slug', '=', slug)
|
||||||
|
pageQ = parentId === null
|
||||||
|
? pageQ.where('category_id', 'is', null)
|
||||||
|
: pageQ.where('category_id', '=', parentId)
|
||||||
|
const collidingPage = await pageQ.executeTakeFirst()
|
||||||
|
if (collidingPage) {
|
||||||
|
throw err(409, `A page already uses the slug "${slug}" at this level. Pick a different slug or move that page.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateCategoryInput {
|
export interface CreateCategoryInput {
|
||||||
slug: string
|
slug: string
|
||||||
|
parent_id?: string | null
|
||||||
menu_order?: number
|
menu_order?: number
|
||||||
translations: Array<{ locale: string; name: string }>
|
translations: Array<{ locale: string; name: string }>
|
||||||
}
|
}
|
||||||
|
|
@ -70,52 +172,86 @@ export async function createCmsCategory(input: CreateCategoryInput): Promise<Cat
|
||||||
throw err(400, 'An English name is required')
|
throw err(400, 'An English name is required')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block collision with an uncategorized page
|
const parentId = input.parent_id ?? null
|
||||||
const collidingPage = await db
|
if (parentId) {
|
||||||
.selectFrom('pages')
|
const parent = await db
|
||||||
.select('id')
|
.selectFrom('categories')
|
||||||
.where('slug', '=', slug)
|
.select('id')
|
||||||
.where('category_id', 'is', null)
|
.where('id', '=', parentId)
|
||||||
.executeTakeFirst()
|
.executeTakeFirst()
|
||||||
if (collidingPage) {
|
if (!parent) throw err(400, 'Parent category not found')
|
||||||
throw err(409, `A page already uses the slug "${slug}". Pick a different category slug or reassign that page.`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await ensureNoSiblingCollision(slug, parentId)
|
||||||
|
|
||||||
|
let created: Category
|
||||||
try {
|
try {
|
||||||
return await dbCreateCategory({
|
created = await dbCreateCategory({
|
||||||
slug,
|
slug,
|
||||||
|
parent_id: parentId,
|
||||||
menu_order: input.menu_order ?? 0,
|
menu_order: input.menu_order ?? 0,
|
||||||
translations
|
translations
|
||||||
})
|
})
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if ((e as { code?: string })?.code === '23505') {
|
if ((e as { code?: string })?.code === '23505') {
|
||||||
throw err(409, 'A category with that slug already exists')
|
throw err(409, 'A category with that slug already exists at this level')
|
||||||
}
|
}
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A new child appears in its parent's landing-page grid; bust the
|
||||||
|
// parent's cached landing so it shows up without waiting for the TTL.
|
||||||
|
if (created.parent_id) {
|
||||||
|
const tree = await loadCategoryTree()
|
||||||
|
const parentPath = categoryUrlPath(tree, created.parent_id)
|
||||||
|
if (parentPath) await applyCategoryInvalidations([parentPath])
|
||||||
|
}
|
||||||
|
return created
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateCategoryInput {
|
export interface UpdateCategoryInput {
|
||||||
id: string
|
id: string
|
||||||
slug?: string
|
slug?: string
|
||||||
|
parent_id?: string | null
|
||||||
menu_order?: number
|
menu_order?: number
|
||||||
translations?: Array<{ locale: string; name: string }>
|
translations?: Array<{ locale: string; name: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCmsCategory(input: UpdateCategoryInput): Promise<{ category: Category; slugsToPurge: string[] }> {
|
export async function updateCmsCategory(input: UpdateCategoryInput): Promise<{ category: Category; slugsToPurge: string[] }> {
|
||||||
|
const existing = await db
|
||||||
|
.selectFrom('categories')
|
||||||
|
.selectAll()
|
||||||
|
.where('id', '=', input.id)
|
||||||
|
.executeTakeFirst()
|
||||||
|
if (!existing) throw err(404, 'Category not found')
|
||||||
|
|
||||||
const args: Parameters<typeof dbUpdateCategory>[1] = {}
|
const args: Parameters<typeof dbUpdateCategory>[1] = {}
|
||||||
|
let nextSlug = existing.slug
|
||||||
|
let nextParentId: string | null = existing.parent_id
|
||||||
|
|
||||||
if (input.slug !== undefined) {
|
if (input.slug !== undefined) {
|
||||||
const slug = validateCategorySlug(input.slug)
|
nextSlug = validateCategorySlug(input.slug)
|
||||||
const collidingPage = await db
|
args.slug = nextSlug
|
||||||
.selectFrom('pages')
|
}
|
||||||
.select('id')
|
if (input.parent_id !== undefined) {
|
||||||
.where('slug', '=', slug)
|
nextParentId = input.parent_id ?? null
|
||||||
.where('category_id', 'is', null)
|
if (nextParentId) {
|
||||||
.executeTakeFirst()
|
const tree = await loadCategoryTree()
|
||||||
if (collidingPage) {
|
if (wouldCreateCycle(tree, input.id, nextParentId)) {
|
||||||
throw err(409, `A page already uses the slug "${slug}".`)
|
throw err(400, 'Parent must not be the category itself or one of its descendants')
|
||||||
|
}
|
||||||
|
const parent = await db
|
||||||
|
.selectFrom('categories')
|
||||||
|
.select('id')
|
||||||
|
.where('id', '=', nextParentId)
|
||||||
|
.executeTakeFirst()
|
||||||
|
if (!parent) throw err(400, 'Parent category not found')
|
||||||
}
|
}
|
||||||
args.slug = slug
|
args.parent_id = nextParentId
|
||||||
|
}
|
||||||
|
if ((args.slug !== undefined && nextSlug !== existing.slug) ||
|
||||||
|
(args.parent_id !== undefined && nextParentId !== existing.parent_id)) {
|
||||||
|
await ensureNoSiblingCollision(nextSlug, nextParentId, input.id)
|
||||||
}
|
}
|
||||||
if (input.menu_order !== undefined) args.menu_order = input.menu_order
|
if (input.menu_order !== undefined) args.menu_order = input.menu_order
|
||||||
if (input.translations !== undefined) args.translations = normalizeTranslations(input.translations)
|
if (input.translations !== undefined) args.translations = normalizeTranslations(input.translations)
|
||||||
|
|
@ -124,30 +260,48 @@ export async function updateCmsCategory(input: UpdateCategoryInput): Promise<{ c
|
||||||
return await dbUpdateCategory(input.id, args)
|
return await dbUpdateCategory(input.id, args)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if ((e as { code?: string })?.code === '23505') {
|
if ((e as { code?: string })?.code === '23505') {
|
||||||
throw err(409, 'A category with that slug already exists')
|
throw err(409, 'A category with that slug already exists at this level')
|
||||||
}
|
}
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeleteCategoryError {
|
export interface DeleteCategoryError {
|
||||||
error: 'attached_pages_present'
|
error: 'attached_pages_present' | 'child_categories_present'
|
||||||
attached_page_count: number
|
attached_page_count: number
|
||||||
sample_slugs: string[]
|
sample_slugs: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns null on success, or a structured error payload when pages
|
// Returns null on success, or a structured error payload when the
|
||||||
// are still attached. Uses the FK constraint to handle the rare race
|
// category still has pages or child categories.
|
||||||
// between count and delete (see plans/mcp-project/mcp-project.md).
|
|
||||||
export async function deleteCmsCategory(id: string): Promise<{ ok: true; slug: string } | DeleteCategoryError> {
|
export async function deleteCmsCategory(id: string): Promise<{ ok: true; slug: string } | DeleteCategoryError> {
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.selectFrom('categories')
|
.selectFrom('categories')
|
||||||
.select(['id', 'slug'])
|
.select(['id', 'slug', 'parent_id'])
|
||||||
.where('id', '=', id)
|
.where('id', '=', id)
|
||||||
.executeTakeFirst()
|
.executeTakeFirst()
|
||||||
if (!existing) throw err(404, 'Category not found')
|
if (!existing) throw err(404, 'Category not found')
|
||||||
|
|
||||||
const result = await db.transaction().execute(async (tx) => {
|
const result = await db.transaction().execute(async (tx) => {
|
||||||
|
const childCountRow = await tx
|
||||||
|
.selectFrom('categories')
|
||||||
|
.select(sql<number>`COUNT(*)::int`.as('count'))
|
||||||
|
.where('parent_id', '=', id)
|
||||||
|
.executeTakeFirstOrThrow()
|
||||||
|
if (Number(childCountRow.count) > 0) {
|
||||||
|
const sample = await tx
|
||||||
|
.selectFrom('categories')
|
||||||
|
.select('slug')
|
||||||
|
.where('parent_id', '=', id)
|
||||||
|
.limit(5)
|
||||||
|
.execute()
|
||||||
|
return {
|
||||||
|
kind: 'children' as const,
|
||||||
|
attached_page_count: Number(childCountRow.count),
|
||||||
|
sample_slugs: sample.map(s => s.slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const countRow = await tx
|
const countRow = await tx
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
.select(sql<number>`COUNT(*)::int`.as('count'))
|
.select(sql<number>`COUNT(*)::int`.as('count'))
|
||||||
|
|
@ -174,7 +328,6 @@ export async function deleteCmsCategory(id: string): Promise<{ ok: true; slug: s
|
||||||
return { kind: 'deleted' as const, slug: existing.slug }
|
return { kind: 'deleted' as const, slug: existing.slug }
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if ((e as { code?: string })?.code === '23503') {
|
if ((e as { code?: string })?.code === '23503') {
|
||||||
// FK violation: race window — a page was just attached.
|
|
||||||
return {
|
return {
|
||||||
kind: 'attached' as const,
|
kind: 'attached' as const,
|
||||||
attached_page_count: 1,
|
attached_page_count: 1,
|
||||||
|
|
@ -192,6 +345,22 @@ export async function deleteCmsCategory(id: string): Promise<{ ok: true; slug: s
|
||||||
sample_slugs: result.sample_slugs
|
sample_slugs: result.sample_slugs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (result.kind === 'children') {
|
||||||
|
return {
|
||||||
|
error: 'child_categories_present',
|
||||||
|
attached_page_count: result.attached_page_count,
|
||||||
|
sample_slugs: result.sample_slugs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The deleted child drops out of its parent's landing-page grid; bust
|
||||||
|
// the parent's cached landing. (The parent still exists post-delete,
|
||||||
|
// so its path resolves.)
|
||||||
|
if (existing.parent_id) {
|
||||||
|
const tree = await loadCategoryTree()
|
||||||
|
const parentPath = categoryUrlPath(tree, existing.parent_id)
|
||||||
|
if (parentPath) await applyCategoryInvalidations([parentPath])
|
||||||
|
}
|
||||||
return { ok: true, slug: result.slug }
|
return { ok: true, slug: result.slug }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
// Shared service for CMS page operations. Called by MCP tools today;
|
// Shared service for CMS page operations. Called by MCP tools and admin
|
||||||
// admin routes will follow under a separate service-extraction PR
|
// routes. Each operation owns: validation, DB writes (transactional
|
||||||
// (Phase P1/P4 in plans/mcp-project/mcp-project.md).
|
// when more than one row changes), activity logging (post-commit unless
|
||||||
|
// inside a transaction), and cache invalidation (always post-commit).
|
||||||
//
|
//
|
||||||
// Each operation is responsible for: validation, DB writes (in a
|
// Slugs are leaves only — the public URL is derived from the page's
|
||||||
// transaction when more than one row changes), activity logging
|
// category chain at request time. Cache purges therefore work in terms
|
||||||
// (post-commit unless inside a transaction), and cache invalidation
|
// of *URLs* (`pageUrl`), not the raw `slug` column. Service callers get
|
||||||
// (always post-commit).
|
// the URL back in `slugsToPurge` (kept that name for compatibility with
|
||||||
|
// the cache layer's API).
|
||||||
|
|
||||||
import { sql } from 'kysely'
|
import { sql } from 'kysely'
|
||||||
import type { Kysely } from 'kysely'
|
import type { Kysely } from 'kysely'
|
||||||
|
|
@ -14,7 +16,14 @@ import { db } from '../utils/database'
|
||||||
import { purgeCmsPage, purgeCmsCategory } from '../utils/cmsCache'
|
import { purgeCmsPage, purgeCmsCategory } from '../utils/cmsCache'
|
||||||
import { ENABLED_LANGUAGE_CODES } from '~~/config/languages'
|
import { ENABLED_LANGUAGE_CODES } from '~~/config/languages'
|
||||||
import type { Page, PageTranslation } from '../database/pages'
|
import type { Page, PageTranslation } from '../database/pages'
|
||||||
|
import { findPageInCategory } from '../database/pages'
|
||||||
import type { Database } from '../database/schema'
|
import type { Database } from '../database/schema'
|
||||||
|
import {
|
||||||
|
loadCategoryTree,
|
||||||
|
walkCategorySegments,
|
||||||
|
pageUrlPath,
|
||||||
|
categoryUrlPath
|
||||||
|
} from '../database/categoryTree'
|
||||||
|
|
||||||
export interface PageActor {
|
export interface PageActor {
|
||||||
userId: string
|
userId: string
|
||||||
|
|
@ -25,8 +34,10 @@ export interface PageActor {
|
||||||
export interface PageListItem {
|
export interface PageListItem {
|
||||||
id: string
|
id: string
|
||||||
slug: string
|
slug: string
|
||||||
|
url: string
|
||||||
category_id: string | null
|
category_id: string | null
|
||||||
category_slug: string | null
|
category_slug: string | null
|
||||||
|
category_path: string | null
|
||||||
theme: 'default' | 'green'
|
theme: 'default' | 'green'
|
||||||
menu_order: number
|
menu_order: number
|
||||||
translations: Array<{
|
translations: Array<{
|
||||||
|
|
@ -138,9 +149,6 @@ export async function listCmsPages(opts: ListPagesOptions = {}): Promise<ListPag
|
||||||
|
|
||||||
let translations = await tQuery.execute()
|
let translations = await tQuery.execute()
|
||||||
|
|
||||||
// Title substring match: requires a translation row to exist for
|
|
||||||
// the page (any locale). When a query is provided we also keep
|
|
||||||
// pages whose slug matched (already filtered above).
|
|
||||||
if (opts.query) {
|
if (opts.query) {
|
||||||
const pattern = opts.query.toLowerCase()
|
const pattern = opts.query.toLowerCase()
|
||||||
translations = translations.filter(t => t.title.toLowerCase().includes(pattern))
|
translations = translations.filter(t => t.title.toLowerCase().includes(pattern))
|
||||||
|
|
@ -158,15 +166,21 @@ export async function listCmsPages(opts: ListPagesOptions = {}): Promise<ListPag
|
||||||
tByPage.set(t.page_id, list)
|
tByPage.set(t.page_id, list)
|
||||||
}
|
}
|
||||||
|
|
||||||
const out: PageListItem[] = pageRows.map(p => ({
|
const tree = await loadCategoryTree()
|
||||||
id: p.id,
|
const out: PageListItem[] = pageRows.map(p => {
|
||||||
slug: p.slug,
|
const categoryPath = p.category_id ? categoryUrlPath(tree, p.category_id) : null
|
||||||
category_id: p.category_id,
|
return {
|
||||||
category_slug: p.category_slug ?? null,
|
id: p.id,
|
||||||
theme: p.theme as 'default' | 'green',
|
slug: p.slug,
|
||||||
menu_order: p.menu_order,
|
url: pageUrlPath(tree, { slug: p.slug, category_id: p.category_id }),
|
||||||
translations: tByPage.get(p.id) ?? []
|
category_id: p.category_id,
|
||||||
}))
|
category_slug: p.category_slug ?? null,
|
||||||
|
category_path: categoryPath || null,
|
||||||
|
theme: p.theme as 'default' | 'green',
|
||||||
|
menu_order: p.menu_order,
|
||||||
|
translations: tByPage.get(p.id) ?? []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return { pages: out, nextCursor }
|
return { pages: out, nextCursor }
|
||||||
}
|
}
|
||||||
|
|
@ -175,42 +189,50 @@ export interface FullPage {
|
||||||
page: Page
|
page: Page
|
||||||
translations: PageTranslation[]
|
translations: PageTranslation[]
|
||||||
category_slug: string | null
|
category_slug: string | null
|
||||||
|
category_path: string | null
|
||||||
|
url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCmsPage(input: { id?: string; slug?: string }): Promise<FullPage | null> {
|
export async function getCmsPage(input: { id?: string; slug?: string }): Promise<FullPage | null> {
|
||||||
let pageQuery = db.selectFrom('pages').selectAll()
|
const tree = await loadCategoryTree()
|
||||||
if (input.id) pageQuery = pageQuery.where('id', '=', input.id)
|
|
||||||
else if (input.slug) pageQuery = pageQuery.where('slug', '=', input.slug)
|
|
||||||
else return null
|
|
||||||
|
|
||||||
const page = await pageQuery.executeTakeFirst()
|
let page: Page | undefined
|
||||||
|
if (input.id) {
|
||||||
|
page = await db.selectFrom('pages').selectAll().where('id', '=', input.id).executeTakeFirst()
|
||||||
|
} else if (input.slug) {
|
||||||
|
// Resolve the slug as a full URL path ("resources/overview") against
|
||||||
|
// the category tree so a leaf that repeats across categories is
|
||||||
|
// unambiguous. A bare leaf ("privacy") resolves to an uncategorized
|
||||||
|
// page; a path that ends on a category (no trailing page) is not a
|
||||||
|
// page lookup and returns null.
|
||||||
|
const segments = input.slug.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean)
|
||||||
|
if (segments.length === 0) return null
|
||||||
|
const { category, remaining } = walkCategorySegments(tree, segments)
|
||||||
|
if (remaining.length !== 1) return null
|
||||||
|
page = (await findPageInCategory(category?.id ?? null, remaining[0]!)) ?? undefined
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
if (!page) return null
|
if (!page) return null
|
||||||
|
|
||||||
const [translations, category] = await Promise.all([
|
const translations = await db
|
||||||
db
|
.selectFrom('page_translations')
|
||||||
.selectFrom('page_translations')
|
.selectAll()
|
||||||
.selectAll()
|
.where('page_id', '=', page.id)
|
||||||
.where('page_id', '=', page.id)
|
.orderBy('locale', 'asc')
|
||||||
.orderBy('locale', 'asc')
|
.execute()
|
||||||
.execute(),
|
|
||||||
page.category_id
|
|
||||||
? db
|
|
||||||
.selectFrom('categories')
|
|
||||||
.select('slug')
|
|
||||||
.where('id', '=', page.category_id)
|
|
||||||
.executeTakeFirst()
|
|
||||||
: Promise.resolve(null)
|
|
||||||
])
|
|
||||||
|
|
||||||
|
const categoryPath = page.category_id ? categoryUrlPath(tree, page.category_id) : null
|
||||||
|
const categorySlug = page.category_id ? (tree.byId.get(page.category_id)?.slug ?? null) : null
|
||||||
return {
|
return {
|
||||||
page,
|
page,
|
||||||
translations,
|
translations,
|
||||||
category_slug: category?.slug ?? null
|
category_slug: categorySlug,
|
||||||
|
category_path: categoryPath || null,
|
||||||
|
url: pageUrlPath(tree, page)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validates a slug shape and uniqueness. Throws an H3-shaped error so
|
|
||||||
// the MCP layer's mcpError mapper can translate to a clean response.
|
|
||||||
function err(statusCode: number, message: string): H3Error {
|
function err(statusCode: number, message: string): H3Error {
|
||||||
return Object.assign(new Error(message), {
|
return Object.assign(new Error(message), {
|
||||||
statusCode,
|
statusCode,
|
||||||
|
|
@ -219,62 +241,53 @@ function err(statusCode: number, message: string): H3Error {
|
||||||
}) as unknown as H3Error
|
}) as unknown as H3Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize slug input: trim whitespace, strip leading/trailing
|
// Normalize slug input: trim whitespace, strip any slashes (slugs are
|
||||||
// slashes. Single source of truth — admin routes and MCP tools both
|
// leaves — slashes were valid only in the old prefix-as-slug model).
|
||||||
// pass raw input; services normalize before validating. Empty result
|
|
||||||
// passes through; the regex check downstream rejects empty strings.
|
|
||||||
export function normalizeSlugInput(raw: string | null | undefined): string {
|
export function normalizeSlugInput(raw: string | null | undefined): string {
|
||||||
return (raw ?? '').trim().replace(/^\/+|\/+$/g, '')
|
return (raw ?? '').trim().replace(/^\/+|\/+$/g, '').replace(/\/+/g, '-')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass an `executor` (a Kysely transaction handle) when validation
|
// Validates a leaf slug: shape, no collision with a sibling category
|
||||||
// is part of a larger atomic operation — the read-side category
|
// at the same level (a category and a page can't share a leaf because
|
||||||
// existence/prefix check and the page-slug-uniqueness check then
|
// `/parent/leaf` would be ambiguous), and no collision with another
|
||||||
// run on the same connection as the subsequent insert/update, so
|
// page in the same category.
|
||||||
// a concurrent category rename or page-slug change can't slip
|
|
||||||
// between validation and write.
|
|
||||||
export async function validatePageSlug(
|
export async function validatePageSlug(
|
||||||
slug: string,
|
slug: string,
|
||||||
opts: { categoryId: string | null; excludePageId?: string },
|
opts: { categoryId: string | null; excludePageId?: string },
|
||||||
executor: Kysely<Database> = db
|
executor: Kysely<Database> = db
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!/^[a-z0-9][a-z0-9/-]*$/.test(slug)) {
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
|
||||||
throw err(400, 'slug must be lowercase letters, digits, dashes, and slashes')
|
throw err(400, 'slug must be lowercase letters, digits, and dashes (no slashes)')
|
||||||
}
|
|
||||||
if (slug.endsWith('/')) {
|
|
||||||
throw err(400, 'slug cannot end with /')
|
|
||||||
}
|
|
||||||
if (opts.categoryId) {
|
|
||||||
const category = await executor
|
|
||||||
.selectFrom('categories')
|
|
||||||
.select(['slug'])
|
|
||||||
.where('id', '=', opts.categoryId)
|
|
||||||
.executeTakeFirst()
|
|
||||||
if (!category) throw err(400, 'Category not found')
|
|
||||||
if (!slug.startsWith(`${category.slug}/`)) {
|
|
||||||
throw err(400, `Slug must start with "${category.slug}/"`)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const collidingCategory = await executor
|
|
||||||
.selectFrom('categories')
|
|
||||||
.select('id')
|
|
||||||
.where('slug', '=', slug)
|
|
||||||
.executeTakeFirst()
|
|
||||||
if (collidingCategory) {
|
|
||||||
throw err(409, `"${slug}" is already used by a category.`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sibling-category collision: a category whose `parent_id` matches
|
||||||
|
// this page's category would render at the same URL level.
|
||||||
|
let categoryQuery = executor
|
||||||
|
.selectFrom('categories')
|
||||||
|
.select('id')
|
||||||
|
.where('slug', '=', slug)
|
||||||
|
categoryQuery = opts.categoryId === null
|
||||||
|
? categoryQuery.where('parent_id', 'is', null)
|
||||||
|
: categoryQuery.where('parent_id', '=', opts.categoryId)
|
||||||
|
const collidingCategory = await categoryQuery.executeTakeFirst()
|
||||||
|
if (collidingCategory) {
|
||||||
|
throw err(409, `"${slug}" is already used by a category at this level.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sibling-page collision in the same category.
|
||||||
let pageQuery = executor
|
let pageQuery = executor
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
.select('id')
|
.select('id')
|
||||||
.where('slug', '=', slug)
|
.where('slug', '=', slug)
|
||||||
|
pageQuery = opts.categoryId === null
|
||||||
|
? pageQuery.where('category_id', 'is', null)
|
||||||
|
: pageQuery.where('category_id', '=', opts.categoryId)
|
||||||
if (opts.excludePageId) {
|
if (opts.excludePageId) {
|
||||||
pageQuery = pageQuery.where('id', '!=', opts.excludePageId)
|
pageQuery = pageQuery.where('id', '!=', opts.excludePageId)
|
||||||
}
|
}
|
||||||
const collidingPage = await pageQuery.executeTakeFirst()
|
const collidingPage = await pageQuery.executeTakeFirst()
|
||||||
if (collidingPage) {
|
if (collidingPage) {
|
||||||
throw err(409, 'A page with that slug already exists')
|
throw err(409, 'A page with that slug already exists in this category')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -297,18 +310,8 @@ export interface CreatePageInput {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Creates a page (and optionally an inline initial translation) in a
|
|
||||||
// single transaction. The audit hook receives the transaction's
|
|
||||||
// executor; callers (admin routes, MCP tools) are expected to pass a
|
|
||||||
// `throwOnError: true` to logEvent (or use mcpLog, which does this
|
|
||||||
// automatically when an executor is provided) so an audit-insert
|
|
||||||
// failure inside the transaction rolls back the page insert.
|
|
||||||
export interface CreateCmsPageAuditContext {
|
export interface CreateCmsPageAuditContext {
|
||||||
pageId: string
|
pageId: string
|
||||||
// The translation row id, set only when an inline translation was
|
|
||||||
// created in the same transaction. Lets callers emit a separate
|
|
||||||
// CREATE event on page_translations keyed by the actual row id so
|
|
||||||
// audit-log greps on `record_id` find the translation.
|
|
||||||
translationId: string | null
|
translationId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -323,15 +326,17 @@ export async function createCmsPage(
|
||||||
throw err(400, 'locale is not enabled')
|
throw err(400, 'locale is not enabled')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Use the normalized form everywhere downstream — input.slug never
|
|
||||||
// touches the DB after this point.
|
|
||||||
input = { ...input, slug }
|
input = { ...input, slug }
|
||||||
|
|
||||||
const result = await db.transaction().execute(async (tx) => {
|
const result = await db.transaction().execute(async (tx) => {
|
||||||
// Validate the slug inside the transaction so the category-prefix
|
if (input.category_id) {
|
||||||
// check and slug-uniqueness check run on the same connection as
|
const cat = await tx
|
||||||
// the insert. Without this a concurrent category rename could
|
.selectFrom('categories')
|
||||||
// change the prefix between validation and write.
|
.select('id')
|
||||||
|
.where('id', '=', input.category_id)
|
||||||
|
.executeTakeFirst()
|
||||||
|
if (!cat) throw err(400, 'Category not found')
|
||||||
|
}
|
||||||
await validatePageSlug(slug, { categoryId: input.category_id ?? null }, tx)
|
await validatePageSlug(slug, { categoryId: input.category_id ?? null }, tx)
|
||||||
|
|
||||||
// New pages append to the bottom of their category. When the caller
|
// New pages append to the bottom of their category. When the caller
|
||||||
|
|
@ -386,7 +391,11 @@ export async function createCmsPage(
|
||||||
return { page, translation }
|
return { page, translation }
|
||||||
})
|
})
|
||||||
|
|
||||||
const slugsToPurge = [result.page.slug]
|
// Compute the full public URL so the cache layer purges by the same
|
||||||
|
// key the lookup endpoint writes (full path, hex-encoded).
|
||||||
|
const tree = await loadCategoryTree()
|
||||||
|
const url = pageUrlPath(tree, result.page)
|
||||||
|
const slugsToPurge = [url]
|
||||||
const categoriesToPurge: string[] = []
|
const categoriesToPurge: string[] = []
|
||||||
if (result.page.category_id) categoriesToPurge.push(result.page.category_id)
|
if (result.page.category_id) categoriesToPurge.push(result.page.category_id)
|
||||||
|
|
||||||
|
|
@ -410,13 +419,6 @@ export interface UpdatePageResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCmsPage(input: UpdatePageInput): Promise<UpdatePageResult> {
|
export async function updateCmsPage(input: UpdatePageInput): Promise<UpdatePageResult> {
|
||||||
// Wrap reads + slug validation + UPDATE in one transaction so a
|
|
||||||
// concurrent rename of the source or target category — or a
|
|
||||||
// concurrent slug change on another page — can't slip between the
|
|
||||||
// category-prefix calculation and the row write. The bare-UPDATE
|
|
||||||
// version that lived here briefly had a race window where the
|
|
||||||
// page would commit with a stale prefix relative to the category's
|
|
||||||
// current slug.
|
|
||||||
return await db.transaction().execute(async (tx) => {
|
return await db.transaction().execute(async (tx) => {
|
||||||
const existing = await tx
|
const existing = await tx
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
|
|
@ -425,7 +427,12 @@ export async function updateCmsPage(input: UpdatePageInput): Promise<UpdatePageR
|
||||||
.executeTakeFirst()
|
.executeTakeFirst()
|
||||||
if (!existing) throw err(404, 'Page not found')
|
if (!existing) throw err(404, 'Page not found')
|
||||||
|
|
||||||
const slugsToPurge = new Set<string>([existing.slug])
|
// Snapshot the page's current full URL before any changes so we
|
||||||
|
// can purge the old key after the update commits.
|
||||||
|
const treeBefore = await loadCategoryTree(tx)
|
||||||
|
const oldUrl = pageUrlPath(treeBefore, existing)
|
||||||
|
|
||||||
|
const slugsToPurge = new Set<string>([oldUrl])
|
||||||
const categoriesToPurge = new Set<string>()
|
const categoriesToPurge = new Set<string>()
|
||||||
const changes: Record<string, unknown> = {}
|
const changes: Record<string, unknown> = {}
|
||||||
|
|
||||||
|
|
@ -437,40 +444,30 @@ export async function updateCmsPage(input: UpdatePageInput): Promise<UpdatePageR
|
||||||
let finalSlug = existing.slug
|
let finalSlug = existing.slug
|
||||||
if (input.slug !== undefined) {
|
if (input.slug !== undefined) {
|
||||||
finalSlug = normalizeSlugInput(input.slug)
|
finalSlug = normalizeSlugInput(input.slug)
|
||||||
} else if (input.category_id !== undefined && finalCategoryId !== existing.category_id) {
|
|
||||||
// Category change without slug change: rewrite the prefix.
|
|
||||||
let leaf = existing.slug
|
|
||||||
if (existing.category_id) {
|
|
||||||
const oldCat = await tx
|
|
||||||
.selectFrom('categories')
|
|
||||||
.select('slug')
|
|
||||||
.where('id', '=', existing.category_id)
|
|
||||||
.executeTakeFirst()
|
|
||||||
if (oldCat && existing.slug.startsWith(`${oldCat.slug}/`)) {
|
|
||||||
leaf = existing.slug.slice(oldCat.slug.length + 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (finalCategoryId) {
|
|
||||||
const newCat = await tx
|
|
||||||
.selectFrom('categories')
|
|
||||||
.select('slug')
|
|
||||||
.where('id', '=', finalCategoryId)
|
|
||||||
.executeTakeFirst()
|
|
||||||
if (!newCat) throw err(400, 'Category not found')
|
|
||||||
finalSlug = `${newCat.slug}/${leaf}`
|
|
||||||
} else {
|
|
||||||
finalSlug = leaf
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (finalSlug !== existing.slug) {
|
if (finalSlug !== existing.slug) {
|
||||||
await validatePageSlug(finalSlug, { categoryId: finalCategoryId, excludePageId: input.id }, tx)
|
|
||||||
changes.slug = finalSlug
|
changes.slug = finalSlug
|
||||||
}
|
}
|
||||||
if (finalCategoryId !== existing.category_id) {
|
if (finalCategoryId !== existing.category_id) {
|
||||||
changes.category_id = finalCategoryId
|
changes.category_id = finalCategoryId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate the (slug, category) pair when either changed. Done in
|
||||||
|
// the same transaction so a concurrent rename can't slip between
|
||||||
|
// validation and write.
|
||||||
|
if (changes.slug !== undefined || changes.category_id !== undefined) {
|
||||||
|
if (finalCategoryId) {
|
||||||
|
const cat = await tx
|
||||||
|
.selectFrom('categories')
|
||||||
|
.select('id')
|
||||||
|
.where('id', '=', finalCategoryId)
|
||||||
|
.executeTakeFirst()
|
||||||
|
if (!cat) throw err(400, 'Category not found')
|
||||||
|
}
|
||||||
|
await validatePageSlug(finalSlug, { categoryId: finalCategoryId, excludePageId: input.id }, tx)
|
||||||
|
}
|
||||||
|
|
||||||
if (input.menu_order !== undefined && input.menu_order !== existing.menu_order) {
|
if (input.menu_order !== undefined && input.menu_order !== existing.menu_order) {
|
||||||
changes.menu_order = input.menu_order
|
changes.menu_order = input.menu_order
|
||||||
}
|
}
|
||||||
|
|
@ -497,7 +494,8 @@ export async function updateCmsPage(input: UpdatePageInput): Promise<UpdatePageR
|
||||||
.returningAll()
|
.returningAll()
|
||||||
.executeTakeFirstOrThrow()
|
.executeTakeFirstOrThrow()
|
||||||
|
|
||||||
slugsToPurge.add(updated.slug)
|
const treeAfter = await loadCategoryTree(tx)
|
||||||
|
slugsToPurge.add(pageUrlPath(treeAfter, updated))
|
||||||
if (updated.slug !== existing.slug || updated.menu_order !== existing.menu_order || updated.category_id !== existing.category_id) {
|
if (updated.slug !== existing.slug || updated.menu_order !== existing.menu_order || updated.category_id !== existing.category_id) {
|
||||||
if (existing.category_id) categoriesToPurge.add(existing.category_id)
|
if (existing.category_id) categoriesToPurge.add(existing.category_id)
|
||||||
if (updated.category_id) categoriesToPurge.add(updated.category_id)
|
if (updated.category_id) categoriesToPurge.add(updated.category_id)
|
||||||
|
|
@ -512,7 +510,7 @@ export async function updateCmsPage(input: UpdatePageInput): Promise<UpdatePageR
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteCmsPage(id: string): Promise<{ slug: string; categoryId: string | null }> {
|
export async function deleteCmsPage(id: string): Promise<{ slug: string; url: string; categoryId: string | null }> {
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
.select(['id', 'slug', 'category_id'])
|
.select(['id', 'slug', 'category_id'])
|
||||||
|
|
@ -520,14 +518,14 @@ export async function deleteCmsPage(id: string): Promise<{ slug: string; categor
|
||||||
.executeTakeFirst()
|
.executeTakeFirst()
|
||||||
if (!existing) throw err(404, 'Page not found')
|
if (!existing) throw err(404, 'Page not found')
|
||||||
|
|
||||||
|
const tree = await loadCategoryTree()
|
||||||
|
const url = pageUrlPath(tree, existing)
|
||||||
|
|
||||||
await db.deleteFrom('pages').where('id', '=', id).execute()
|
await db.deleteFrom('pages').where('id', '=', id).execute()
|
||||||
return { slug: existing.slug, categoryId: existing.category_id }
|
return { slug: existing.slug, url, categoryId: existing.category_id }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function applyPageInvalidations(slugs: string[], categoryIds: string[]): Promise<void> {
|
export async function applyPageInvalidations(slugs: string[], categoryIds: string[]): Promise<void> {
|
||||||
await Promise.all(slugs.map(s => purgeCmsPage(s)))
|
await Promise.all(slugs.map(s => purgeCmsPage(s)))
|
||||||
// Skip the slugs we just purged from the category-sibling fan-out;
|
|
||||||
// otherwise purgeCmsCategory enumerates every member (including the
|
|
||||||
// page we already touched) and re-purges keys for no benefit.
|
|
||||||
await Promise.all(categoryIds.map(id => purgeCmsCategory(id, slugs)))
|
await Promise.all(categoryIds.map(id => purgeCmsCategory(id, slugs)))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -214,7 +214,7 @@ export async function translatePage(input: TranslatePageInput): Promise<Translat
|
||||||
// keeps the invalidation contract identical to admin's pre-
|
// keeps the invalidation contract identical to admin's pre-
|
||||||
// unification behavior — locales that errored or were skipped
|
// unification behavior — locales that errored or were skipped
|
||||||
// never had their cache touched.
|
// never had their cache touched.
|
||||||
await applyTranslationInvalidations(upserted.pageSlug, upserted.categoryId, target)
|
await applyTranslationInvalidations(upserted.pageUrl, upserted.categoryId, target)
|
||||||
|
|
||||||
results.push({ locale: target, ok: true })
|
results.push({ locale: target, ok: true })
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { detectLossy } from '../utils/tiptapLossyDetector'
|
||||||
import { tiptapValidate, type SanitizationWarning } from '../utils/tiptapValidate'
|
import { tiptapValidate, type SanitizationWarning } from '../utils/tiptapValidate'
|
||||||
import { markdownToTiptap } from '../utils/tiptapFromMarkdown'
|
import { markdownToTiptap } from '../utils/tiptapFromMarkdown'
|
||||||
import { isSafeHttpUrl as isSafeUrl } from '../utils/urlValidation'
|
import { isSafeHttpUrl as isSafeUrl } from '../utils/urlValidation'
|
||||||
|
import { loadCategoryTree, pageUrlPath } from '../database/categoryTree'
|
||||||
|
|
||||||
function err(statusCode: number, message: string, extra?: Record<string, unknown>): H3Error {
|
function err(statusCode: number, message: string, extra?: Record<string, unknown>): H3Error {
|
||||||
return Object.assign(new Error(message), {
|
return Object.assign(new Error(message), {
|
||||||
|
|
@ -44,11 +45,12 @@ export interface UpsertTranslationInput {
|
||||||
|
|
||||||
export interface UpsertTranslationResult {
|
export interface UpsertTranslationResult {
|
||||||
translation: PageTranslation
|
translation: PageTranslation
|
||||||
|
// Full public URL path (no leading slash), e.g. "resources/adoption/overview".
|
||||||
|
// Cache purge keys are URL-based, so callers pass this through to
|
||||||
|
// `applyTranslationInvalidations` rather than the raw leaf slug.
|
||||||
|
pageUrl: string
|
||||||
pageSlug: string
|
pageSlug: string
|
||||||
categoryId: string | null
|
categoryId: string | null
|
||||||
// True iff the input came in as Markdown and the existing body was
|
|
||||||
// lossy-in-markdown but allow_lossy_overwrite was set — the caller
|
|
||||||
// should log an mcp.lossy_overwrite event with the dropped reasons.
|
|
||||||
lossyOverwriteApplied: boolean
|
lossyOverwriteApplied: boolean
|
||||||
droppedReasons: string[]
|
droppedReasons: string[]
|
||||||
// Sanitizer notes from tiptapValidate — attrs/marks/nodes that were
|
// Sanitizer notes from tiptapValidate — attrs/marks/nodes that were
|
||||||
|
|
@ -202,8 +204,10 @@ export async function upsertCmsPageTranslation(input: UpsertTranslationInput): P
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tree = await loadCategoryTree()
|
||||||
return {
|
return {
|
||||||
translation,
|
translation,
|
||||||
|
pageUrl: pageUrlPath(tree, page),
|
||||||
pageSlug: page.slug,
|
pageSlug: page.slug,
|
||||||
categoryId: page.category_id,
|
categoryId: page.category_id,
|
||||||
lossyOverwriteApplied,
|
lossyOverwriteApplied,
|
||||||
|
|
@ -226,7 +230,7 @@ export async function setCmsTranslationStatus(input: {
|
||||||
page_id: string
|
page_id: string
|
||||||
locale: string
|
locale: string
|
||||||
status: 'draft' | 'published'
|
status: 'draft' | 'published'
|
||||||
}): Promise<{ translation: PageTranslation; pageSlug: string; categoryId: string | null }> {
|
}): Promise<{ translation: PageTranslation; pageUrl: string; pageSlug: string; categoryId: string | null }> {
|
||||||
if (!ENABLED_LANGUAGE_CODES.includes(input.locale)) {
|
if (!ENABLED_LANGUAGE_CODES.includes(input.locale)) {
|
||||||
throw err(400, 'locale is not enabled')
|
throw err(400, 'locale is not enabled')
|
||||||
}
|
}
|
||||||
|
|
@ -248,11 +252,14 @@ export async function setCmsTranslationStatus(input: {
|
||||||
|
|
||||||
if (!updated) throw err(404, 'Translation not found')
|
if (!updated) throw err(404, 'Translation not found')
|
||||||
|
|
||||||
return { translation: updated, pageSlug: page.slug, categoryId: page.category_id }
|
const tree = await loadCategoryTree()
|
||||||
|
return { translation: updated, pageUrl: pageUrlPath(tree, page), pageSlug: page.slug, categoryId: page.category_id }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function applyTranslationInvalidations(slug: string, categoryId: string | null, locale?: string): Promise<void> {
|
// `url` is the full public URL path (no leading slash) — same string
|
||||||
await purgeCmsPage(slug, locale ? [locale] : undefined)
|
// the lookup endpoint uses as its cache key suffix.
|
||||||
if (categoryId) await purgeCmsCategory(categoryId, slug)
|
export async function applyTranslationInvalidations(url: string, categoryId: string | null, locale?: string): Promise<void> {
|
||||||
|
await purgeCmsPage(url, locale ? [locale] : undefined)
|
||||||
|
if (categoryId) await purgeCmsCategory(categoryId, url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,13 @@
|
||||||
|
|
||||||
import { ENABLED_LANGUAGE_CODES } from '~~/config/languages'
|
import { ENABLED_LANGUAGE_CODES } from '~~/config/languages'
|
||||||
import { db } from './database'
|
import { db } from './database'
|
||||||
|
import {
|
||||||
|
loadCategoryTree,
|
||||||
|
categoryChain,
|
||||||
|
descendantCategoryIds,
|
||||||
|
pageUrlPath,
|
||||||
|
categoryUrlPath
|
||||||
|
} from '../database/categoryTree'
|
||||||
|
|
||||||
// Key format matches what defineCachedEventHandler writes in
|
// Key format matches what defineCachedEventHandler writes in
|
||||||
// server/api/pages/[...slug].get.ts. Nitro composes
|
// server/api/pages/[...slug].get.ts. Nitro composes
|
||||||
|
|
@ -29,30 +36,49 @@ export async function purgeCmsPage(slug: string, locales?: string[]) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Purge every page in a category across all locales. Needed whenever a
|
// Purge every cached page across a category's whole top-level subtree,
|
||||||
// field that leaks into a sibling's cached response changes — titles,
|
// in all locales. Needed whenever a field that leaks into another cached
|
||||||
// excerpts, featured images (all embedded in `children[]`) and
|
// response changes — titles, excerpts, featured images (embedded in
|
||||||
// menu_order (alters sibling sort order). `excludeSlugs` lets the
|
// `children[]` and the landing grid), menu_order (sort order), or a
|
||||||
// caller skip pages whose entries were already purged independently
|
// page's published state (whether a category appears in the menu/grid at
|
||||||
// (avoids duplicate removeItem calls + lets `applyPageInvalidations`
|
// all). The sidebar nav tree and the landing-page grids span the entire
|
||||||
// pass the page-being-modified's slug — and its old slug, on a
|
// top-level subtree — every page under `/resources` shares one nav, and
|
||||||
// category move — through).
|
// ancestor landings list their descendants — so the invalidation has to
|
||||||
|
// reach from the top-level ancestor down, not just this category's own
|
||||||
|
// subtree.
|
||||||
|
//
|
||||||
|
// `excludeUrls` skips entries already purged by the caller (avoids
|
||||||
|
// duplicate removeItem calls when `applyPageInvalidations` already
|
||||||
|
// handled the page-being-modified's old + new URL).
|
||||||
export async function purgeCmsCategory(
|
export async function purgeCmsCategory(
|
||||||
categoryId: string,
|
categoryId: string,
|
||||||
excludeSlugs?: string | ReadonlyArray<string>
|
excludeUrls?: string | ReadonlyArray<string>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const skip = new Set<string>(
|
const skip = new Set<string>(
|
||||||
excludeSlugs === undefined
|
excludeUrls === undefined
|
||||||
? []
|
? []
|
||||||
: (typeof excludeSlugs === 'string' ? [excludeSlugs] : excludeSlugs)
|
: (typeof excludeUrls === 'string' ? [excludeUrls] : excludeUrls)
|
||||||
)
|
)
|
||||||
|
const tree = await loadCategoryTree()
|
||||||
|
const chain = categoryChain(tree, categoryId)
|
||||||
|
const rootId = chain[0]?.id ?? categoryId
|
||||||
|
const subtreeIds = descendantCategoryIds(tree, rootId)
|
||||||
|
if (subtreeIds.length === 0) return
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
.select('slug')
|
.select(['slug', 'category_id'])
|
||||||
.where('category_id', '=', categoryId)
|
.where('category_id', 'in', subtreeIds)
|
||||||
.execute()
|
.execute()
|
||||||
const slugs = rows.map(r => r.slug).filter(s => !skip.has(s))
|
const urls = new Set<string>()
|
||||||
await Promise.all(slugs.map(s => purgeCmsPage(s)))
|
for (const r of rows) urls.add(pageUrlPath(tree, r))
|
||||||
|
// Bare-category landings cache too (e.g. `/resources` resolves
|
||||||
|
// through getCategoryDefaultPage); purge them along with the pages.
|
||||||
|
for (const cid of subtreeIds) {
|
||||||
|
const path = categoryUrlPath(tree, cid)
|
||||||
|
if (path) urls.add(path)
|
||||||
|
}
|
||||||
|
const targets = [...urls].filter(u => !skip.has(u))
|
||||||
|
await Promise.all(targets.map(u => purgeCmsPage(u)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wipe every cached CMS page response. Used by the admin "Flush Cache"
|
// Wipe every cached CMS page response. Used by the admin "Flush Cache"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue