phase 5
This commit is contained in:
parent
d5e06c8c71
commit
b2975650ec
13 changed files with 638 additions and 30 deletions
150
app/pages/[...slug].vue
Normal file
150
app/pages/[...slug].vue
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<script setup lang="ts">
|
||||
// CMS catch-all renderer. Port of marketing-theme/page.php — the default
|
||||
// page template with optional sidebar child-page nav. Fetches the
|
||||
// rendered page from /api/pages/:slug and renders its title + body_html
|
||||
// + sidebar. Falls back to a child-cards grid if a parent page has no
|
||||
// body content (matches the `empty(trim(strip_tags(get_the_content())))`
|
||||
// branch in the PHP source).
|
||||
//
|
||||
// Nuxt's router will only mount this file after every more-specific
|
||||
// route (/, /research, /pray, /adopt, /adopt/[slug], /contact-us, /admin/*)
|
||||
// has failed to match — so the hardcoded pages keep their priority.
|
||||
|
||||
interface ChildPage {
|
||||
slug: string
|
||||
title: string
|
||||
excerpt: string | null
|
||||
featured_image: string | null
|
||||
menu_order: number
|
||||
}
|
||||
|
||||
interface PageResponse {
|
||||
slug: string
|
||||
parent_slug: string | null
|
||||
menu_order: number
|
||||
requested_locale: string
|
||||
resolved_locale: string
|
||||
title: string
|
||||
excerpt: string | null
|
||||
featured_image: string | null
|
||||
meta_title: string | null
|
||||
meta_description: string | null
|
||||
og_image: string | null
|
||||
body_html: string
|
||||
body_is_empty: boolean
|
||||
menu_parent: { slug: string; title: string }
|
||||
children: ChildPage[]
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const { locale } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
|
||||
const slug = computed(() => {
|
||||
const raw = route.params.slug
|
||||
const parts = Array.isArray(raw) ? raw : [raw]
|
||||
return parts.filter(Boolean).join('/')
|
||||
})
|
||||
|
||||
const { data, error } = await useAsyncData<PageResponse | null>(
|
||||
() => `cms-${slug.value}-${locale.value}`,
|
||||
async () => {
|
||||
if (!slug.value) return null
|
||||
try {
|
||||
return await $fetch<PageResponse>(`/api/pages/${slug.value}`, {
|
||||
query: { locale: locale.value }
|
||||
})
|
||||
} catch (e: any) {
|
||||
if (e?.statusCode === 404) return null
|
||||
throw e
|
||||
}
|
||||
},
|
||||
{ watch: [() => slug.value, () => locale.value] }
|
||||
)
|
||||
|
||||
if (!data.value && !error.value) {
|
||||
// Explicit 404 if the page isn't in the CMS (or not published)
|
||||
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
|
||||
}
|
||||
|
||||
// <head>
|
||||
useHead(() => {
|
||||
if (!data.value) return {}
|
||||
return {
|
||||
title: data.value.meta_title || data.value.title,
|
||||
meta: [
|
||||
...(data.value.meta_description ? [{ name: 'description', content: data.value.meta_description }] : []),
|
||||
...(data.value.og_image ? [{ property: 'og:image', content: data.value.og_image }] : [])
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const hasSidebar = computed(() => (data.value?.children.length ?? 0) > 0)
|
||||
const isParentSelf = computed(() => data.value?.menu_parent.slug === data.value?.slug)
|
||||
const showChildGrid = computed(() =>
|
||||
// Parent page with no body content → render children as cards
|
||||
Boolean(data.value && isParentSelf.value && data.value.body_is_empty && data.value.children.length > 0)
|
||||
)
|
||||
|
||||
useTextHighlight()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="data" class="container page-content">
|
||||
<div :class="hasSidebar ? 'with-sidebar' : ''">
|
||||
<aside v-if="hasSidebar" class="sidebar">
|
||||
<nav class="stack" aria-label="Child pages navigation">
|
||||
<div>
|
||||
<ul class="stack | max-width-xs" role="list">
|
||||
<li>
|
||||
<NuxtLink
|
||||
class="font-size-lg"
|
||||
:class="{ 'current-link': data.slug === data.menu_parent.slug }"
|
||||
:to="localePath(`/${data.menu_parent.slug}`)"
|
||||
>
|
||||
{{ data.menu_parent.title }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li v-for="child in data.children" :key="child.slug">
|
||||
<NuxtLink
|
||||
:class="{ 'current-link': data.slug === child.slug }"
|
||||
:to="localePath(`/${child.slug}`)"
|
||||
>
|
||||
{{ child.title }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<article :id="`page-${data.slug}`" class="page">
|
||||
<div v-if="data.featured_image" class="page-featured-image">
|
||||
<img :src="data.featured_image" :alt="data.title">
|
||||
</div>
|
||||
|
||||
<h1 v-if="!showChildGrid">{{ data.title }}</h1>
|
||||
|
||||
<div v-if="!showChildGrid" class="page-body" v-html="data.body_html" />
|
||||
|
||||
<div v-if="showChildGrid" class="grid">
|
||||
<div v-for="child in data.children" :key="child.slug" class="card" data-variant="secondary">
|
||||
<div v-if="child.featured_image" class="child-thumbnail">
|
||||
<NuxtLink :to="localePath(`/${child.slug}`)">
|
||||
<img :src="child.featured_image" :alt="child.title">
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="stack">
|
||||
<h3>
|
||||
<NuxtLink class="color-white" :to="localePath(`/${child.slug}`)">
|
||||
{{ child.title }}
|
||||
</NuxtLink>
|
||||
</h3>
|
||||
<div v-if="child.excerpt" class="child-excerpt" v-html="child.excerpt" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
<template>
|
||||
<section class="container stack stack--md">
|
||||
<h1>Definitions</h1>
|
||||
<p>This page is rendered by the CMS in Phase 5.</p>
|
||||
</section>
|
||||
</template>
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
<template>
|
||||
<section class="container stack stack--md">
|
||||
<h1>About</h1>
|
||||
<p>This page is rendered by the CMS in Phase 5.</p>
|
||||
</section>
|
||||
</template>
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
<template>
|
||||
<section class="container stack stack--md">
|
||||
<h1>Vision</h1>
|
||||
<p>This page is rendered by the CMS in Phase 5.</p>
|
||||
</section>
|
||||
</template>
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
<template>
|
||||
<section class="container stack stack--md">
|
||||
<h1>Privacy Policy</h1>
|
||||
<p>This page is rendered by the CMS in Phase 5.</p>
|
||||
</section>
|
||||
</template>
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
<template>
|
||||
<section class="container stack stack--md">
|
||||
<h1>Adoption Resources</h1>
|
||||
<p>Resources are rendered by the CMS in Phase 5.</p>
|
||||
</section>
|
||||
</template>
|
||||
39
bun.lock
39
bun.lock
|
|
@ -12,6 +12,19 @@
|
|||
"@nuxt/ui": "^4.6.1",
|
||||
"@nuxtjs/i18n": "^10.2.4",
|
||||
"@nuxtjs/turnstile": "^1.1.2",
|
||||
"@tiptap/core": "^3.22.4",
|
||||
"@tiptap/extension-color": "^3.22.4",
|
||||
"@tiptap/extension-highlight": "^3.22.4",
|
||||
"@tiptap/extension-image": "^3.22.4",
|
||||
"@tiptap/extension-link": "^3.22.4",
|
||||
"@tiptap/extension-subscript": "^3.22.4",
|
||||
"@tiptap/extension-superscript": "^3.22.4",
|
||||
"@tiptap/extension-text-align": "^3.22.4",
|
||||
"@tiptap/extension-text-style": "^3.22.4",
|
||||
"@tiptap/extension-typography": "^3.22.4",
|
||||
"@tiptap/extension-youtube": "^3.22.4",
|
||||
"@tiptap/html": "^3.22.4",
|
||||
"@tiptap/starter-kit": "^3.22.4",
|
||||
"bcrypt": "^6.0.0",
|
||||
"fuse.js": "^7.3.0",
|
||||
"intl-tel-input": "^26.7.5",
|
||||
|
|
@ -825,6 +838,8 @@
|
|||
|
||||
"@tiptap/extension-collaboration": ["@tiptap/extension-collaboration@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4", "@tiptap/pm": "3.22.4", "@tiptap/y-tiptap": "^3.0.2", "yjs": "^13" } }, "sha512-4g4DdpvXZyYYCcWs3cO4DwtzhukqI13waYUKfwOcNnQwZ4YOCR83ET/kgqMk/xMAfbymKghbyAZRCc33zD5xvw=="],
|
||||
|
||||
"@tiptap/extension-color": ["@tiptap/extension-color@3.22.4", "", { "peerDependencies": { "@tiptap/extension-text-style": "3.22.4" } }, "sha512-1vDuVsrOETshe4j4nZhWalbKYcWfNybRCe30h829ExX06XwFryUYLb/LgTIaGCr9beWZUldsK+vOkBWdDTGMTw=="],
|
||||
|
||||
"@tiptap/extension-document": ["@tiptap/extension-document@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-XQKla1+703FqQJC48tPDVgt9ucGiFbIEmQdOg5L5o07z9a6/NzuaZAc+1zJ7NxcUZzy+z6wBn1PrVMTiqiSXlw=="],
|
||||
|
||||
"@tiptap/extension-drag-handle": ["@tiptap/extension-drag-handle@3.22.4", "", { "dependencies": { "@floating-ui/dom": "^1.6.13" }, "peerDependencies": { "@tiptap/core": "3.22.4", "@tiptap/extension-collaboration": "3.22.4", "@tiptap/extension-node-range": "3.22.4", "@tiptap/pm": "3.22.4", "@tiptap/y-tiptap": "^3.0.2" } }, "sha512-VCheiqy0OGrcrxOYt7Kp7MXWMZoEaZp517HnRrdrvcCCO0j2hcrT5mbo1mMPzP/WvTUHkXCmqcLv+uIk3Bdo/g=="],
|
||||
|
|
@ -841,6 +856,8 @@
|
|||
|
||||
"@tiptap/extension-heading": ["@tiptap/extension-heading@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-TUaj5f0Ir5qy9HKKt2ocnwfXKpZDYeHgbbP9gshKFzdq5PLe1RbIgkjfy6bnoI865cYjmPYWRjcT7XsKyIcb9Q=="],
|
||||
|
||||
"@tiptap/extension-highlight": ["@tiptap/extension-highlight@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-QItbgkWqGZ04rOdYfjrn1TaUTv9XLKQE9VVJm9f6bxCpacPREJpz4lzkdea6zISHBZjEXJzlkjRWZP0zQLXXFA=="],
|
||||
|
||||
"@tiptap/extension-horizontal-rule": ["@tiptap/extension-horizontal-rule@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4", "@tiptap/pm": "3.22.4" } }, "sha512-cCI1HekGQwhY/MbgaKQ0R/7HcH5ZM1oFAyI/J72QGLC0XnF403S/OXoHMuBWr1mCu8hNiQWCzeNRJUty0iytNw=="],
|
||||
|
||||
"@tiptap/extension-image": ["@tiptap/extension-image@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-ZDc+fLaratTQ4IgnKcJJwfUgUgpcHjbZSBi6UQAILJwkflMy1Zxj8mpbma5P934nLSI+uDnR5ret6ZZLNITKhA=="],
|
||||
|
|
@ -867,12 +884,26 @@
|
|||
|
||||
"@tiptap/extension-strike": ["@tiptap/extension-strike@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-aRHWQj42HiailXSC9LkKYM3jWMcSeGwOjbqM4PiuxQZmHVDRFmeHkfJItOdn2cSHaO0vuEVK+TvrWUWsBFi3pg=="],
|
||||
|
||||
"@tiptap/extension-subscript": ["@tiptap/extension-subscript@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4", "@tiptap/pm": "3.22.4" } }, "sha512-95Uwi8jEYVr/qil7tGZhG87wR0SNIUBZXSFcDkDeFBzRdccofwD2XL2HU5FqWm2IPNuE21lQWPImVspXR0Kzyw=="],
|
||||
|
||||
"@tiptap/extension-superscript": ["@tiptap/extension-superscript@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4", "@tiptap/pm": "3.22.4" } }, "sha512-VGNR67uTmdTfRC5+1HqtAfA+kK6Lbh9tXitcIs8Scwxlq89PBOUEs11RnzcXfwv5hYq94WjgQJF3A99Jc9Cg2w=="],
|
||||
|
||||
"@tiptap/extension-text": ["@tiptap/extension-text@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-mM69uUW5cSxIhyEpWXi/YcfyupcJMDLCPEfYi62awH0iOP/LRoCv/nHjJq4Hyj/KxRJbe8HKwIUnqaCUf7m5Pg=="],
|
||||
|
||||
"@tiptap/extension-text-align": ["@tiptap/extension-text-align@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-W7TnXWSyfDXSatGXp5y/CahE8G4btrQPb0/sy+eG+42FxdzYsqvh1ys3OE9j2XSuTrZ1q/tZA/NLPUkc7vw6Kw=="],
|
||||
|
||||
"@tiptap/extension-text-style": ["@tiptap/extension-text-style@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-24DVBdySNKq3ovY+v9ERVxAyHStDa6ftUlyoHuZv0YXQ2amjUNOmqQtGEHBIULpCbBb1jZ+atHhv9MBZ0Ia9Pw=="],
|
||||
|
||||
"@tiptap/extension-typography": ["@tiptap/extension-typography@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-LXcJbhLl7m8jQ+VFj8u6T5p8ylZLv8WUUZQal7JF4KgDLBhPGiS0O2HXXzcRfjuVzT+erqakwybnS+0bkTGcsQ=="],
|
||||
|
||||
"@tiptap/extension-underline": ["@tiptap/extension-underline@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-08kGdbhIrA6h10GWXqOkqIveaBj5tmxclK208/nUIAlonI9hPd739vu7fmVtpnmqCnSSNpoRtU4u6Gj5at0ZpA=="],
|
||||
|
||||
"@tiptap/extension-youtube": ["@tiptap/extension-youtube@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4" } }, "sha512-H5dKDEdVhGeE2JZ4z6AvurN9GNwZQBNige8wjUie3R+hkwX/0DDql0oqvNM8lO5WM1M90Sq/AwyPQ2K9auSbbw=="],
|
||||
|
||||
"@tiptap/extensions": ["@tiptap/extensions@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4", "@tiptap/pm": "3.22.4" } }, "sha512-fOe8VptJvLPs32bNdUYo8SRyljwqKNQVXWW056VoXIc5en/59OdJlJQVeHI0jRRciH3MtrqODi/gfJR0VHNZ8A=="],
|
||||
|
||||
"@tiptap/html": ["@tiptap/html@3.22.4", "", { "peerDependencies": { "@tiptap/core": "3.22.4", "@tiptap/pm": "3.22.4", "happy-dom": "^20.8.9" } }, "sha512-Y0ydX5ghuypurDRzU5YEVAXKJH2SWar7J7bDNslWHuXSmMnu2CBxFViGf7RKWw0BDMPv33wkuMxzkw1n0gOpgg=="],
|
||||
|
||||
"@tiptap/markdown": ["@tiptap/markdown@3.22.4", "", { "dependencies": { "marked": "^17.0.1" }, "peerDependencies": { "@tiptap/core": "3.22.4", "@tiptap/pm": "3.22.4" } }, "sha512-gcoLOq5TBntw13QdeWMy7yc2X+b9yTplcFb5kpMQNgNoxlu0+jlX4LiBR+E6lOV+m+AtkprHHltTYyKG23bdOw=="],
|
||||
|
||||
"@tiptap/pm": ["@tiptap/pm@3.22.4", "", { "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-commands": "^1.6.2", "prosemirror-dropcursor": "^1.8.1", "prosemirror-gapcursor": "^1.3.2", "prosemirror-history": "^1.4.1", "prosemirror-keymap": "^1.2.2", "prosemirror-model": "^1.24.1", "prosemirror-schema-list": "^1.5.0", "prosemirror-state": "^1.4.3", "prosemirror-tables": "^1.6.4", "prosemirror-transform": "^1.10.2", "prosemirror-view": "^1.38.1" } }, "sha512-hj8Qka6WcHRllHUdeSjDnq2XaisUo4KsoGJc1WcFpoa1Yd+OeD861zUMnV7DFVGdZRy45Obht0CUYJpXQ4yA4w=="],
|
||||
|
|
@ -919,6 +950,10 @@
|
|||
|
||||
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
|
||||
|
||||
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/type-utils": "8.59.0", "@typescript-eslint/utils": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg=="],
|
||||
|
|
@ -1499,6 +1534,8 @@
|
|||
|
||||
"h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="],
|
||||
|
||||
"happy-dom": ["happy-dom@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||
|
|
@ -2273,6 +2310,8 @@
|
|||
|
||||
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
||||
|
||||
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
|
||||
"wheel-gestures": ["wheel-gestures@2.2.48", "", {}, "sha512-f+Gy33Oa5Z14XY9679Zze+7VFhbsQfBFXodnU2x589l4kxGM9L5Y8zETTmcMR5pWOPQyRv4Z0lNax6xCO0NSlA=="],
|
||||
|
|
|
|||
58
migrations/005_create_pages_table.ts
Normal file
58
migrations/005_create_pages_table.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { Kysely, sql } from 'kysely'
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('pages')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'uuid', col => col.primaryKey().defaultTo(sql`gen_random_uuid()`))
|
||||
.addColumn('slug', 'text', col => col.unique().notNull())
|
||||
.addColumn('parent_slug', 'text')
|
||||
.addColumn('menu_order', 'integer', col => col.notNull().defaultTo(0))
|
||||
.addColumn('created', 'timestamptz', col => col.notNull().defaultTo(sql`now()`))
|
||||
.addColumn('updated', 'timestamptz', col => col.notNull().defaultTo(sql`now()`))
|
||||
.execute()
|
||||
|
||||
await db.schema
|
||||
.createIndex('pages_parent_slug_idx')
|
||||
.ifNotExists()
|
||||
.on('pages')
|
||||
.column('parent_slug')
|
||||
.execute()
|
||||
|
||||
await db.schema
|
||||
.createTable('page_translations')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'uuid', col => col.primaryKey().defaultTo(sql`gen_random_uuid()`))
|
||||
.addColumn('page_id', 'uuid', col => col.notNull().references('pages.id').onDelete('cascade'))
|
||||
.addColumn('locale', 'text', col => col.notNull())
|
||||
.addColumn('title', 'text', col => col.notNull())
|
||||
.addColumn('body_json', 'jsonb', col => col.notNull().defaultTo(sql`'{}'::jsonb`))
|
||||
.addColumn('excerpt', 'text')
|
||||
.addColumn('featured_image', 'text')
|
||||
.addColumn('meta_title', 'text')
|
||||
.addColumn('meta_description', 'text')
|
||||
.addColumn('og_image', 'text')
|
||||
.addColumn('status', 'text', col => col.notNull().defaultTo('draft'))
|
||||
.addColumn('updated', 'timestamptz', col => col.notNull().defaultTo(sql`now()`))
|
||||
.execute()
|
||||
|
||||
await db.schema
|
||||
.createIndex('page_translations_page_locale_uidx')
|
||||
.ifNotExists()
|
||||
.on('page_translations')
|
||||
.columns(['page_id', 'locale'])
|
||||
.unique()
|
||||
.execute()
|
||||
|
||||
await db.schema
|
||||
.createIndex('page_translations_status_idx')
|
||||
.ifNotExists()
|
||||
.on('page_translations')
|
||||
.column('status')
|
||||
.execute()
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('page_translations').ifExists().execute()
|
||||
await db.schema.dropTable('pages').ifExists().execute()
|
||||
}
|
||||
13
package.json
13
package.json
|
|
@ -18,6 +18,19 @@
|
|||
"@nuxt/ui": "^4.6.1",
|
||||
"@nuxtjs/i18n": "^10.2.4",
|
||||
"@nuxtjs/turnstile": "^1.1.2",
|
||||
"@tiptap/core": "^3.22.4",
|
||||
"@tiptap/extension-color": "^3.22.4",
|
||||
"@tiptap/extension-highlight": "^3.22.4",
|
||||
"@tiptap/extension-image": "^3.22.4",
|
||||
"@tiptap/extension-link": "^3.22.4",
|
||||
"@tiptap/extension-subscript": "^3.22.4",
|
||||
"@tiptap/extension-superscript": "^3.22.4",
|
||||
"@tiptap/extension-text-align": "^3.22.4",
|
||||
"@tiptap/extension-text-style": "^3.22.4",
|
||||
"@tiptap/extension-typography": "^3.22.4",
|
||||
"@tiptap/extension-youtube": "^3.22.4",
|
||||
"@tiptap/html": "^3.22.4",
|
||||
"@tiptap/starter-kit": "^3.22.4",
|
||||
"bcrypt": "^6.0.0",
|
||||
"fuse.js": "^7.3.0",
|
||||
"intl-tel-input": "^26.7.5",
|
||||
|
|
|
|||
86
server/api/pages/[...slug].get.ts
Normal file
86
server/api/pages/[...slug].get.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// Public CMS page read endpoint. Mirrors the plan:
|
||||
// - picks the translation for the requested locale
|
||||
// - falls back to English if that locale isn't published
|
||||
// - 404s if neither are published
|
||||
//
|
||||
// Returns the translation (title, excerpt, featured_image, meta_*,
|
||||
// status) plus pre-rendered HTML body (so the public bundle doesn't
|
||||
// have to ship the Tiptap renderer) and the list of published child
|
||||
// pages for sidebar navigation.
|
||||
|
||||
import { defineEventHandler, getRouterParam, getQuery, createError } from 'h3'
|
||||
import { getPageBySlug, getChildTranslations } from '../../database/pages'
|
||||
import { renderTiptap } from '../../utils/renderTiptap'
|
||||
|
||||
const ENABLED_LOCALES = new Set(['en', 'es', 'fr', 'pt', 'ar', 'ru'])
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const raw = getRouterParam(event, 'slug')
|
||||
const slugParam = Array.isArray(raw) ? raw.join('/') : (raw ?? '')
|
||||
const slug = slugParam.replace(/^\/+|\/+$/g, '')
|
||||
if (!slug) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing slug' })
|
||||
}
|
||||
|
||||
const query = getQuery(event)
|
||||
const requested = typeof query.locale === 'string' ? query.locale : 'en'
|
||||
const locale = ENABLED_LOCALES.has(requested) ? requested : 'en'
|
||||
|
||||
const result = await getPageBySlug(slug, locale, { fallback: 'en' })
|
||||
if (!result) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Page not found' })
|
||||
}
|
||||
|
||||
const { page, translation, resolvedLocale, requestedLocale } = result
|
||||
const bodyHtml = renderTiptap(translation.body_json)
|
||||
|
||||
// Menu parent = parent page if this is a child, or self if this is a
|
||||
// top-level page. Sidebar items = children of that menu parent.
|
||||
// Mirrors the WP page.php logic using `wp_get_post_parent_id()` and
|
||||
// `get_pages(['child_of' => $menu_parent_id])`.
|
||||
let menuParentSlug: string
|
||||
let menuParentTitle: string
|
||||
if (page.parent_slug) {
|
||||
const parent = await getPageBySlug(page.parent_slug, resolvedLocale, { fallback: 'en' })
|
||||
if (parent) {
|
||||
menuParentSlug = parent.page.slug
|
||||
menuParentTitle = parent.translation.title
|
||||
} else {
|
||||
// Parent row exists but isn't published — fall back to self
|
||||
menuParentSlug = page.slug
|
||||
menuParentTitle = translation.title
|
||||
}
|
||||
} else {
|
||||
menuParentSlug = page.slug
|
||||
menuParentTitle = translation.title
|
||||
}
|
||||
|
||||
const siblings = await getChildTranslations(menuParentSlug, resolvedLocale, 'en')
|
||||
|
||||
return {
|
||||
slug: page.slug,
|
||||
parent_slug: page.parent_slug,
|
||||
menu_order: page.menu_order,
|
||||
requested_locale: requestedLocale,
|
||||
resolved_locale: resolvedLocale,
|
||||
title: translation.title,
|
||||
excerpt: translation.excerpt,
|
||||
featured_image: translation.featured_image,
|
||||
meta_title: translation.meta_title,
|
||||
meta_description: translation.meta_description,
|
||||
og_image: translation.og_image,
|
||||
body_html: bodyHtml,
|
||||
body_is_empty: bodyHtml.trim() === '',
|
||||
menu_parent: {
|
||||
slug: menuParentSlug,
|
||||
title: menuParentTitle
|
||||
},
|
||||
children: siblings.map(({ page: c, translation: t }) => ({
|
||||
slug: c.slug,
|
||||
title: t.title,
|
||||
excerpt: t.excerpt,
|
||||
featured_image: t.featured_image,
|
||||
menu_order: c.menu_order
|
||||
}))
|
||||
}
|
||||
})
|
||||
214
server/database/pages.ts
Normal file
214
server/database/pages.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
// Query helpers for the `pages` + `page_translations` tables (migration
|
||||
// 005). All functions use Kysely against the shared `db` instance.
|
||||
//
|
||||
// Lookup semantics (`getPageBySlug`) mirror the plan: prefer the
|
||||
// requested locale's published translation, fall back to English if
|
||||
// that locale is missing/draft, return null when neither is published.
|
||||
|
||||
import { db } from '../utils/database'
|
||||
import type { PagesTable, PageTranslationsTable } from './schema'
|
||||
import type { Selectable } from 'kysely'
|
||||
|
||||
export type Page = Selectable<PagesTable>
|
||||
export type PageTranslation = Selectable<PageTranslationsTable>
|
||||
|
||||
export interface LocalizedPage {
|
||||
page: Page
|
||||
translation: PageTranslation
|
||||
resolvedLocale: string // locale actually rendered (may differ from requested when fallback kicked in)
|
||||
requestedLocale: string
|
||||
}
|
||||
|
||||
export async function listPages(): Promise<Page[]> {
|
||||
return db
|
||||
.selectFrom('pages')
|
||||
.selectAll()
|
||||
.orderBy('parent_slug', 'asc')
|
||||
.orderBy('menu_order', 'asc')
|
||||
.orderBy('slug', 'asc')
|
||||
.execute()
|
||||
}
|
||||
|
||||
export async function getPageChildren(parentSlug: string): Promise<Page[]> {
|
||||
return db
|
||||
.selectFrom('pages')
|
||||
.selectAll()
|
||||
.where('parent_slug', '=', parentSlug)
|
||||
.orderBy('menu_order', 'asc')
|
||||
.orderBy('slug', 'asc')
|
||||
.execute()
|
||||
}
|
||||
|
||||
export async function getPageBySlug(
|
||||
slug: string,
|
||||
locale: string,
|
||||
options: { fallback?: string } = {}
|
||||
): Promise<LocalizedPage | null> {
|
||||
const fallbackLocale = options.fallback ?? 'en'
|
||||
|
||||
const page = await db
|
||||
.selectFrom('pages')
|
||||
.selectAll()
|
||||
.where('slug', '=', slug)
|
||||
.executeTakeFirst()
|
||||
if (!page) return null
|
||||
|
||||
const translation = await db
|
||||
.selectFrom('page_translations')
|
||||
.selectAll()
|
||||
.where('page_id', '=', page.id)
|
||||
.where('locale', '=', locale)
|
||||
.where('status', '=', 'published')
|
||||
.executeTakeFirst()
|
||||
|
||||
if (translation) {
|
||||
return { page, translation, resolvedLocale: locale, requestedLocale: locale }
|
||||
}
|
||||
|
||||
// Fall back to published English if the requested locale isn't published
|
||||
if (locale !== fallbackLocale) {
|
||||
const fallback = await db
|
||||
.selectFrom('page_translations')
|
||||
.selectAll()
|
||||
.where('page_id', '=', page.id)
|
||||
.where('locale', '=', fallbackLocale)
|
||||
.where('status', '=', 'published')
|
||||
.executeTakeFirst()
|
||||
if (fallback) {
|
||||
return { page, translation: fallback, resolvedLocale: fallbackLocale, requestedLocale: locale }
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function getChildTranslations(
|
||||
parentSlug: string,
|
||||
locale: string,
|
||||
fallbackLocale = 'en'
|
||||
): Promise<Array<{ page: Page; translation: PageTranslation }>> {
|
||||
const children = await getPageChildren(parentSlug)
|
||||
if (children.length === 0) return []
|
||||
const ids = children.map(c => c.id)
|
||||
|
||||
const preferred = await db
|
||||
.selectFrom('page_translations')
|
||||
.selectAll()
|
||||
.where('page_id', 'in', ids)
|
||||
.where('locale', '=', locale)
|
||||
.where('status', '=', 'published')
|
||||
.execute()
|
||||
const haveLocale = new Set(preferred.map(p => p.page_id))
|
||||
|
||||
const missing = ids.filter(id => !haveLocale.has(id))
|
||||
let fallbacks: PageTranslation[] = []
|
||||
if (missing.length && locale !== fallbackLocale) {
|
||||
fallbacks = await db
|
||||
.selectFrom('page_translations')
|
||||
.selectAll()
|
||||
.where('page_id', 'in', missing)
|
||||
.where('locale', '=', fallbackLocale)
|
||||
.where('status', '=', 'published')
|
||||
.execute()
|
||||
}
|
||||
|
||||
const byPageId = new Map<string, PageTranslation>()
|
||||
for (const t of preferred) byPageId.set(t.page_id, t)
|
||||
for (const t of fallbacks) byPageId.set(t.page_id, t)
|
||||
|
||||
return children
|
||||
.map(page => ({ page, translation: byPageId.get(page.id)! }))
|
||||
.filter(entry => Boolean(entry.translation))
|
||||
}
|
||||
|
||||
export async function createPage(input: {
|
||||
slug: string
|
||||
parent_slug?: string | null
|
||||
menu_order?: number
|
||||
}): Promise<Page> {
|
||||
const row = await db
|
||||
.insertInto('pages')
|
||||
.values({
|
||||
slug: input.slug,
|
||||
parent_slug: input.parent_slug ?? null,
|
||||
menu_order: input.menu_order ?? 0
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow()
|
||||
return row
|
||||
}
|
||||
|
||||
export async function deletePage(id: string): Promise<void> {
|
||||
await db.deleteFrom('pages').where('id', '=', id).execute()
|
||||
}
|
||||
|
||||
export async function upsertTranslation(input: {
|
||||
page_id: string
|
||||
locale: string
|
||||
title: string
|
||||
body_json: Record<string, any>
|
||||
excerpt?: string | null
|
||||
featured_image?: string | null
|
||||
meta_title?: string | null
|
||||
meta_description?: string | null
|
||||
og_image?: string | null
|
||||
status?: 'draft' | 'published'
|
||||
}): Promise<PageTranslation> {
|
||||
const existing = await db
|
||||
.selectFrom('page_translations')
|
||||
.selectAll()
|
||||
.where('page_id', '=', input.page_id)
|
||||
.where('locale', '=', input.locale)
|
||||
.executeTakeFirst()
|
||||
|
||||
if (existing) {
|
||||
const updated = await db
|
||||
.updateTable('page_translations')
|
||||
.set({
|
||||
title: input.title,
|
||||
body_json: input.body_json,
|
||||
excerpt: input.excerpt ?? null,
|
||||
featured_image: input.featured_image ?? null,
|
||||
meta_title: input.meta_title ?? null,
|
||||
meta_description: input.meta_description ?? null,
|
||||
og_image: input.og_image ?? null,
|
||||
...(input.status ? { status: input.status } : {}),
|
||||
updated: new Date()
|
||||
})
|
||||
.where('id', '=', existing.id)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow()
|
||||
return updated
|
||||
}
|
||||
|
||||
const inserted = await db
|
||||
.insertInto('page_translations')
|
||||
.values({
|
||||
page_id: input.page_id,
|
||||
locale: input.locale,
|
||||
title: input.title,
|
||||
body_json: input.body_json,
|
||||
excerpt: input.excerpt ?? null,
|
||||
featured_image: input.featured_image ?? null,
|
||||
meta_title: input.meta_title ?? null,
|
||||
meta_description: input.meta_description ?? null,
|
||||
og_image: input.og_image ?? null,
|
||||
status: input.status ?? 'draft'
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow()
|
||||
return inserted
|
||||
}
|
||||
|
||||
export async function setTranslationStatus(
|
||||
pageId: string,
|
||||
locale: string,
|
||||
status: 'draft' | 'published'
|
||||
): Promise<void> {
|
||||
await db
|
||||
.updateTable('page_translations')
|
||||
.set({ status, updated: new Date() })
|
||||
.where('page_id', '=', pageId)
|
||||
.where('locale', '=', locale)
|
||||
.execute()
|
||||
}
|
||||
|
|
@ -35,8 +35,34 @@ export interface ActivityLogsTable {
|
|||
metadata: Generated<Record<string, any>>
|
||||
}
|
||||
|
||||
export interface PagesTable {
|
||||
id: Generated<string>
|
||||
slug: string
|
||||
parent_slug: string | null
|
||||
menu_order: Generated<number>
|
||||
created: ColumnType<Date, Date | string | undefined, Date | string>
|
||||
updated: ColumnType<Date, Date | string | undefined, Date | string>
|
||||
}
|
||||
|
||||
export interface PageTranslationsTable {
|
||||
id: Generated<string>
|
||||
page_id: string
|
||||
locale: string
|
||||
title: string
|
||||
body_json: Generated<Record<string, any>>
|
||||
excerpt: string | null
|
||||
featured_image: string | null
|
||||
meta_title: string | null
|
||||
meta_description: string | null
|
||||
og_image: string | null
|
||||
status: Generated<'draft' | 'published'>
|
||||
updated: ColumnType<Date, Date | string | undefined, Date | string>
|
||||
}
|
||||
|
||||
export interface Database {
|
||||
users: UsersTable
|
||||
password_reset_requests: PasswordResetRequestsTable
|
||||
activity_logs: ActivityLogsTable
|
||||
pages: PagesTable
|
||||
page_translations: PageTranslationsTable
|
||||
}
|
||||
|
|
|
|||
52
server/utils/renderTiptap.ts
Normal file
52
server/utils/renderTiptap.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// Server-side Tiptap JSON → HTML renderer. The extension set here must
|
||||
// stay aligned with app/components/admin/RichTextEditor.vue (built in
|
||||
// Phase 6) so content authored in the editor round-trips back out to
|
||||
// the public page with the same tags/attributes.
|
||||
//
|
||||
// Keeping this on the server keeps the public bundle free of Tiptap
|
||||
// runtime code — the renderer serializes once and we send HTML.
|
||||
|
||||
import { generateHTML } from '@tiptap/html'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import Image from '@tiptap/extension-image'
|
||||
import TextAlign from '@tiptap/extension-text-align'
|
||||
import { TextStyle } from '@tiptap/extension-text-style'
|
||||
import Color from '@tiptap/extension-color'
|
||||
import Highlight from '@tiptap/extension-highlight'
|
||||
import Typography from '@tiptap/extension-typography'
|
||||
import Subscript from '@tiptap/extension-subscript'
|
||||
import Superscript from '@tiptap/extension-superscript'
|
||||
import Youtube from '@tiptap/extension-youtube'
|
||||
|
||||
const EXTENSIONS = [
|
||||
StarterKit,
|
||||
Link.configure({ openOnClick: false, autolink: true }),
|
||||
Image,
|
||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||
TextStyle,
|
||||
Color,
|
||||
Highlight,
|
||||
Typography,
|
||||
Subscript,
|
||||
Superscript,
|
||||
Youtube
|
||||
]
|
||||
|
||||
function isEmptyDoc(doc: unknown): boolean {
|
||||
if (!doc || typeof doc !== 'object') return true
|
||||
const d = doc as { type?: string; content?: unknown[] }
|
||||
if (d.type !== 'doc') return true
|
||||
if (!Array.isArray(d.content) || d.content.length === 0) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function renderTiptap(doc: unknown): string {
|
||||
if (isEmptyDoc(doc)) return ''
|
||||
try {
|
||||
return generateHTML(doc as any, EXTENSIONS)
|
||||
} catch (err) {
|
||||
console.error('renderTiptap: failed to serialize doc', err)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue