Compare commits

...

3 Commits

18 changed files with 1065 additions and 175 deletions

View File

@@ -171,7 +171,7 @@ This file is the single source of truth for roadmap and delivery progress.
- [ ] [P1] Media entity rendering with enrichment data
- [ ] [P1] Portfolio views (gallery/album/category/tag) for artworks with filter and sort controls
- [ ] [P1] Rendition-aware media delivery (thumbnail/card/full) per template slot
- [ ] [P1] Translation-ready content model for public entities (pages/news/navigation labels)
- [~] [P1] Translation-ready content model for public entities (pages/news/navigation labels)
- [ ] [P2] Artwork views and listing filters
- [ ] [P1] Commission request submission flow
- [x] [P1] Header banner render logic and fallbacks
@@ -190,7 +190,7 @@ This file is the single source of truth for roadmap and delivery progress.
- [x] [P1] Component tests for admin forms (pages/media/navigation)
- [x] [P1] Integration tests for owner invariant and hidden support-user protection
- [x] [P1] Integration tests for registration allow/deny behavior
- [ ] [P1] Integration tests for translated content CRUD and locale-specific validation
- [x] [P1] Integration tests for translated content CRUD and locale-specific validation
- [~] [P1] E2E happy paths: create page, publish, see on public app
- [~] [P1] E2E happy paths: media upload + artwork refinement display
- [~] [P1] E2E happy paths: commissions kanban transitions
@@ -321,6 +321,8 @@ This file is the single source of truth for roadmap and delivery progress.
- [2026-02-12] Started admin form component tests with media upload behavior coverage (`apps/admin/src/components/media/media-upload-form.test.tsx`).
- [2026-02-12] Added code handover documentation baseline: architecture map, critical invariants, request lifecycles, and onboarding playbook under `docs/product-engineering/`.
- [2026-02-12] Completed admin form component coverage for pages/navigation/media using isolated form components and tests.
- [2026-02-12] Added page translation CRUD baseline (`PageTranslation`) with locale validation (`de/en/es/fr`) and integration coverage for localized read + fallback behavior.
- [2026-02-12] Page editor now supports locale translations in `/pages/:id`; public page rendering uses locale-aware page lookup with base-content fallback.
## How We Use This File

View File

@@ -5,6 +5,7 @@ import {
listNavigationMenus,
listPages,
updateNavigationItem,
upsertNavigationItemTranslation,
} from "@cms/db"
import { Button } from "@cms/ui/button"
import { revalidatePath } from "next/cache"
@@ -18,6 +19,9 @@ import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic"
type SearchParamsInput = Record<string, string | string[] | undefined>
const SUPPORTED_LOCALES = ["de", "en", "es", "fr"] as const
type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]
function readFirstValue(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) {
@@ -53,6 +57,14 @@ function readInt(formData: FormData, field: string, fallback = 0): number {
return parsed
}
function normalizeLocale(input: string | null): SupportedLocale {
if (input && SUPPORTED_LOCALES.includes(input as SupportedLocale)) {
return input as SupportedLocale
}
return "en"
}
function redirectWithState(params: { notice?: string; error?: string }) {
const query = new URLSearchParams()
@@ -165,6 +177,31 @@ async function deleteItemAction(formData: FormData) {
redirectWithState({ notice: "Navigation item deleted." })
}
async function upsertItemTranslationAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/navigation",
permission: "navigation:write",
scope: "team",
})
const locale = normalizeLocale(readInputString(formData, "locale"))
try {
await upsertNavigationItemTranslation({
navigationItemId: readInputString(formData, "navigationItemId"),
locale,
label: readInputString(formData, "label"),
})
} catch {
redirectWithState({ error: "Failed to save item translation." })
}
revalidatePath("/navigation")
redirectWithState({ notice: "Navigation item translation saved." })
}
export default async function NavigationManagementPage({
searchParams,
}: {
@@ -184,6 +221,7 @@ export default async function NavigationManagementPage({
const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error)
const selectedLocale = normalizeLocale(readFirstValue(resolvedSearchParams.locale))
return (
<AdminShell
@@ -218,6 +256,22 @@ export default async function NavigationManagementPage({
</section>
<section className="space-y-4">
<div className="flex flex-wrap gap-2">
{SUPPORTED_LOCALES.map((locale) => (
<a
key={locale}
href={`/navigation?locale=${locale}`}
className={`inline-flex rounded border px-3 py-1.5 text-xs ${
selectedLocale === locale
? "border-neutral-800 bg-neutral-900 text-white"
: "border-neutral-300 text-neutral-700"
}`}
>
{locale.toUpperCase()}
</a>
))}
</div>
{menus.length === 0 ? (
<article className="rounded-xl border border-neutral-200 p-6 text-sm text-neutral-600">
No navigation menus yet.
@@ -238,94 +292,126 @@ export default async function NavigationManagementPage({
{menu.items.length === 0 ? (
<p className="text-sm text-neutral-600">No items in this menu.</p>
) : (
menu.items.map((item) => (
<form
key={item.id}
action={updateItemAction}
className="rounded-lg border border-neutral-200 p-3"
>
<input type="hidden" name="id" value={item.id} />
<div className="grid gap-3 md:grid-cols-5">
<label className="space-y-1 md:col-span-2">
<span className="text-xs text-neutral-600">Label</span>
<input
name="label"
defaultValue={item.label}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1 md:col-span-2">
<span className="text-xs text-neutral-600">Href</span>
<input
name="href"
defaultValue={item.href ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Sort</span>
<input
name="sortOrder"
type="number"
min={0}
defaultValue={item.sortOrder}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
menu.items.map((item) => {
const translation = item.translations.find(
(entry) => entry.locale === selectedLocale,
)
<div className="mt-3 grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Linked page</span>
<select
name="pageId"
defaultValue={item.pageId ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="">(none)</option>
{pages.map((page) => (
<option key={page.id} value={page.id}>
{page.title} (/{page.slug})
</option>
))}
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Parent id</span>
<input
name="parentId"
defaultValue={item.parentId ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
return (
<div key={item.id} className="rounded-lg border border-neutral-200 p-3">
<form action={updateItemAction}>
<input type="hidden" name="id" value={item.id} />
<div className="grid gap-3 md:grid-cols-5">
<label className="space-y-1 md:col-span-2">
<span className="text-xs text-neutral-600">Label</span>
<input
name="label"
defaultValue={item.label}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1 md:col-span-2">
<span className="text-xs text-neutral-600">Href</span>
<input
name="href"
defaultValue={item.href ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Sort</span>
<input
name="sortOrder"
type="number"
min={0}
defaultValue={item.sortOrder}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
<input
type="checkbox"
name="isVisible"
value="true"
defaultChecked={item.isVisible}
className="size-4"
/>
Visible
</label>
<div className="flex items-center gap-2">
<Button type="submit" size="sm">
Save item
</Button>
<button
type="submit"
formAction={deleteItemAction}
className="rounded-md border border-red-300 px-3 py-2 text-sm text-red-700"
>
Delete
</button>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Linked page</span>
<select
name="pageId"
defaultValue={item.pageId ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="">(none)</option>
{pages.map((page) => (
<option key={page.id} value={page.id}>
{page.title} (/{page.slug})
</option>
))}
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Parent id</span>
<input
name="parentId"
defaultValue={item.parentId ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
<input
type="checkbox"
name="isVisible"
value="true"
defaultChecked={item.isVisible}
className="size-4"
/>
Visible
</label>
<div className="flex items-center gap-2">
<Button type="submit" size="sm">
Save item
</Button>
<button
type="submit"
formAction={deleteItemAction}
className="rounded-md border border-red-300 px-3 py-2 text-sm text-red-700"
>
Delete
</button>
</div>
</div>
</form>
<form
action={upsertItemTranslationAction}
className="mt-3 rounded border border-neutral-200 p-3"
>
<input type="hidden" name="navigationItemId" value={item.id} />
<input type="hidden" name="locale" value={selectedLocale} />
<p className="text-xs text-neutral-600">
Translation ({selectedLocale.toUpperCase()}) - saved locales:{" "}
{item.translations.length > 0
? item.translations
.map((entry) => entry.locale.toUpperCase())
.join(", ")
: "none"}
</p>
<div className="mt-2 flex gap-2">
<input
name="label"
defaultValue={translation?.label ?? item.label}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
<Button type="submit" size="sm" variant="secondary">
Save translation
</Button>
</div>
</form>
</div>
</form>
))
)
})
)}
</div>
</article>

View File

@@ -1,4 +1,10 @@
import { createPost, deletePost, listPosts, updatePost } from "@cms/db"
import {
createPost,
deletePost,
listPostsWithTranslations,
updatePost,
upsertPostTranslation,
} from "@cms/db"
import { Button } from "@cms/ui/button"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
@@ -9,6 +15,9 @@ import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic"
type SearchParamsInput = Record<string, string | string[] | undefined>
const SUPPORTED_LOCALES = ["de", "en", "es", "fr"] as const
type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]
function readFirstValue(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) {
@@ -28,6 +37,14 @@ function readNullableString(formData: FormData, field: string): string | undefin
return value.length > 0 ? value : undefined
}
function normalizeLocale(input: string | null): SupportedLocale {
if (input && SUPPORTED_LOCALES.includes(input as SupportedLocale)) {
return input as SupportedLocale
}
return "en"
}
function redirectWithState(params: { notice?: string; error?: string }) {
const query = new URLSearchParams()
@@ -115,6 +132,34 @@ async function deleteNewsAction(formData: FormData) {
redirectWithState({ notice: "Post deleted." })
}
async function upsertNewsTranslationAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/news",
permission: "news:write",
scope: "team",
})
const locale = normalizeLocale(readInputString(formData, "locale"))
try {
await upsertPostTranslation({
postId: readInputString(formData, "postId"),
locale,
title: readInputString(formData, "title"),
excerpt: readNullableString(formData, "excerpt") ?? null,
body: readInputString(formData, "body"),
})
} catch {
redirectWithState({ error: "Failed to save translation." })
}
revalidatePath("/news")
revalidatePath("/")
redirectWithState({ notice: "Post translation saved." })
}
export default async function NewsManagementPage({
searchParams,
}: {
@@ -126,10 +171,14 @@ export default async function NewsManagementPage({
scope: "team",
})
const [resolvedSearchParams, posts] = await Promise.all([searchParams, listPosts()])
const [resolvedSearchParams, posts] = await Promise.all([
searchParams,
listPostsWithTranslations(),
])
const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error)
const selectedLocale = normalizeLocale(readFirstValue(resolvedSearchParams.locale))
return (
<AdminShell
@@ -204,72 +253,146 @@ export default async function NewsManagementPage({
</section>
<section className="space-y-3">
{posts.map((post) => (
<form
key={post.id}
action={updateNewsAction}
className="rounded-xl border border-neutral-200 p-6"
>
<input type="hidden" name="id" value={post.id} />
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Title</span>
<input
name="title"
defaultValue={post.title}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Slug</span>
<input
name="slug"
defaultValue={post.slug}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<label className="mt-3 block space-y-1">
<span className="text-xs text-neutral-600">Excerpt</span>
<input
name="excerpt"
defaultValue={post.excerpt ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="mt-3 block space-y-1">
<span className="text-xs text-neutral-600">Body</span>
<textarea
name="body"
rows={4}
defaultValue={post.body}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
<select
name="status"
defaultValue={post.status}
className="rounded border border-neutral-300 px-3 py-2 text-sm"
<div className="flex flex-wrap gap-2">
{SUPPORTED_LOCALES.map((locale) => (
<a
key={locale}
href={`/news?locale=${locale}`}
className={`inline-flex rounded border px-3 py-1.5 text-xs ${
selectedLocale === locale
? "border-neutral-800 bg-neutral-900 text-white"
: "border-neutral-300 text-neutral-700"
}`}
>
{locale.toUpperCase()}
</a>
))}
</div>
{posts.map((post) => {
const translation = post.translations.find((entry) => entry.locale === selectedLocale)
return (
<div key={post.id} className="rounded-xl border border-neutral-200 p-6">
<form action={updateNewsAction}>
<input type="hidden" name="id" value={post.id} />
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Title</span>
<input
name="title"
defaultValue={post.title}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Slug</span>
<input
name="slug"
defaultValue={post.slug}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<label className="mt-3 block space-y-1">
<span className="text-xs text-neutral-600">Excerpt</span>
<input
name="excerpt"
defaultValue={post.excerpt ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="mt-3 block space-y-1">
<span className="text-xs text-neutral-600">Body</span>
<textarea
name="body"
rows={4}
defaultValue={post.body}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
<select
name="status"
defaultValue={post.status}
className="rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="draft">draft</option>
<option value="published">published</option>
</select>
<div className="flex items-center gap-2">
<Button type="submit" size="sm">
Save
</Button>
<button
type="submit"
formAction={deleteNewsAction}
className="rounded-md border border-red-300 px-3 py-2 text-sm text-red-700"
>
Delete
</button>
</div>
</div>
</form>
<form
action={upsertNewsTranslationAction}
className="mt-4 rounded-lg border border-neutral-200 p-4"
>
<option value="draft">draft</option>
<option value="published">published</option>
</select>
<div className="flex items-center gap-2">
<Button type="submit" size="sm">
Save
</Button>
<button
type="submit"
formAction={deleteNewsAction}
className="rounded-md border border-red-300 px-3 py-2 text-sm text-red-700"
>
Delete
</button>
</div>
<input type="hidden" name="postId" value={post.id} />
<input type="hidden" name="locale" value={selectedLocale} />
<h3 className="text-sm font-medium">
Translation ({selectedLocale.toUpperCase()})
</h3>
<p className="mt-1 text-xs text-neutral-600">
Missing fields fall back to base post content on public pages.
</p>
{post.translations.length > 0 ? (
<p className="mt-2 text-xs text-neutral-600">
Saved locales:{" "}
{post.translations.map((entry) => entry.locale.toUpperCase()).join(", ")}
</p>
) : null}
<div className="mt-3 grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Title</span>
<input
name="title"
defaultValue={translation?.title ?? post.title}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Excerpt</span>
<input
name="excerpt"
defaultValue={translation?.excerpt ?? post.excerpt ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<label className="mt-3 block space-y-1">
<span className="text-xs text-neutral-600">Body</span>
<textarea
name="body"
rows={4}
defaultValue={translation?.body ?? post.body}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<div className="mt-3">
<Button type="submit" size="sm">
Save translation
</Button>
</div>
</form>
</div>
</form>
))}
)
})}
</section>
</AdminShell>
)

View File

@@ -1,4 +1,10 @@
import { deletePage, getPageById, updatePage } from "@cms/db"
import {
deletePage,
getPageById,
listPageTranslations,
updatePage,
upsertPageTranslation,
} from "@cms/db"
import { Button } from "@cms/ui/button"
import Link from "next/link"
import { redirect } from "next/navigation"
@@ -9,6 +15,8 @@ import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic"
type SearchParamsInput = Record<string, string | string[] | undefined>
const SUPPORTED_LOCALES = ["de", "en", "es", "fr"] as const
type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]
type PageProps = {
params: Promise<{ id: string }>
@@ -48,6 +56,14 @@ function redirectWithState(pageId: string, params: { notice?: string; error?: st
redirect(value ? `/pages/${pageId}?${value}` : `/pages/${pageId}`)
}
function normalizeLocale(input: string | null): SupportedLocale {
if (input && SUPPORTED_LOCALES.includes(input as SupportedLocale)) {
return input as SupportedLocale
}
return "en"
}
export default async function PageEditorPage({ params, searchParams }: PageProps) {
const role = await requirePermissionForRoute({
nextPath: "/pages",
@@ -57,7 +73,11 @@ export default async function PageEditorPage({ params, searchParams }: PageProps
const resolvedParams = await params
const pageId = resolvedParams.id
const [resolvedSearchParams, pageRecord] = await Promise.all([searchParams, getPageById(pageId)])
const [resolvedSearchParams, pageRecord, translations] = await Promise.all([
searchParams,
getPageById(pageId),
listPageTranslations(pageId),
])
if (!pageRecord) {
redirect("/pages?error=Page+not+found")
@@ -66,6 +86,8 @@ export default async function PageEditorPage({ params, searchParams }: PageProps
const page = pageRecord
const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error)
const selectedLocale = normalizeLocale(readFirstValue(resolvedSearchParams.locale))
const selectedTranslation = translations.find((entry) => entry.locale === selectedLocale)
async function updatePageAction(formData: FormData) {
"use server"
@@ -118,6 +140,34 @@ export default async function PageEditorPage({ params, searchParams }: PageProps
redirect("/pages?notice=Page+deleted")
}
async function upsertPageTranslationAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/pages",
permission: "pages:write",
scope: "team",
})
const locale = normalizeLocale(readInputString(formData, "locale"))
try {
await upsertPageTranslation({
pageId,
locale,
title: readInputString(formData, "title"),
summary: readNullableString(formData, "summary"),
content: readInputString(formData, "content"),
seoTitle: readNullableString(formData, "seoTitle"),
seoDescription: readNullableString(formData, "seoDescription"),
})
} catch {
redirect(`/pages/${pageId}?error=Failed+to+save+translation.&locale=${locale}`)
}
redirect(`/pages/${pageId}?notice=Translation+saved.&locale=${locale}`)
}
return (
<AdminShell
role={role}
@@ -226,6 +276,132 @@ export default async function PageEditorPage({ params, searchParams }: PageProps
</form>
</section>
<section className="rounded-xl border border-neutral-200 p-6">
<div className="space-y-1">
<h3 className="text-xl font-medium">Translations</h3>
<p className="text-sm text-neutral-600">
Add locale-specific page content. Missing locales fall back to base page fields.
</p>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{SUPPORTED_LOCALES.map((locale) => {
const isActive = locale === selectedLocale
const hasTranslation = translations.some((entry) => entry.locale === locale)
return (
<Link
key={locale}
href={`/pages/${pageId}?locale=${locale}`}
className={`inline-flex items-center gap-2 rounded border px-3 py-1.5 text-xs ${
isActive
? "border-neutral-800 bg-neutral-900 text-white"
: "border-neutral-300 text-neutral-700"
}`}
>
<span>{locale.toUpperCase()}</span>
<span className={isActive ? "text-neutral-200" : "text-neutral-500"}>
{hasTranslation ? "saved" : "missing"}
</span>
</Link>
)
})}
</div>
{translations.length > 0 ? (
<div className="mt-4 rounded border border-neutral-200">
<table className="min-w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wide text-neutral-500">
<tr>
<th className="px-3 py-2">Locale</th>
<th className="px-3 py-2">Title</th>
<th className="px-3 py-2">Updated</th>
</tr>
</thead>
<tbody>
{translations.map((translation) => (
<tr key={translation.id} className="border-t border-neutral-200">
<td className="px-3 py-2">{translation.locale.toUpperCase()}</td>
<td className="px-3 py-2">{translation.title}</td>
<td className="px-3 py-2 text-neutral-600">
{translation.updatedAt.toLocaleDateString("en-US")}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
<form action={upsertPageTranslationAction} className="mt-6 space-y-3">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Locale</span>
<select
name="locale"
defaultValue={selectedLocale}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
{SUPPORTED_LOCALES.map((locale) => (
<option key={locale} value={locale}>
{locale.toUpperCase()}
</option>
))}
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Title</span>
<input
name="title"
defaultValue={selectedTranslation?.title ?? page.title}
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Summary</span>
<input
name="summary"
defaultValue={selectedTranslation?.summary ?? page.summary ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Content</span>
<textarea
name="content"
rows={8}
defaultValue={selectedTranslation?.content ?? page.content}
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">SEO title</span>
<input
name="seoTitle"
defaultValue={selectedTranslation?.seoTitle ?? page.seoTitle ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">SEO description</span>
<input
name="seoDescription"
defaultValue={selectedTranslation?.seoDescription ?? page.seoDescription ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<Button type="submit">Save translation</Button>
</form>
</section>
<section className="rounded-xl border border-red-300 bg-red-50 p-6">
<h3 className="text-lg font-medium text-red-800">Danger Zone</h3>
<p className="mt-1 text-sm text-red-700">

View File

@@ -1,4 +1,4 @@
import { getPublishedPageBySlug } from "@cms/db"
import { getPublishedPageBySlugForLocale } from "@cms/db"
import { notFound } from "next/navigation"
import { PublicPageView } from "@/components/public-page-view"
@@ -6,12 +6,12 @@ import { PublicPageView } from "@/components/public-page-view"
export const dynamic = "force-dynamic"
type PageProps = {
params: Promise<{ slug: string }>
params: Promise<{ locale: string; slug: string }>
}
export default async function CmsPageRoute({ params }: PageProps) {
const { slug } = await params
const page = await getPublishedPageBySlug(slug)
const { locale, slug } = await params
const page = await getPublishedPageBySlugForLocale(slug, locale)
if (!page) {
notFound()

View File

@@ -1,15 +1,15 @@
import { getPostBySlug } from "@cms/db"
import { getPostBySlugForLocale } from "@cms/db"
import { notFound } from "next/navigation"
export const dynamic = "force-dynamic"
type PageProps = {
params: Promise<{ slug: string }>
params: Promise<{ locale: string; slug: string }>
}
export default async function PublicNewsDetailPage({ params }: PageProps) {
const { slug } = await params
const post = await getPostBySlug(slug)
const { locale, slug } = await params
const post = await getPostBySlugForLocale(slug, locale)
if (!post || post.status !== "published") {
notFound()

View File

@@ -1,10 +1,15 @@
import { listPosts } from "@cms/db"
import { listPostsForLocale } from "@cms/db"
import Link from "next/link"
export const dynamic = "force-dynamic"
export default async function PublicNewsIndexPage() {
const posts = await listPosts()
type PublicNewsIndexPageProps = {
params: Promise<{ locale: string }>
}
export default async function PublicNewsIndexPage({ params }: PublicNewsIndexPageProps) {
const { locale } = await params
const posts = await listPostsForLocale(locale)
return (
<section className="mx-auto w-full max-w-4xl space-y-4 px-6 py-16">

View File

@@ -1,4 +1,4 @@
import { getPublishedPageBySlug, listPosts } from "@cms/db"
import { getPublishedPageBySlugForLocale, listPosts } from "@cms/db"
import { Button } from "@cms/ui/button"
import { getTranslations } from "next-intl/server"
import { PublicAnnouncements } from "@/components/public-announcements"
@@ -6,9 +6,15 @@ import { PublicPageView } from "@/components/public-page-view"
export const dynamic = "force-dynamic"
export default async function HomePage() {
type HomePageProps = {
params: Promise<{ locale: string }>
}
export default async function HomePage({ params }: HomePageProps) {
const { locale } = await params
const [homePage, posts, t] = await Promise.all([
getPublishedPageBySlug("home"),
getPublishedPageBySlugForLocale("home", locale),
listPosts(),
getTranslations("Home"),
])

View File

@@ -1,11 +1,13 @@
import { listPublicNavigation } from "@cms/db"
import { getLocale } from "next-intl/server"
import { Link } from "@/i18n/navigation"
import { LanguageSwitcher } from "./language-switcher"
export async function PublicSiteHeader() {
const navItems = await listPublicNavigation("header")
const locale = await getLocale()
const navItems = await listPublicNavigation("header", locale)
return (
<header className="border-b border-neutral-200 bg-white/80 backdrop-blur">

View File

@@ -1,6 +1,7 @@
import { z } from "zod"
export const pageStatusSchema = z.enum(["draft", "published"])
export const pageLocaleSchema = z.enum(["de", "en", "es", "fr"])
export const createPageInputSchema = z.object({
title: z.string().min(1).max(180),
@@ -23,6 +24,16 @@ export const updatePageInputSchema = z.object({
seoDescription: z.string().max(320).nullable().optional(),
})
export const upsertPageTranslationInputSchema = z.object({
pageId: z.string().uuid(),
locale: pageLocaleSchema,
title: z.string().min(1).max(180),
summary: z.string().max(500).nullable().optional(),
content: z.string().min(1),
seoTitle: z.string().max(180).nullable().optional(),
seoDescription: z.string().max(320).nullable().optional(),
})
export const createNavigationMenuInputSchema = z.object({
name: z.string().min(1).max(180),
slug: z.string().min(1).max(180),
@@ -52,6 +63,7 @@ export const updateNavigationItemInputSchema = z.object({
export type CreatePageInput = z.infer<typeof createPageInputSchema>
export type UpdatePageInput = z.infer<typeof updatePageInputSchema>
export type UpsertPageTranslationInput = z.infer<typeof upsertPageTranslationInputSchema>
export type CreateNavigationMenuInput = z.infer<typeof createNavigationMenuInputSchema>
export type CreateNavigationItemInput = z.infer<typeof createNavigationItemInputSchema>
export type UpdateNavigationItemInput = z.infer<typeof updateNavigationItemInputSchema>

View File

@@ -0,0 +1,24 @@
-- CreateTable
CREATE TABLE "PageTranslation" (
"id" TEXT NOT NULL,
"pageId" TEXT NOT NULL,
"locale" TEXT NOT NULL,
"title" TEXT NOT NULL,
"summary" TEXT,
"content" TEXT NOT NULL,
"seoTitle" TEXT,
"seoDescription" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PageTranslation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "PageTranslation_locale_idx" ON "PageTranslation"("locale");
-- CreateIndex
CREATE UNIQUE INDEX "PageTranslation_pageId_locale_key" ON "PageTranslation"("pageId", "locale");
-- AddForeignKey
ALTER TABLE "PageTranslation" ADD CONSTRAINT "PageTranslation_pageId_fkey" FOREIGN KEY ("pageId") REFERENCES "Page"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,43 @@
-- CreateTable
CREATE TABLE "PostTranslation" (
"id" TEXT NOT NULL,
"postId" TEXT NOT NULL,
"locale" TEXT NOT NULL,
"title" TEXT NOT NULL,
"excerpt" TEXT,
"body" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PostTranslation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "NavigationItemTranslation" (
"id" TEXT NOT NULL,
"navigationItemId" TEXT NOT NULL,
"locale" TEXT NOT NULL,
"label" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "NavigationItemTranslation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "PostTranslation_locale_idx" ON "PostTranslation"("locale");
-- CreateIndex
CREATE UNIQUE INDEX "PostTranslation_postId_locale_key" ON "PostTranslation"("postId", "locale");
-- CreateIndex
CREATE INDEX "NavigationItemTranslation_locale_idx" ON "NavigationItemTranslation"("locale");
-- CreateIndex
CREATE UNIQUE INDEX "NavigationItemTranslation_navigationItemId_locale_key" ON "NavigationItemTranslation"("navigationItemId", "locale");
-- AddForeignKey
ALTER TABLE "PostTranslation" ADD CONSTRAINT "PostTranslation_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NavigationItemTranslation" ADD CONSTRAINT "NavigationItemTranslation_navigationItemId_fkey" FOREIGN KEY ("navigationItemId") REFERENCES "NavigationItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -16,6 +16,22 @@ model Post {
status String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
translations PostTranslation[]
}
model PostTranslation {
id String @id @default(uuid())
postId String
locale String
title String
excerpt String?
body String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
@@unique([postId, locale])
@@index([locale])
}
model User {
@@ -267,10 +283,28 @@ model Page {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
navItems NavigationItem[]
translations PageTranslation[]
@@index([status])
}
model PageTranslation {
id String @id @default(uuid())
pageId String
locale String
title String
summary String?
content String
seoTitle String?
seoDescription String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
@@unique([pageId, locale])
@@index([locale])
}
model NavigationMenu {
id String @id @default(uuid())
name String
@@ -297,6 +331,7 @@ model NavigationItem {
page Page? @relation(fields: [pageId], references: [id], onDelete: SetNull)
parent NavigationItem? @relation("NavigationItemParent", fields: [parentId], references: [id], onDelete: Cascade)
children NavigationItem[] @relation("NavigationItemParent")
translations NavigationItemTranslation[]
@@index([menuId])
@@index([pageId])
@@ -304,6 +339,19 @@ model NavigationItem {
@@unique([menuId, parentId, sortOrder, label])
}
model NavigationItemTranslation {
id String @id @default(uuid())
navigationItemId String
locale String
label String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
navigationItem NavigationItem @relation(fields: [navigationItemId], references: [id], onDelete: Cascade)
@@unique([navigationItemId, locale])
@@index([locale])
}
model Customer {
id String @id @default(uuid())
name String

View File

@@ -41,21 +41,29 @@ export {
deletePage,
getPageById,
getPublishedPageBySlug,
getPublishedPageBySlugForLocale,
listNavigationMenus,
listPages,
listPageTranslations,
listPublicNavigation,
listPublishedPageSlugs,
updateNavigationItem,
updatePage,
upsertNavigationItemTranslation,
upsertPageTranslation,
} from "./pages-navigation"
export {
createPost,
deletePost,
getPostById,
getPostBySlug,
getPostBySlugForLocale,
listPosts,
listPostsForLocale,
listPostsWithTranslations,
registerPostCrudAuditHook,
updatePost,
upsertPostTranslation,
} from "./posts"
export type { PublicHeaderBanner, PublicHeaderBannerConfig } from "./settings"
export {

View File

@@ -7,6 +7,11 @@ const { mockDb } = vi.hoisted(() => ({
update: vi.fn(),
delete: vi.fn(),
findUnique: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
},
pageTranslation: {
upsert: vi.fn(),
findMany: vi.fn(),
},
navigationMenu: {
@@ -19,6 +24,9 @@ const { mockDb } = vi.hoisted(() => ({
update: vi.fn(),
delete: vi.fn(),
},
navigationItemTranslation: {
upsert: vi.fn(),
},
},
}))
@@ -30,8 +38,11 @@ import {
createNavigationItem,
createNavigationMenu,
createPage,
getPublishedPageBySlugForLocale,
listPublicNavigation,
updatePage,
upsertNavigationItemTranslation,
upsertPageTranslation,
} from "./pages-navigation"
describe("pages-navigation service", () => {
@@ -105,19 +116,89 @@ describe("pages-navigation service", () => {
slug: "home",
status: "published",
},
translations: [{ locale: "de", label: "Startseite" }],
},
],
})
const navigation = await listPublicNavigation("header")
const navigation = await listPublicNavigation("header", "de")
expect(navigation).toEqual([
{
id: "item-1",
label: "Home",
label: "Startseite",
href: "/",
children: [],
},
])
})
it("validates locale when upserting navigation item translation", async () => {
await expect(() =>
upsertNavigationItemTranslation({
navigationItemId: "550e8400-e29b-41d4-a716-446655440001",
locale: "it",
label: "Home",
}),
).rejects.toThrow()
})
it("validates locale when upserting page translation", async () => {
await expect(() =>
upsertPageTranslation({
pageId: "550e8400-e29b-41d4-a716-446655440000",
locale: "it",
title: "Titolo",
content: "Contenuto",
}),
).rejects.toThrow()
})
it("upserts page translation and reads localized page with fallback", async () => {
mockDb.pageTranslation.upsert.mockResolvedValue({ id: "pt-1" })
mockDb.page.findFirst
.mockResolvedValueOnce({
id: "page-1",
title: "About",
summary: "Base summary",
content: "Base content",
seoTitle: "Base SEO",
seoDescription: "Base description",
translations: [
{
locale: "de",
title: "Uber Uns",
summary: "Zusammenfassung",
content: "Inhalt",
seoTitle: "SEO DE",
seoDescription: "Beschreibung",
},
],
})
.mockResolvedValueOnce({
id: "page-1",
title: "About",
summary: "Base summary",
content: "Base content",
seoTitle: "Base SEO",
seoDescription: "Base description",
translations: [],
})
await upsertPageTranslation({
pageId: "550e8400-e29b-41d4-a716-446655440000",
locale: "de",
title: "Uber Uns",
content: "Inhalt",
})
const translated = await getPublishedPageBySlugForLocale("about", "de")
const fallback = await getPublishedPageBySlugForLocale("about", "fr")
expect(mockDb.pageTranslation.upsert).toHaveBeenCalledTimes(1)
expect(translated?.title).toBe("Uber Uns")
expect(translated?.content).toBe("Inhalt")
expect(fallback?.title).toBe("About")
expect(fallback?.content).toBe("Base content")
})
})

View File

@@ -4,7 +4,9 @@ import {
createPageInputSchema,
updateNavigationItemInputSchema,
updatePageInputSchema,
upsertPageTranslationInputSchema,
} from "@cms/content"
import { z } from "zod"
import { db } from "./client"
@@ -15,6 +17,13 @@ export type PublicNavigationItem = {
children: PublicNavigationItem[]
}
const supportedLocaleSchema = z.enum(["de", "en", "es", "fr"])
const upsertNavigationItemTranslationInputSchema = z.object({
navigationItemId: z.string().uuid(),
locale: supportedLocaleSchema,
label: z.string().min(1).max(180),
})
function resolvePublishedAt(status: string): Date | null {
return status === "published" ? new Date() : null
}
@@ -54,6 +63,38 @@ export async function getPublishedPageBySlug(slug: string) {
})
}
export async function getPublishedPageBySlugForLocale(slug: string, locale: string) {
const page = await db.page.findFirst({
where: {
slug,
status: "published",
},
include: {
translations: {
where: {
locale,
},
take: 1,
},
},
})
if (!page) {
return null
}
const translation = page.translations[0]
return {
...page,
title: translation?.title ?? page.title,
summary: translation?.summary ?? page.summary,
content: translation?.content ?? page.content,
seoTitle: translation?.seoTitle ?? page.seoTitle,
seoDescription: translation?.seoDescription ?? page.seoDescription,
}
}
export async function createPage(input: unknown) {
const payload = createPageInputSchema.parse(input)
@@ -85,6 +126,33 @@ export async function deletePage(id: string) {
})
}
export async function upsertPageTranslation(input: unknown) {
const payload = upsertPageTranslationInputSchema.parse(input)
const { pageId, locale, ...data } = payload
return db.pageTranslation.upsert({
where: {
pageId_locale: {
pageId,
locale,
},
},
create: {
pageId,
locale,
...data,
},
update: data,
})
}
export async function listPageTranslations(pageId: string) {
return db.pageTranslation.findMany({
where: { pageId },
orderBy: [{ locale: "asc" }],
})
}
export async function listNavigationMenus() {
return db.navigationMenu.findMany({
orderBy: [{ location: "asc" }, { name: "asc" }],
@@ -99,6 +167,9 @@ export async function listNavigationMenus() {
slug: true,
},
},
translations: {
orderBy: [{ locale: "asc" }],
},
},
},
},
@@ -123,7 +194,12 @@ function resolveNavigationHref(item: {
return null
}
export async function listPublicNavigation(location = "header"): Promise<PublicNavigationItem[]> {
export async function listPublicNavigation(
location = "header",
locale?: string,
): Promise<PublicNavigationItem[]> {
const normalizedLocale = locale ? supportedLocaleSchema.safeParse(locale).data : undefined
const menu = await db.navigationMenu.findFirst({
where: {
location,
@@ -143,6 +219,12 @@ export async function listPublicNavigation(location = "header"): Promise<PublicN
status: true,
},
},
translations: normalizedLocale
? {
where: { locale: normalizedLocale },
take: 1,
}
: false,
},
},
},
@@ -172,7 +254,7 @@ export async function listPublicNavigation(location = "header"): Promise<PublicN
itemMap.set(item.id, {
id: item.id,
label: item.label,
label: item.translations?.[0]?.label ?? item.label,
href,
parentId: item.parentId,
children: [],
@@ -238,3 +320,20 @@ export async function deleteNavigationItem(id: string) {
where: { id },
})
}
export async function upsertNavigationItemTranslation(input: unknown) {
const payload = upsertNavigationItemTranslationInputSchema.parse(input)
return db.navigationItemTranslation.upsert({
where: {
navigationItemId_locale: {
navigationItemId: payload.navigationItemId,
locale: payload.locale,
},
},
create: payload,
update: {
label: payload.label,
},
})
}

View File

@@ -9,6 +9,9 @@ const { mockDb } = vi.hoisted(() => ({
update: vi.fn(),
delete: vi.fn(),
},
postTranslation: {
upsert: vi.fn(),
},
},
}))
@@ -16,7 +19,15 @@ vi.mock("./client", () => ({
db: mockDb,
}))
import { createPost, getPostBySlug, listPosts, updatePost } from "./posts"
import {
createPost,
getPostBySlug,
getPostBySlugForLocale,
listPosts,
listPostsForLocale,
updatePost,
upsertPostTranslation,
} from "./posts"
describe("posts service", () => {
beforeEach(() => {
@@ -25,6 +36,7 @@ describe("posts service", () => {
fn.mockReset()
}
}
mockDb.postTranslation.upsert.mockReset()
})
it("lists posts ordered by update date desc", async () => {
@@ -72,4 +84,63 @@ describe("posts service", () => {
},
})
})
it("upserts post translation and reads localized/fallback post views", async () => {
mockDb.postTranslation.upsert.mockResolvedValue({ id: "pt-1" })
mockDb.post.findUnique
.mockResolvedValueOnce({
id: "post-1",
slug: "hello",
title: "Base title",
excerpt: "Base excerpt",
body: "Base body",
translations: [{ locale: "de", title: "Titel", excerpt: "Auszug", body: "Text" }],
})
.mockResolvedValueOnce({
id: "post-1",
slug: "hello",
title: "Base title",
excerpt: "Base excerpt",
body: "Base body",
translations: [],
})
mockDb.post.findMany.mockResolvedValue([
{
id: "post-1",
slug: "hello",
title: "Base title",
excerpt: "Base excerpt",
body: "Base body",
status: "published",
translations: [{ locale: "de", title: "Titel", excerpt: "Auszug", body: "Text" }],
},
])
await upsertPostTranslation({
postId: "550e8400-e29b-41d4-a716-446655440000",
locale: "de",
title: "Titel",
body: "Text",
})
const localized = await getPostBySlugForLocale("hello", "de")
const fallback = await getPostBySlugForLocale("hello", "fr")
const localizedList = await listPostsForLocale("de")
expect(mockDb.postTranslation.upsert).toHaveBeenCalledTimes(1)
expect(localized?.title).toBe("Titel")
expect(fallback?.title).toBe("Base title")
expect(localizedList[0]?.title).toBe("Titel")
})
it("validates locale for post translations", async () => {
await expect(() =>
upsertPostTranslation({
postId: "550e8400-e29b-41d4-a716-446655440000",
locale: "it",
title: "Titolo",
body: "Testo",
}),
).rejects.toThrow()
})
})

View File

@@ -5,6 +5,7 @@ import {
updatePostInputSchema,
} from "@cms/content"
import { type CrudAuditHook, type CrudMutationContext, createCrudService } from "@cms/crud"
import { z } from "zod"
import type { Post } from "../prisma/generated/client/client"
import { db } from "./client"
@@ -35,6 +36,15 @@ const postRepository = {
}),
}
const supportedLocaleSchema = z.enum(["de", "en", "es", "fr"])
const upsertPostTranslationInputSchema = z.object({
postId: z.string().uuid(),
locale: supportedLocaleSchema,
title: z.string().min(3).max(180),
excerpt: z.string().max(320).nullable().optional(),
body: z.string().min(1),
})
const postAuditHooks: Array<CrudAuditHook<Post>> = []
const postCrudService = createCrudService({
@@ -73,6 +83,100 @@ export async function getPostBySlug(slug: string) {
})
}
export async function getPostBySlugForLocale(slug: string, locale: string) {
const normalizedLocale = supportedLocaleSchema.safeParse(locale).data
const post = await db.post.findUnique({
where: { slug },
include: {
translations: normalizedLocale
? {
where: {
locale: normalizedLocale,
},
take: 1,
}
: false,
},
})
if (!post) {
return null
}
const translation = post.translations?.[0]
return {
...post,
title: translation?.title ?? post.title,
excerpt: translation?.excerpt ?? post.excerpt,
body: translation?.body ?? post.body,
}
}
export async function listPostsForLocale(locale: string) {
const normalizedLocale = supportedLocaleSchema.safeParse(locale).data
const posts = await db.post.findMany({
where: {
status: "published",
},
orderBy: {
updatedAt: "desc",
},
include: {
translations: normalizedLocale
? {
where: { locale: normalizedLocale },
take: 1,
}
: false,
},
})
return posts.map((post) => {
const translation = post.translations?.[0]
return {
...post,
title: translation?.title ?? post.title,
excerpt: translation?.excerpt ?? post.excerpt,
body: translation?.body ?? post.body,
}
})
}
export async function listPostsWithTranslations() {
return db.post.findMany({
orderBy: {
updatedAt: "desc",
},
include: {
translations: {
orderBy: [{ locale: "asc" }],
},
},
})
}
export async function upsertPostTranslation(input: unknown) {
const payload = upsertPostTranslationInputSchema.parse(input)
const { postId, locale, ...data } = payload
return db.postTranslation.upsert({
where: {
postId_locale: {
postId,
locale,
},
},
create: {
postId,
locale,
...data,
},
update: data,
})
}
export async function createPost(input: unknown, context?: CrudMutationContext) {
return postCrudService.create(input, context)
}