fix(admin): content-pages éditait FR quel que soit le lien cliqué — support multilang complet

This commit is contained in:
Claude Integration 2026-06-01 00:49:31 +00:00
parent c8c97e467d
commit a5ae692cf4
3 changed files with 204 additions and 60 deletions

View file

@ -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}`);

View file

@ -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 (
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
<Link
href="/admin/content-pages"
className="text-sm text-gray-600 hover:text-gray-900"
>
Toutes les pages
</Link>
<h1 className="mt-3 text-2xl font-semibold">Éditer · {page.title}</h1>
<p className="mt-1 text-sm text-gray-600">
URL publique : <code>/{page.slug}</code>
</p>
<div className="mx-auto max-w-4xl">
<header className="mt-2">
<Link href="/admin/content-pages" className="text-xs text-zinc-500 hover:text-zinc-900">
Toutes les pages
</Link>
<h1 className="mt-1 flex flex-wrap items-center gap-3 text-2xl font-semibold text-zinc-900">
{page.title}
<span className="rounded-full bg-zinc-900 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider text-white">
{page.lang}
</span>
</h1>
<p className="mt-1 text-sm text-zinc-500">
URL publique : <code>/{page.slug}</code>
{page.lang !== "fr" ? ` · variante ${page.lang}` : ""}
</p>
{siblings.length > 1 ? (
<nav className="mt-3 flex flex-wrap items-center gap-1.5 text-xs">
<span className="text-zinc-500">Versions :</span>
{siblings.map((s) => {
const active = s.lang === page.lang;
return (
<Link
key={s.lang}
href={`/admin/content-pages/${encodeURIComponent(slug)}?lang=${s.lang}`}
className={
"rounded-md px-2.5 py-1 font-semibold uppercase tracking-wider transition " +
(active
? "bg-zinc-900 text-white"
: "border border-zinc-300 bg-white text-zinc-700 hover:bg-zinc-50")
}
title={s.title + (s.published ? "" : " (dépublié)")}
>
{s.lang}
{!s.published ? " ·" : ""}
</Link>
);
})}
</nav>
) : null}
</header>
<div className="mt-6">
<EditorForm page={page} />
</div>

View file

@ -10,50 +10,146 @@ const CATEGORY_LABEL: Record<string, string> = {
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<Record<string, typeof pages>>((acc, p) => {
// Regrouper par slug — chaque slug peut avoir plusieurs traductions.
const bySlug = new Map<string, GroupedPage>();
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<Record<string, GroupedPage[]>>((acc, p) => {
(acc[p.category] ??= []).push(p);
return acc;
}, {});
return (
<div className="mx-auto max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
<h1 className="text-2xl font-semibold">Pages éditoriales</h1>
<p className="mt-2 text-sm text-gray-600">
Pages markdown affichées dans le site public. La catégorie « Général »
est gérée par le plugin <code>content-pages</code>, la catégorie « Légales »
par <code>legal-pages</code>. Désactiver le plugin dépublie ses pages
sans les supprimer.
</p>
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
<div className="mt-6 space-y-8">
return (
<div className="mx-auto max-w-5xl">
<header className="mb-5 mt-2">
<h1 className="text-2xl font-semibold text-zinc-900">Pages éditoriales</h1>
<p className="mt-2 text-sm text-zinc-600">
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.
</p>
</header>
<div className="space-y-8">
{Object.entries(byCategory).map(([cat, list]) => (
<section key={cat}>
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-gray-500">
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wider text-zinc-500">
{CATEGORY_LABEL[cat] ?? cat}
</h2>
<ul className="divide-y divide-gray-200 rounded-lg border border-gray-200 bg-white">
{list.map((p) => (
<li key={p.slug} className="flex items-center justify-between gap-4 px-4 py-3">
<div>
<div className="font-medium">{p.title}</div>
<div className="text-xs text-gray-500">
<code>/{p.slug}</code> · {p.published ? "publié" : "dépublié"} ·
mis à jour le {new Date(p.updatedAt).toLocaleDateString("fr-FR")}
</div>
</div>
<Link
href={`/admin/content-pages/${encodeURIComponent(p.slug)}`}
className="rounded-full bg-gray-900 px-3 py-1 text-xs font-semibold text-white hover:bg-gray-800"
>
Éditer
</Link>
</li>
))}
</ul>
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
<table className="w-full text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-4 py-2 text-left font-semibold">Slug</th>
<th className="px-4 py-2 text-left font-semibold">Titre (FR)</th>
<th className="px-4 py-2 text-left font-semibold">Traductions</th>
<th className="px-4 py-2 text-right font-semibold">MAJ</th>
<th className="px-4 py-2 text-right font-semibold">Éditer</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{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 (
<tr key={p.slug} className="hover:bg-zinc-50">
<td className="px-4 py-2 font-mono text-[11px] text-zinc-700">/{p.slug}</td>
<td className="px-4 py-2">
{fr ? (
<>
<span className="font-medium text-zinc-900">{fr.title}</span>
{!fr.published ? (
<span className="ml-2 rounded-full bg-amber-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-800 ring-1 ring-inset ring-amber-300">
dépublié
</span>
) : null}
</>
) : (
<span className="text-zinc-400"> (pas de version FR)</span>
)}
</td>
<td className="px-4 py-2 text-xs text-zinc-700">
{others.length === 0 ? (
<span className="text-zinc-400"></span>
) : (
<span className="flex flex-wrap gap-1">
{others.map((t) => (
<span
key={t.lang}
className={
"inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider ring-1 ring-inset " +
(t.published
? "bg-emerald-100 text-emerald-800 ring-emerald-300"
: "bg-zinc-100 text-zinc-500 ring-zinc-300")
}
title={t.title}
>
{t.lang}
</span>
))}
</span>
)}
</td>
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">
{lastUpdated ? dateFmt.format(new Date(lastUpdated)) : "—"}
</td>
<td className="px-4 py-2 text-right">
<span className="inline-flex flex-wrap justify-end gap-1">
{p.translations
.sort((a, b) => (a.lang === "fr" ? -1 : b.lang === "fr" ? 1 : a.lang.localeCompare(b.lang)))
.map((t) => (
<Link
key={t.lang}
href={`/admin/content-pages/${encodeURIComponent(p.slug)}?lang=${t.lang}`}
className="rounded-md bg-zinc-900 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider text-white hover:bg-zinc-800"
>
{t.lang}
</Link>
))}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
))}
</div>