feat(admin): /admin/home — éditeur des textes de la page d'accueil (FR+EN, override DB)
This commit is contained in:
parent
d3cc5bdfb9
commit
a9fcd18022
10 changed files with 491 additions and 2 deletions
169
src/app/admin/home/_components/HomeTranslationsForm.tsx
Normal file
169
src/app/admin/home/_components/HomeTranslationsForm.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
67
src/app/admin/home/actions.ts
Normal file
67
src/app/admin/home/actions.ts
Normal 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 };
|
||||
}
|
||||
39
src/app/admin/home/page.tsx
Normal file
39
src/app/admin/home/page.tsx
Normal 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'accueil</h1>
|
||||
<p className="mt-1 text-sm text-zinc-600">
|
||||
Édition des textes affichés sur la page d'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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue