feat(admin): /admin/home — éditeur des textes de la page d'accueil (FR+EN, override DB)

This commit is contained in:
Claude Integration 2026-06-01 01:10:49 +00:00
parent d3cc5bdfb9
commit a9fcd18022
10 changed files with 491 additions and 2 deletions

View file

@ -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<string | null>(null);
const [success, setSuccess] = useState<string | null>(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<string, { fr: string; en: string }>();
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 (
<form action={onSubmit} className="space-y-8">
<fieldset disabled={pending} className="space-y-8">
{sections.map((section) => (
<section key={section.id} className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<header className="mb-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-zinc-700">
{section.label}
</h2>
<p className="mt-0.5 text-xs text-zinc-500">{section.description}</p>
</header>
<div className="space-y-4">
{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 (
<div key={r.key} className="rounded-md border border-zinc-100 bg-zinc-50/50 p-3">
<div className="mb-2 flex flex-wrap items-baseline justify-between gap-2">
<code className="text-[11px] font-mono text-zinc-600">{r.key}</code>
<span className="flex gap-1 text-[10px] uppercase tracking-wider">
{hasOverrideFr ? (
<span className="rounded-full bg-emerald-100 px-1.5 py-0.5 font-semibold text-emerald-800 ring-1 ring-inset ring-emerald-300">
FR modifié
</span>
) : null}
{hasOverrideEn ? (
<span className="rounded-full bg-emerald-100 px-1.5 py-0.5 font-semibold text-emerald-800 ring-1 ring-inset ring-emerald-300">
EN modifié
</span>
) : null}
</span>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<label className="block">
<span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-zinc-500">
FR
</span>
<input type="hidden" name={`entries[${idxFr}][key]`} value={r.key} />
<input type="hidden" name={`entries[${idxFr}][lang]`} value="fr" />
<input type="hidden" name={`entries[${idxFr}][baseValue]`} value={r.baseFr} />
<textarea
name={`entries[${idxFr}][value]`}
rows={autoRows(init.fr)}
defaultValue={init.fr}
maxLength={4000}
className="w-full rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm leading-relaxed focus:border-zinc-900 focus:outline-none"
/>
<span className="mt-0.5 block text-[10px] text-zinc-400">
Base : <span className="italic">{r.baseFr.slice(0, 80)}{r.baseFr.length > 80 ? "…" : ""}</span>
</span>
</label>
<label className="block">
<span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-zinc-500">
EN
</span>
<input type="hidden" name={`entries[${idxEn}][key]`} value={r.key} />
<input type="hidden" name={`entries[${idxEn}][lang]`} value="en" />
<input type="hidden" name={`entries[${idxEn}][baseValue]`} value={r.baseEn} />
<textarea
name={`entries[${idxEn}][value]`}
rows={autoRows(init.en)}
defaultValue={init.en}
maxLength={4000}
className="w-full rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm leading-relaxed focus:border-zinc-900 focus:outline-none"
/>
<span className="mt-0.5 block text-[10px] text-zinc-400">
Base : <span className="italic">{r.baseEn.slice(0, 80)}{r.baseEn.length > 80 ? "…" : ""}</span>
</span>
</label>
</div>
</div>
);
})}
</div>
</section>
))}
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
) : null}
{success ? (
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
) : null}
<div className="sticky bottom-3 flex items-center justify-end gap-3 rounded-lg border border-zinc-200 bg-white px-4 py-3 shadow-md">
<span className="text-xs text-zinc-500">
Laisser une case vide ou identique au texte de base réinitialise l&apos;override.
</span>
<button
type="submit"
className="rounded-md bg-zinc-900 px-5 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
>
{pending ? "Enregistrement…" : "Enregistrer les modifications"}
</button>
</div>
</fieldset>
</form>
);
}

View file

@ -0,0 +1,67 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { auth } from "@/auth";
import { UserRole } from "@/generated/prisma/enums";
import { requireRole } from "@/lib/authorization";
import { recordAudit } from "@/lib/admin/audit";
import { deleteTranslationOverride, upsertTranslation } from "@/lib/admin/translations";
import { invalidateTranslationCache } from "@/lib/i18n/overrides";
import { isHomeKey } from "@/lib/admin/home-keys";
const entrySchema = z.object({
key: z.string().min(1).max(200),
lang: z.enum(["fr", "en"]),
value: z.string().max(4000),
baseValue: z.string().max(4000),
});
type SaveResult = { ok: true; saved: number; reset: number } | { ok: false; error: string };
export async function saveHomeTranslationsAction(fd: FormData): Promise<SaveResult> {
await requireRole([UserRole.ADMIN]);
const session = await auth();
const actorEmail = session?.user?.email ?? null;
// FormData arrive avec entries[N][key], entries[N][lang], entries[N][value], entries[N][baseValue].
const grouped = new Map<string, Record<string, string>>();
for (const [name, val] of fd.entries()) {
if (typeof val !== "string") continue;
const m = name.match(/^entries\[(\d+)\]\[(key|lang|value|baseValue)\]$/);
if (!m) continue;
const [, idx, field] = m;
if (!grouped.has(idx)) grouped.set(idx, {});
grouped.get(idx)![field] = val;
}
let saved = 0;
let reset = 0;
for (const raw of grouped.values()) {
const parsed = entrySchema.safeParse(raw);
if (!parsed.success) continue;
if (!isHomeKey(parsed.data.key)) continue;
const trimmed = parsed.data.value.trim();
const base = parsed.data.baseValue;
if (trimmed === "" || trimmed === base) {
// Suppression de l'override : on revient à la valeur du fichier.
await deleteTranslationOverride(parsed.data.key, parsed.data.lang);
reset++;
} else {
await upsertTranslation(parsed.data.key, parsed.data.lang, trimmed, actorEmail);
saved++;
}
}
invalidateTranslationCache();
await recordAudit({
scope: "admin.home",
event: "translations.save",
actorEmail,
details: { saved, reset },
});
revalidatePath("/admin/home");
revalidatePath("/");
return { ok: true, saved, reset };
}

View file

@ -0,0 +1,39 @@
import { HOME_SECTIONS } from "@/lib/admin/home-keys";
import { listTranslationsForKeys } from "@/lib/admin/translations";
import { HomeTranslationsForm } from "./_components/HomeTranslationsForm";
export const dynamic = "force-dynamic";
export default async function HomeAdminPage() {
const allKeys = await listTranslationsForKeys(HOME_SECTIONS.flatMap((s) => s.prefixes));
const keysBySection = HOME_SECTIONS.map((s) => ({
id: s.id,
label: s.label,
description: s.description,
rows: allKeys.filter((r) => s.prefixes.some((p) => r.key.startsWith(p))),
}));
const totalOverrides = allKeys.reduce(
(acc, r) => acc + (r.overrideFr !== null ? 1 : 0) + (r.overrideEn !== null ? 1 : 0),
0,
);
return (
<div className="mx-auto max-w-6xl">
<header className="mb-5 mt-2">
<h1 className="text-2xl font-semibold text-zinc-900">Page d&apos;accueil</h1>
<p className="mt-1 text-sm text-zinc-600">
Édition des textes affichés sur la page d&apos;accueil publique, en français et en anglais.
Les modifications sont appliquées immédiatement (cache rafraîchi sous 10 secondes).
</p>
<p className="mt-1 text-xs text-zinc-500">
{totalOverrides === 0
? "Aucun texte personnalisé pour l'instant — les valeurs par défaut viennent des fichiers de traduction."
: `${totalOverrides} valeur${totalOverrides > 1 ? "s" : ""} personnalisée${totalOverrides > 1 ? "s" : ""} actuellement active${totalOverrides > 1 ? "s" : ""}.`}
</p>
</header>
<HomeTranslationsForm sections={keysBySection} />
</div>
);
}