fix(admin): content-pages éditait FR quel que soit le lien cliqué — support multilang complet
This commit is contained in:
parent
c8c97e467d
commit
a5ae692cf4
3 changed files with 204 additions and 60 deletions
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue