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] Media entity rendering with enrichment data
- [ ] [P1] Portfolio views (gallery/album/category/tag) for artworks with filter and sort controls - [ ] [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] 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 - [ ] [P2] Artwork views and listing filters
- [ ] [P1] Commission request submission flow - [ ] [P1] Commission request submission flow
- [x] [P1] Header banner render logic and fallbacks - [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] 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 owner invariant and hidden support-user protection
- [x] [P1] Integration tests for registration allow/deny behavior - [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: create page, publish, see on public app
- [~] [P1] E2E happy paths: media upload + artwork refinement display - [~] [P1] E2E happy paths: media upload + artwork refinement display
- [~] [P1] E2E happy paths: commissions kanban transitions - [~] [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] 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] 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] 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 ## How We Use This File

View File

@@ -5,6 +5,7 @@ import {
listNavigationMenus, listNavigationMenus,
listPages, listPages,
updateNavigationItem, updateNavigationItem,
upsertNavigationItemTranslation,
} from "@cms/db" } from "@cms/db"
import { Button } from "@cms/ui/button" import { Button } from "@cms/ui/button"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
@@ -18,6 +19,9 @@ import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
type SearchParamsInput = Record<string, string | string[] | undefined> 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 { function readFirstValue(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) { if (Array.isArray(value)) {
@@ -53,6 +57,14 @@ function readInt(formData: FormData, field: string, fallback = 0): number {
return parsed 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 }) { function redirectWithState(params: { notice?: string; error?: string }) {
const query = new URLSearchParams() const query = new URLSearchParams()
@@ -165,6 +177,31 @@ async function deleteItemAction(formData: FormData) {
redirectWithState({ notice: "Navigation item deleted." }) 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({ export default async function NavigationManagementPage({
searchParams, searchParams,
}: { }: {
@@ -184,6 +221,7 @@ export default async function NavigationManagementPage({
const notice = readFirstValue(resolvedSearchParams.notice) const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error) const error = readFirstValue(resolvedSearchParams.error)
const selectedLocale = normalizeLocale(readFirstValue(resolvedSearchParams.locale))
return ( return (
<AdminShell <AdminShell
@@ -218,6 +256,22 @@ export default async function NavigationManagementPage({
</section> </section>
<section className="space-y-4"> <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 ? ( {menus.length === 0 ? (
<article className="rounded-xl border border-neutral-200 p-6 text-sm text-neutral-600"> <article className="rounded-xl border border-neutral-200 p-6 text-sm text-neutral-600">
No navigation menus yet. No navigation menus yet.
@@ -238,94 +292,126 @@ export default async function NavigationManagementPage({
{menu.items.length === 0 ? ( {menu.items.length === 0 ? (
<p className="text-sm text-neutral-600">No items in this menu.</p> <p className="text-sm text-neutral-600">No items in this menu.</p>
) : ( ) : (
menu.items.map((item) => ( menu.items.map((item) => {
<form const translation = item.translations.find(
key={item.id} (entry) => entry.locale === selectedLocale,
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>
<div className="mt-3 grid gap-3 md:grid-cols-2"> return (
<label className="space-y-1"> <div key={item.id} className="rounded-lg border border-neutral-200 p-3">
<span className="text-xs text-neutral-600">Linked page</span> <form action={updateItemAction}>
<select <input type="hidden" name="id" value={item.id} />
name="pageId" <div className="grid gap-3 md:grid-cols-5">
defaultValue={item.pageId ?? ""} <label className="space-y-1 md:col-span-2">
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm" <span className="text-xs text-neutral-600">Label</span>
> <input
<option value="">(none)</option> name="label"
{pages.map((page) => ( defaultValue={item.label}
<option key={page.id} value={page.id}> className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
{page.title} (/{page.slug}) />
</option> </label>
))} <label className="space-y-1 md:col-span-2">
</select> <span className="text-xs text-neutral-600">Href</span>
</label> <input
<label className="space-y-1"> name="href"
<span className="text-xs text-neutral-600">Parent id</span> defaultValue={item.href ?? ""}
<input className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
name="parentId" />
defaultValue={item.parentId ?? ""} </label>
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm" <label className="space-y-1">
/> <span className="text-xs text-neutral-600">Sort</span>
</label> <input
</div> 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"> <div className="mt-3 grid gap-3 md:grid-cols-2">
<label className="inline-flex items-center gap-2 text-sm text-neutral-700"> <label className="space-y-1">
<input <span className="text-xs text-neutral-600">Linked page</span>
type="checkbox" <select
name="isVisible" name="pageId"
value="true" defaultValue={item.pageId ?? ""}
defaultChecked={item.isVisible} className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
className="size-4" >
/> <option value="">(none)</option>
Visible {pages.map((page) => (
</label> <option key={page.id} value={page.id}>
<div className="flex items-center gap-2"> {page.title} (/{page.slug})
<Button type="submit" size="sm"> </option>
Save item ))}
</Button> </select>
<button </label>
type="submit" <label className="space-y-1">
formAction={deleteItemAction} <span className="text-xs text-neutral-600">Parent id</span>
className="rounded-md border border-red-300 px-3 py-2 text-sm text-red-700" <input
> name="parentId"
Delete defaultValue={item.parentId ?? ""}
</button> className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
</div> />
</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> </div>
</form> )
)) })
)} )}
</div> </div>
</article> </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 { Button } from "@cms/ui/button"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
@@ -9,6 +15,9 @@ import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
type SearchParamsInput = Record<string, string | string[] | undefined> 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 { function readFirstValue(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) { if (Array.isArray(value)) {
@@ -28,6 +37,14 @@ function readNullableString(formData: FormData, field: string): string | undefin
return value.length > 0 ? value : undefined 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 }) { function redirectWithState(params: { notice?: string; error?: string }) {
const query = new URLSearchParams() const query = new URLSearchParams()
@@ -115,6 +132,34 @@ async function deleteNewsAction(formData: FormData) {
redirectWithState({ notice: "Post deleted." }) 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({ export default async function NewsManagementPage({
searchParams, searchParams,
}: { }: {
@@ -126,10 +171,14 @@ export default async function NewsManagementPage({
scope: "team", scope: "team",
}) })
const [resolvedSearchParams, posts] = await Promise.all([searchParams, listPosts()]) const [resolvedSearchParams, posts] = await Promise.all([
searchParams,
listPostsWithTranslations(),
])
const notice = readFirstValue(resolvedSearchParams.notice) const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error) const error = readFirstValue(resolvedSearchParams.error)
const selectedLocale = normalizeLocale(readFirstValue(resolvedSearchParams.locale))
return ( return (
<AdminShell <AdminShell
@@ -204,72 +253,146 @@ export default async function NewsManagementPage({
</section> </section>
<section className="space-y-3"> <section className="space-y-3">
{posts.map((post) => ( <div className="flex flex-wrap gap-2">
<form {SUPPORTED_LOCALES.map((locale) => (
key={post.id} <a
action={updateNewsAction} key={locale}
className="rounded-xl border border-neutral-200 p-6" href={`/news?locale=${locale}`}
> className={`inline-flex rounded border px-3 py-1.5 text-xs ${
<input type="hidden" name="id" value={post.id} /> selectedLocale === locale
<div className="grid gap-3 md:grid-cols-2"> ? "border-neutral-800 bg-neutral-900 text-white"
<label className="space-y-1"> : "border-neutral-300 text-neutral-700"
<span className="text-xs text-neutral-600">Title</span> }`}
<input >
name="title" {locale.toUpperCase()}
defaultValue={post.title} </a>
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm" ))}
/> </div>
</label>
<label className="space-y-1"> {posts.map((post) => {
<span className="text-xs text-neutral-600">Slug</span> const translation = post.translations.find((entry) => entry.locale === selectedLocale)
<input
name="slug" return (
defaultValue={post.slug} <div key={post.id} className="rounded-xl border border-neutral-200 p-6">
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm" <form action={updateNewsAction}>
/> <input type="hidden" name="id" value={post.id} />
</label> <div className="grid gap-3 md:grid-cols-2">
</div> <label className="space-y-1">
<label className="mt-3 block space-y-1"> <span className="text-xs text-neutral-600">Title</span>
<span className="text-xs text-neutral-600">Excerpt</span> <input
<input name="title"
name="excerpt" defaultValue={post.title}
defaultValue={post.excerpt ?? ""} className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm" />
/> </label>
</label> <label className="space-y-1">
<label className="mt-3 block space-y-1"> <span className="text-xs text-neutral-600">Slug</span>
<span className="text-xs text-neutral-600">Body</span> <input
<textarea name="slug"
name="body" defaultValue={post.slug}
rows={4} className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
defaultValue={post.body} />
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm" </label>
/> </div>
</label> <label className="mt-3 block space-y-1">
<div className="mt-3 flex flex-wrap items-center justify-between gap-3"> <span className="text-xs text-neutral-600">Excerpt</span>
<select <input
name="status" name="excerpt"
defaultValue={post.status} defaultValue={post.excerpt ?? ""}
className="rounded border border-neutral-300 px-3 py-2 text-sm" 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> <input type="hidden" name="postId" value={post.id} />
<option value="published">published</option> <input type="hidden" name="locale" value={selectedLocale} />
</select>
<div className="flex items-center gap-2"> <h3 className="text-sm font-medium">
<Button type="submit" size="sm"> Translation ({selectedLocale.toUpperCase()})
Save </h3>
</Button> <p className="mt-1 text-xs text-neutral-600">
<button Missing fields fall back to base post content on public pages.
type="submit" </p>
formAction={deleteNewsAction} {post.translations.length > 0 ? (
className="rounded-md border border-red-300 px-3 py-2 text-sm text-red-700" <p className="mt-2 text-xs text-neutral-600">
> Saved locales:{" "}
Delete {post.translations.map((entry) => entry.locale.toUpperCase()).join(", ")}
</button> </p>
</div> ) : 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> </div>
</form> )
))} })}
</section> </section>
</AdminShell> </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 { Button } from "@cms/ui/button"
import Link from "next/link" import Link from "next/link"
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
@@ -9,6 +15,8 @@ import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
type SearchParamsInput = Record<string, string | string[] | undefined> type SearchParamsInput = Record<string, string | string[] | undefined>
const SUPPORTED_LOCALES = ["de", "en", "es", "fr"] as const
type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]
type PageProps = { type PageProps = {
params: Promise<{ id: string }> params: Promise<{ id: string }>
@@ -48,6 +56,14 @@ function redirectWithState(pageId: string, params: { notice?: string; error?: st
redirect(value ? `/pages/${pageId}?${value}` : `/pages/${pageId}`) 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) { export default async function PageEditorPage({ params, searchParams }: PageProps) {
const role = await requirePermissionForRoute({ const role = await requirePermissionForRoute({
nextPath: "/pages", nextPath: "/pages",
@@ -57,7 +73,11 @@ export default async function PageEditorPage({ params, searchParams }: PageProps
const resolvedParams = await params const resolvedParams = await params
const pageId = resolvedParams.id 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) { if (!pageRecord) {
redirect("/pages?error=Page+not+found") redirect("/pages?error=Page+not+found")
@@ -66,6 +86,8 @@ export default async function PageEditorPage({ params, searchParams }: PageProps
const page = pageRecord const page = pageRecord
const notice = readFirstValue(resolvedSearchParams.notice) const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error) const error = readFirstValue(resolvedSearchParams.error)
const selectedLocale = normalizeLocale(readFirstValue(resolvedSearchParams.locale))
const selectedTranslation = translations.find((entry) => entry.locale === selectedLocale)
async function updatePageAction(formData: FormData) { async function updatePageAction(formData: FormData) {
"use server" "use server"
@@ -118,6 +140,34 @@ export default async function PageEditorPage({ params, searchParams }: PageProps
redirect("/pages?notice=Page+deleted") 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 ( return (
<AdminShell <AdminShell
role={role} role={role}
@@ -226,6 +276,132 @@ export default async function PageEditorPage({ params, searchParams }: PageProps
</form> </form>
</section> </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"> <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> <h3 className="text-lg font-medium text-red-800">Danger Zone</h3>
<p className="mt-1 text-sm text-red-700"> <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 { notFound } from "next/navigation"
import { PublicPageView } from "@/components/public-page-view" import { PublicPageView } from "@/components/public-page-view"
@@ -6,12 +6,12 @@ import { PublicPageView } from "@/components/public-page-view"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
type PageProps = { type PageProps = {
params: Promise<{ slug: string }> params: Promise<{ locale: string; slug: string }>
} }
export default async function CmsPageRoute({ params }: PageProps) { export default async function CmsPageRoute({ params }: PageProps) {
const { slug } = await params const { locale, slug } = await params
const page = await getPublishedPageBySlug(slug) const page = await getPublishedPageBySlugForLocale(slug, locale)
if (!page) { if (!page) {
notFound() notFound()

View File

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

View File

@@ -1,10 +1,15 @@
import { listPosts } from "@cms/db" import { listPostsForLocale } from "@cms/db"
import Link from "next/link" import Link from "next/link"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
export default async function PublicNewsIndexPage() { type PublicNewsIndexPageProps = {
const posts = await listPosts() params: Promise<{ locale: string }>
}
export default async function PublicNewsIndexPage({ params }: PublicNewsIndexPageProps) {
const { locale } = await params
const posts = await listPostsForLocale(locale)
return ( return (
<section className="mx-auto w-full max-w-4xl space-y-4 px-6 py-16"> <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 { Button } from "@cms/ui/button"
import { getTranslations } from "next-intl/server" import { getTranslations } from "next-intl/server"
import { PublicAnnouncements } from "@/components/public-announcements" import { PublicAnnouncements } from "@/components/public-announcements"
@@ -6,9 +6,15 @@ import { PublicPageView } from "@/components/public-page-view"
export const dynamic = "force-dynamic" 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([ const [homePage, posts, t] = await Promise.all([
getPublishedPageBySlug("home"), getPublishedPageBySlugForLocale("home", locale),
listPosts(), listPosts(),
getTranslations("Home"), getTranslations("Home"),
]) ])

View File

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

View File

@@ -1,6 +1,7 @@
import { z } from "zod" import { z } from "zod"
export const pageStatusSchema = z.enum(["draft", "published"]) export const pageStatusSchema = z.enum(["draft", "published"])
export const pageLocaleSchema = z.enum(["de", "en", "es", "fr"])
export const createPageInputSchema = z.object({ export const createPageInputSchema = z.object({
title: z.string().min(1).max(180), title: z.string().min(1).max(180),
@@ -23,6 +24,16 @@ export const updatePageInputSchema = z.object({
seoDescription: z.string().max(320).nullable().optional(), 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({ export const createNavigationMenuInputSchema = z.object({
name: z.string().min(1).max(180), name: z.string().min(1).max(180),
slug: 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 CreatePageInput = z.infer<typeof createPageInputSchema>
export type UpdatePageInput = z.infer<typeof updatePageInputSchema> export type UpdatePageInput = z.infer<typeof updatePageInputSchema>
export type UpsertPageTranslationInput = z.infer<typeof upsertPageTranslationInputSchema>
export type CreateNavigationMenuInput = z.infer<typeof createNavigationMenuInputSchema> export type CreateNavigationMenuInput = z.infer<typeof createNavigationMenuInputSchema>
export type CreateNavigationItemInput = z.infer<typeof createNavigationItemInputSchema> export type CreateNavigationItemInput = z.infer<typeof createNavigationItemInputSchema>
export type UpdateNavigationItemInput = z.infer<typeof updateNavigationItemInputSchema> 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 status String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt 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 { model User {
@@ -267,10 +283,28 @@ model Page {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
navItems NavigationItem[] navItems NavigationItem[]
translations PageTranslation[]
@@index([status]) @@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 { model NavigationMenu {
id String @id @default(uuid()) id String @id @default(uuid())
name String name String
@@ -297,6 +331,7 @@ model NavigationItem {
page Page? @relation(fields: [pageId], references: [id], onDelete: SetNull) page Page? @relation(fields: [pageId], references: [id], onDelete: SetNull)
parent NavigationItem? @relation("NavigationItemParent", fields: [parentId], references: [id], onDelete: Cascade) parent NavigationItem? @relation("NavigationItemParent", fields: [parentId], references: [id], onDelete: Cascade)
children NavigationItem[] @relation("NavigationItemParent") children NavigationItem[] @relation("NavigationItemParent")
translations NavigationItemTranslation[]
@@index([menuId]) @@index([menuId])
@@index([pageId]) @@index([pageId])
@@ -304,6 +339,19 @@ model NavigationItem {
@@unique([menuId, parentId, sortOrder, label]) @@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 { model Customer {
id String @id @default(uuid()) id String @id @default(uuid())
name String name String

View File

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

View File

@@ -7,6 +7,11 @@ const { mockDb } = vi.hoisted(() => ({
update: vi.fn(), update: vi.fn(),
delete: vi.fn(), delete: vi.fn(),
findUnique: vi.fn(), findUnique: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
},
pageTranslation: {
upsert: vi.fn(),
findMany: vi.fn(), findMany: vi.fn(),
}, },
navigationMenu: { navigationMenu: {
@@ -19,6 +24,9 @@ const { mockDb } = vi.hoisted(() => ({
update: vi.fn(), update: vi.fn(),
delete: vi.fn(), delete: vi.fn(),
}, },
navigationItemTranslation: {
upsert: vi.fn(),
},
}, },
})) }))
@@ -30,8 +38,11 @@ import {
createNavigationItem, createNavigationItem,
createNavigationMenu, createNavigationMenu,
createPage, createPage,
getPublishedPageBySlugForLocale,
listPublicNavigation, listPublicNavigation,
updatePage, updatePage,
upsertNavigationItemTranslation,
upsertPageTranslation,
} from "./pages-navigation" } from "./pages-navigation"
describe("pages-navigation service", () => { describe("pages-navigation service", () => {
@@ -105,19 +116,89 @@ describe("pages-navigation service", () => {
slug: "home", slug: "home",
status: "published", status: "published",
}, },
translations: [{ locale: "de", label: "Startseite" }],
}, },
], ],
}) })
const navigation = await listPublicNavigation("header") const navigation = await listPublicNavigation("header", "de")
expect(navigation).toEqual([ expect(navigation).toEqual([
{ {
id: "item-1", id: "item-1",
label: "Home", label: "Startseite",
href: "/", href: "/",
children: [], 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, createPageInputSchema,
updateNavigationItemInputSchema, updateNavigationItemInputSchema,
updatePageInputSchema, updatePageInputSchema,
upsertPageTranslationInputSchema,
} from "@cms/content" } from "@cms/content"
import { z } from "zod"
import { db } from "./client" import { db } from "./client"
@@ -15,6 +17,13 @@ export type PublicNavigationItem = {
children: 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 { function resolvePublishedAt(status: string): Date | null {
return status === "published" ? new 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) { export async function createPage(input: unknown) {
const payload = createPageInputSchema.parse(input) 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() { export async function listNavigationMenus() {
return db.navigationMenu.findMany({ return db.navigationMenu.findMany({
orderBy: [{ location: "asc" }, { name: "asc" }], orderBy: [{ location: "asc" }, { name: "asc" }],
@@ -99,6 +167,9 @@ export async function listNavigationMenus() {
slug: true, slug: true,
}, },
}, },
translations: {
orderBy: [{ locale: "asc" }],
},
}, },
}, },
}, },
@@ -123,7 +194,12 @@ function resolveNavigationHref(item: {
return null 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({ const menu = await db.navigationMenu.findFirst({
where: { where: {
location, location,
@@ -143,6 +219,12 @@ export async function listPublicNavigation(location = "header"): Promise<PublicN
status: true, 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, { itemMap.set(item.id, {
id: item.id, id: item.id,
label: item.label, label: item.translations?.[0]?.label ?? item.label,
href, href,
parentId: item.parentId, parentId: item.parentId,
children: [], children: [],
@@ -238,3 +320,20 @@ export async function deleteNavigationItem(id: string) {
where: { id }, 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(), update: vi.fn(),
delete: vi.fn(), delete: vi.fn(),
}, },
postTranslation: {
upsert: vi.fn(),
},
}, },
})) }))
@@ -16,7 +19,15 @@ vi.mock("./client", () => ({
db: mockDb, db: mockDb,
})) }))
import { createPost, getPostBySlug, listPosts, updatePost } from "./posts" import {
createPost,
getPostBySlug,
getPostBySlugForLocale,
listPosts,
listPostsForLocale,
updatePost,
upsertPostTranslation,
} from "./posts"
describe("posts service", () => { describe("posts service", () => {
beforeEach(() => { beforeEach(() => {
@@ -25,6 +36,7 @@ describe("posts service", () => {
fn.mockReset() fn.mockReset()
} }
} }
mockDb.postTranslation.upsert.mockReset()
}) })
it("lists posts ordered by update date desc", async () => { 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, updatePostInputSchema,
} from "@cms/content" } from "@cms/content"
import { type CrudAuditHook, type CrudMutationContext, createCrudService } from "@cms/crud" import { type CrudAuditHook, type CrudMutationContext, createCrudService } from "@cms/crud"
import { z } from "zod"
import type { Post } from "../prisma/generated/client/client" import type { Post } from "../prisma/generated/client/client"
import { db } from "./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 postAuditHooks: Array<CrudAuditHook<Post>> = []
const postCrudService = createCrudService({ 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) { export async function createPost(input: unknown, context?: CrudMutationContext) {
return postCrudService.create(input, context) return postCrudService.create(input, context)
} }