From 618319dbc2c2be7b29757f1e126bb4abed0d348e Mon Sep 17 00:00:00 2001 From: Citali Date: Thu, 12 Feb 2026 20:57:42 +0100 Subject: [PATCH] feat(i18n): wire page translation editor and locale rendering --- TODO.md | 3 +- apps/admin/src/app/pages/[id]/page.tsx | 180 +++++++++++++++++++++- apps/web/src/app/[locale]/[slug]/page.tsx | 8 +- apps/web/src/app/[locale]/page.tsx | 12 +- 4 files changed, 193 insertions(+), 10 deletions(-) diff --git a/TODO.md b/TODO.md index be6a607..c05d960 100644 --- a/TODO.md +++ b/TODO.md @@ -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 @@ -322,6 +322,7 @@ This file is the single source of truth for roadmap and delivery progress. - [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 diff --git a/apps/admin/src/app/pages/[id]/page.tsx b/apps/admin/src/app/pages/[id]/page.tsx index 2230520..3c524d5 100644 --- a/apps/admin/src/app/pages/[id]/page.tsx +++ b/apps/admin/src/app/pages/[id]/page.tsx @@ -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 +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 ( +
+
+

Translations

+

+ Add locale-specific page content. Missing locales fall back to base page fields. +

+
+ +
+ {SUPPORTED_LOCALES.map((locale) => { + const isActive = locale === selectedLocale + const hasTranslation = translations.some((entry) => entry.locale === locale) + + return ( + + {locale.toUpperCase()} + + {hasTranslation ? "saved" : "missing"} + + + ) + })} +
+ + {translations.length > 0 ? ( +
+ + + + + + + + + + {translations.map((translation) => ( + + + + + + ))} + +
LocaleTitleUpdated
{translation.locale.toUpperCase()}{translation.title} + {translation.updatedAt.toLocaleDateString("en-US")} +
+
+ ) : null} + +
+ + + + + + +