From a5ae692cf46ba7e0304c1086c34381f4e54b56c3 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Mon, 1 Jun 2026 00:49:31 +0000 Subject: [PATCH 01/45] =?UTF-8?q?fix(admin):=20content-pages=20=C3=A9ditai?= =?UTF-8?q?t=20FR=20quel=20que=20soit=20le=20lien=20cliqu=C3=A9=20?= =?UTF-8?q?=E2=80=94=20support=20multilang=20complet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[slug]/_components/EditorForm.tsx | 14 +- src/app/admin/content-pages/[slug]/page.tsx | 90 +++++++--- src/app/admin/content-pages/page.tsx | 160 ++++++++++++++---- 3 files changed, 204 insertions(+), 60 deletions(-) diff --git a/src/app/admin/content-pages/[slug]/_components/EditorForm.tsx b/src/app/admin/content-pages/[slug]/_components/EditorForm.tsx index 64e3818..0f2d54a 100644 --- a/src/app/admin/content-pages/[slug]/_components/EditorForm.tsx +++ b/src/app/admin/content-pages/[slug]/_components/EditorForm.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; type Page = { slug: string; + lang: string; title: string; body: string; category: string; @@ -25,11 +26,14 @@ export default function EditorForm({ page }: { page: Page }) { setMsg(null); setErr(null); try { - const res = await fetch(`/api/admin/content-pages/${encodeURIComponent(page.slug)}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title, body, published }), - }); + const res = await fetch( + `/api/admin/content-pages/${encodeURIComponent(page.slug)}?lang=${encodeURIComponent(page.lang)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title, body, published }), + }, + ); if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j?.error || `HTTP ${res.status}`); diff --git a/src/app/admin/content-pages/[slug]/page.tsx b/src/app/admin/content-pages/[slug]/page.tsx index d9e6b25..db82c4b 100644 --- a/src/app/admin/content-pages/[slug]/page.tsx +++ b/src/app/admin/content-pages/[slug]/page.tsx @@ -2,46 +2,90 @@ import { notFound } from "next/navigation"; import Link from "next/link"; import { requireRole } from "@/lib/authorization"; import { UserRole } from "@/generated/prisma/enums"; -import { getContentPage } from "@/lib/content-pages"; import { prisma } from "@/lib/prisma"; import EditorForm from "./_components/EditorForm"; export const dynamic = "force-dynamic"; -type PageProps = { params: Promise<{ slug: string }> }; +type PageProps = { + params: Promise<{ slug: string }>; + searchParams: Promise<{ lang?: string }>; +}; -export default async function EditContentPage({ params }: PageProps) { +function normalizeLang(v: string | undefined): string { + if (!v) return "fr"; + const l = v.toLowerCase().trim(); + return /^[a-z]{2}$/.test(l) ? l : "fr"; +} + +export default async function EditContentPage({ params, searchParams }: PageProps) { await requireRole([UserRole.ADMIN]); const { slug } = await params; - // Pas getContentPage : il filtre published=true. Ici on veut tout voir. - // Admin édite la version FR par défaut. (Édition EN = future feature.) - const row = await prisma.contentPage.findUnique({ - where: { slug_lang: { slug, lang: "fr" } }, - }); + const sp = await searchParams; + const lang = normalizeLang(sp.lang); + + const [row, siblings] = await Promise.all([ + prisma.contentPage.findUnique({ where: { slug_lang: { slug, lang } } }), + prisma.contentPage.findMany({ + where: { slug }, + select: { lang: true, title: true, published: true, updatedAt: true }, + orderBy: { lang: "asc" }, + }), + ]); if (!row) notFound(); - // Re-construction du type minimal attendu par le formulaire. + const page = { slug: row.slug, + lang: row.lang, title: row.title, body: row.body, category: row.category, published: row.published, - updatedAt: row.updatedAt, }; - // Mute eslint sur le _ = getContentPage (gardé importé pour la cohérence future). - void getContentPage; + return ( -
- - ← Toutes les pages - -

Éditer · {page.title}

-

- URL publique : /{page.slug} -

+
+
+ + ← Toutes les pages + +

+ {page.title} + + {page.lang} + +

+

+ URL publique : /{page.slug} + {page.lang !== "fr" ? ` · variante ${page.lang}` : ""} +

+ + {siblings.length > 1 ? ( + + ) : null} +
+
diff --git a/src/app/admin/content-pages/page.tsx b/src/app/admin/content-pages/page.tsx index 263e290..0f9f2ab 100644 --- a/src/app/admin/content-pages/page.tsx +++ b/src/app/admin/content-pages/page.tsx @@ -10,50 +10,146 @@ const CATEGORY_LABEL: Record = { legal: "Légales", }; +type Translation = { + lang: string; + title: string; + published: boolean; + updatedAt: Date; +}; + +type GroupedPage = { + slug: string; + category: string; + translations: Translation[]; +}; + export default async function ContentPagesAdminPage() { await requireRole([UserRole.ADMIN]); - const pages = await listContentPages(); + const rows = await listContentPages(); - const byCategory = pages.reduce>((acc, p) => { + // Regrouper par slug — chaque slug peut avoir plusieurs traductions. + const bySlug = new Map(); + for (const r of rows) { + const existing = bySlug.get(r.slug); + const t: Translation = { + lang: r.lang, + title: r.title, + published: r.published, + updatedAt: r.updatedAt, + }; + if (existing) { + existing.translations.push(t); + } else { + bySlug.set(r.slug, { slug: r.slug, category: r.category, translations: [t] }); + } + } + const pages = Array.from(bySlug.values()).sort((a, b) => a.slug.localeCompare(b.slug)); + + const byCategory = pages.reduce>((acc, p) => { (acc[p.category] ??= []).push(p); return acc; }, {}); - return ( -
-

Pages éditoriales

-

- Pages markdown affichées dans le site public. La catégorie « Général » - est gérée par le plugin content-pages, la catégorie « Légales » - par legal-pages. Désactiver le plugin dépublie ses pages - sans les supprimer. -

+ const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" }); -
+ return ( +
+
+

Pages éditoriales

+

+ Pages markdown servies par le site public. Chaque page existe en une ou + plusieurs langues — utilisez le bouton de la langue voulue pour éditer + la bonne version. +

+
+ +
{Object.entries(byCategory).map(([cat, list]) => (
-

+

{CATEGORY_LABEL[cat] ?? cat}

-
    - {list.map((p) => ( -
  • -
    -
    {p.title}
    -
    - /{p.slug} · {p.published ? "publié" : "dépublié"} · - mis à jour le {new Date(p.updatedAt).toLocaleDateString("fr-FR")} -
    -
    - - Éditer - -
  • - ))} -
+
+ + + + + + + + + + + + {list.map((p) => { + const fr = p.translations.find((t) => t.lang === "fr"); + const others = p.translations.filter((t) => t.lang !== "fr").sort((a, b) => a.lang.localeCompare(b.lang)); + const lastUpdated = p.translations + .map((t) => t.updatedAt.getTime()) + .reduce((a, b) => Math.max(a, b), 0); + return ( + + + + + + + + ); + })} + +
SlugTitre (FR)TraductionsMAJÉditer
/{p.slug} + {fr ? ( + <> + {fr.title} + {!fr.published ? ( + + dépublié + + ) : null} + + ) : ( + — (pas de version FR) + )} + + {others.length === 0 ? ( + + ) : ( + + {others.map((t) => ( + + {t.lang} + + ))} + + )} + + {lastUpdated ? dateFmt.format(new Date(lastUpdated)) : "—"} + + + {p.translations + .sort((a, b) => (a.lang === "fr" ? -1 : b.lang === "fr" ? 1 : a.lang.localeCompare(b.lang))) + .map((t) => ( + + {t.lang} + + ))} + +
+
))}
From 1f8dd90979ab629d675da60d4e7f3b01b65ee1fa Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Mon, 1 Jun 2026 00:51:19 +0000 Subject: [PATCH 02/45] =?UTF-8?q?fix(admin):=20PATCH=20content-pages=20res?= =?UTF-8?q?pecte=20=3Flang=3D=20(sinon=20=C3=A9crasait=20FR)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/admin/content-pages/[slug]/route.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/app/api/admin/content-pages/[slug]/route.ts b/src/app/api/admin/content-pages/[slug]/route.ts index c2db7dd..df9ee1d 100644 --- a/src/app/api/admin/content-pages/[slug]/route.ts +++ b/src/app/api/admin/content-pages/[slug]/route.ts @@ -11,21 +11,28 @@ const patchSchema = z.object({ published: z.boolean().optional(), }); +function normalizeLang(v: string | null): string { + if (!v) return "fr"; + const l = v.toLowerCase().trim(); + return /^[a-z]{2}$/.test(l) ? l : "fr"; +} + export async function PATCH(req: Request, ctx: { params: Promise<{ slug: string }> }) { await requireRole([UserRole.ADMIN]); const { slug } = await ctx.params; + const url = new URL(req.url); + const lang = normalizeLang(url.searchParams.get("lang")); const session = await auth(); const parsed = patchSchema.safeParse(await req.json().catch(() => ({}))); if (!parsed.success) { return NextResponse.json({ error: "Invalid payload" }, { status: 400 }); } - // L'admin édite la version FR par défaut (édition multi-langues à venir). const existing = await prisma.contentPage.findUnique({ - where: { slug_lang: { slug, lang: "fr" } }, + where: { slug_lang: { slug, lang } }, }); if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 }); const updated = await prisma.contentPage.update({ - where: { slug_lang: { slug, lang: "fr" } }, + where: { slug_lang: { slug, lang } }, data: { ...(parsed.data.title !== undefined ? { title: parsed.data.title } : {}), ...(parsed.data.body !== undefined ? { body: parsed.data.body } : {}), @@ -35,6 +42,7 @@ export async function PATCH(req: Request, ctx: { params: Promise<{ slug: string }); return NextResponse.json({ slug: updated.slug, + lang: updated.lang, title: updated.title, published: updated.published, updatedAt: updated.updatedAt, From a9fcd18022ac0271bc6af1e6b306ec37cf03c0ac Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Mon, 1 Jun 2026 01:10:49 +0000 Subject: [PATCH 03/45] =?UTF-8?q?feat(admin):=20/admin/home=20=E2=80=94=20?= =?UTF-8?q?=C3=A9diteur=20des=20textes=20de=20la=20page=20d'accueil=20(FR+?= =?UTF-8?q?EN,=20override=20DB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 9 + prisma/schema.prisma | 11 ++ .../home/_components/HomeTranslationsForm.tsx | 169 ++++++++++++++++++ src/app/admin/home/actions.ts | 67 +++++++ src/app/admin/home/page.tsx | 39 ++++ src/components/admin/Sidebar.tsx | 5 +- src/lib/admin/home-keys.ts | 51 ++++++ src/lib/admin/translations.ts | 72 ++++++++ src/lib/i18n/overrides.ts | 59 ++++++ src/lib/i18n/server.ts | 11 +- 10 files changed, 491 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20260601120000_translation_overrides/migration.sql create mode 100644 src/app/admin/home/_components/HomeTranslationsForm.tsx create mode 100644 src/app/admin/home/actions.ts create mode 100644 src/app/admin/home/page.tsx create mode 100644 src/lib/admin/home-keys.ts create mode 100644 src/lib/admin/translations.ts create mode 100644 src/lib/i18n/overrides.ts diff --git a/prisma/migrations/20260601120000_translation_overrides/migration.sql b/prisma/migrations/20260601120000_translation_overrides/migration.sql new file mode 100644 index 0000000..5f3bfb5 --- /dev/null +++ b/prisma/migrations/20260601120000_translation_overrides/migration.sql @@ -0,0 +1,9 @@ +CREATE TABLE "Translation" ( + "key" TEXT NOT NULL, + "lang" TEXT NOT NULL, + "value" TEXT NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + "updatedBy" TEXT, + CONSTRAINT "Translation_pkey" PRIMARY KEY ("key", "lang") +); +CREATE INDEX "Translation_lang_idx" ON "Translation"("lang"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d0cc722..caa7314 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -348,3 +348,14 @@ model Setting { updatedAt DateTime @updatedAt updatedBy String? } + +model Translation { + key String + lang String + value String + updatedAt DateTime @updatedAt + updatedBy String? + + @@id([key, lang]) + @@index([lang]) +} diff --git a/src/app/admin/home/_components/HomeTranslationsForm.tsx b/src/app/admin/home/_components/HomeTranslationsForm.tsx new file mode 100644 index 0000000..e0bc7c0 --- /dev/null +++ b/src/app/admin/home/_components/HomeTranslationsForm.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { useMemo, useState, useTransition } from "react"; +import { saveHomeTranslationsAction } from "../actions"; + +type Row = { + key: string; + baseFr: string; + baseEn: string; + overrideFr: string | null; + overrideEn: string | null; +}; + +type Section = { + id: string; + label: string; + description: string; + rows: Row[]; +}; + +type Props = { + sections: Section[]; +}; + +function autoRows(text: string): number { + const lines = text.split("\n").length; + return Math.min(8, Math.max(1, lines)); +} + +export function HomeTranslationsForm({ sections }: Props) { + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + // État local : on garde uniquement la valeur courante (initialisée avec override ?? base). + // Le baseValue est posé en input caché et sert au backend pour décider override vs reset. + const initial = useMemo(() => { + const m = new Map(); + for (const s of sections) { + for (const r of s.rows) { + m.set(r.key, { fr: r.overrideFr ?? r.baseFr, en: r.overrideEn ?? r.baseEn }); + } + } + return m; + }, [sections]); + + function onSubmit(formData: FormData) { + setError(null); + setSuccess(null); + startTransition(async () => { + const res = await saveHomeTranslationsAction(formData); + if (res.ok === false) { + setError(res.error); + } else { + const parts: string[] = []; + if (res.saved) parts.push(`${res.saved} sauvegardé${res.saved > 1 ? "s" : ""}`); + if (res.reset) parts.push(`${res.reset} réinitialisé${res.reset > 1 ? "s" : ""} (valeur de base)`); + setSuccess(parts.length > 0 ? parts.join(" · ") : "Aucun changement."); + } + }); + } + + // On crée un seul formulaire global qui contient toutes les sections. + let counter = 0; + + return ( +
+
+ {sections.map((section) => ( +
+
+

+ {section.label} +

+

{section.description}

+
+ +
+ {section.rows.map((r) => { + const idxFr = counter++; + const idxEn = counter++; + const init = initial.get(r.key)!; + const hasOverrideFr = r.overrideFr !== null; + const hasOverrideEn = r.overrideEn !== null; + return ( +
+
+ {r.key} + + {hasOverrideFr ? ( + + FR modifié + + ) : null} + {hasOverrideEn ? ( + + EN modifié + + ) : null} + +
+ +
+