feat(plugins): content-pages + legal-pages (Phase 4.1 + 4.3)

Plugin content-pages :
- Modèle Prisma ContentPage (slug PK, title, body markdown, category, published)
- lib/content-pages.ts : helpers upsert/get/list/unpublish
- lib/markdown.ts : mini-renderer markdown server-side sans deps externe
  (h1-h3, paragraphes, gras/italique, liens, listes ul/ol, hr, blockquote,
  échappement HTML)
- ContentPageRenderer server component, applique le theme Guyane (font-serif)
- 5 pages seedées : /a-propos, /faq, /comment-ca-marche,
  /pour-comites-entreprise, /devenir-loueur
- Routes publiques + force-dynamic + guard requirePluginOr404

Plugin legal-pages :
- Réutilise le même modèle ContentPage, catégorie 'legal'
- 3 pages seedées : /cgv, /mentions-legales, /politique-de-confidentialite
  (contenu de base, à valider par avocat avant prod réelle)

Admin :
- /admin/content-pages : table par catégorie, statut publié/dépublié
- /admin/content-pages/[slug] : éditeur markdown + toggle publié
- PATCH /api/admin/content-pages/[slug]

Hooks plugin :
- onEnable seed + republish toutes les pages
- onDisable dépublie toute la catégorie sans la supprimer (preserve les edits)
This commit is contained in:
Claude Integration 2026-05-31 10:12:13 +00:00
parent ae8f79b436
commit 68f37f554f
20 changed files with 1116 additions and 0 deletions

View file

@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
type Page = {
slug: string;
title: string;
body: string;
category: string;
published: boolean;
};
export default function EditorForm({ page }: { page: Page }) {
const router = useRouter();
const [title, setTitle] = useState(page.title);
const [body, setBody] = useState(page.body);
const [published, setPublished] = useState(page.published);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
async function save() {
setBusy(true);
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 }),
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j?.error || `HTTP ${res.status}`);
}
setMsg("Sauvegardé.");
router.refresh();
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}
return (
<div className="space-y-4">
<label className="block">
<span className="text-sm font-medium text-gray-700">Titre</span>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2"
/>
</label>
<label className="block">
<span className="text-sm font-medium text-gray-700">
Contenu (markdown léger : # ## ### gras italique [link](url) listes - 1. ---)
</span>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
rows={24}
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-sm leading-relaxed"
/>
</label>
<label className="inline-flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={published}
onChange={(e) => setPublished(e.target.checked)}
/>
Publié
</label>
<div className="flex items-center gap-3">
<button
type="button"
onClick={save}
disabled={busy}
className="rounded-full bg-gray-900 px-5 py-2 text-sm font-semibold text-white hover:bg-gray-800 disabled:opacity-50"
>
{busy ? "Sauvegarde…" : "Sauvegarder"}
</button>
{msg ? <span className="text-sm text-green-700">{msg}</span> : null}
{err ? <span className="text-sm text-red-700">{err}</span> : null}
</div>
</div>
);
}

View file

@ -0,0 +1,47 @@
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 }> };
export default async function EditContentPage({ params }: PageProps) {
await requireRole([UserRole.ADMIN]);
const { slug } = await params;
// Pas getContentPage : il filtre published=true. Ici on veut tout voir.
const row = await prisma.contentPage.findUnique({ where: { slug } });
if (!row) notFound();
// Re-construction du type minimal attendu par le formulaire.
const page = {
slug: row.slug,
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="mt-6">
<EditorForm page={page} />
</div>
</div>
);
}

View file

@ -0,0 +1,62 @@
import Link from "next/link";
import { requireRole } from "@/lib/authorization";
import { UserRole } from "@/generated/prisma/enums";
import { listContentPages } from "@/lib/content-pages";
export const dynamic = "force-dynamic";
const CATEGORY_LABEL: Record<string, string> = {
general: "Général",
legal: "Légales",
};
export default async function ContentPagesAdminPage() {
await requireRole([UserRole.ADMIN]);
const pages = await listContentPages();
const byCategory = pages.reduce<Record<string, typeof pages>>((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>
<div className="mt-6 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">
{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>
</section>
))}
</div>
</div>
);
}