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
|
|
@ -0,0 +1,9 @@
|
|||
CREATE TABLE "Translation" (
|
||||
"key" TEXT NOT NULL,
|
||||
"lang" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"updatedBy" TEXT,
|
||||
CONSTRAINT "Translation_pkey" PRIMARY KEY ("key", "lang")
|
||||
);
|
||||
CREATE INDEX "Translation_lang_idx" ON "Translation"("lang");
|
||||
|
|
@ -348,3 +348,14 @@ model Setting {
|
|||
updatedAt DateTime @updatedAt
|
||||
updatedBy String?
|
||||
}
|
||||
|
||||
model Translation {
|
||||
key String
|
||||
lang String
|
||||
value String
|
||||
updatedAt DateTime @updatedAt
|
||||
updatedBy String?
|
||||
|
||||
@@id([key, lang])
|
||||
@@index([lang])
|
||||
}
|
||||
|
|
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -134,7 +134,10 @@ const GROUPS: NavGroup[] = [
|
|||
},
|
||||
{
|
||||
label: "Contenu",
|
||||
items: [{ href: "/admin/content-pages", label: "Pages éditoriales", icon: ICONS.pages }],
|
||||
items: [
|
||||
{ href: "/admin/home", label: "Page d'accueil", icon: ICONS.dashboard },
|
||||
{ href: "/admin/content-pages", label: "Pages éditoriales", icon: ICONS.pages },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Système",
|
||||
|
|
|
|||
51
src/lib/admin/home-keys.ts
Normal file
51
src/lib/admin/home-keys.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Sections éditables depuis /admin/home.
|
||||
*
|
||||
* Liste curatée des préfixes de clés qui apparaissent réellement sur la
|
||||
* page d'accueil. Le reste (season, language, access, site) est éditable
|
||||
* via /admin/translations (toutes les clés) une fois construit.
|
||||
*/
|
||||
export const HOME_SECTIONS: { id: string; label: string; description: string; prefixes: string[] }[] = [
|
||||
{
|
||||
id: "hero",
|
||||
label: "Bandeau d'accueil (hero)",
|
||||
description: "Le visuel plein écran tout en haut — accroche + sous-titre + boutons.",
|
||||
prefixes: ["hero."],
|
||||
},
|
||||
{
|
||||
id: "experiences",
|
||||
label: "Deux expériences",
|
||||
description: "Section présentant les 2 modes (route + fleuve / expédition fleuve).",
|
||||
prefixes: ["experiences."],
|
||||
},
|
||||
{
|
||||
id: "howItWorks",
|
||||
label: "Comment ça marche",
|
||||
description: "Les étapes pour réserver un séjour.",
|
||||
prefixes: ["howItWorks."],
|
||||
},
|
||||
{
|
||||
id: "ce",
|
||||
label: "Comités d'entreprise",
|
||||
description: "Section dédiée aux CE et leurs membres.",
|
||||
prefixes: ["ce."],
|
||||
},
|
||||
{
|
||||
id: "testimonials",
|
||||
label: "Témoignages",
|
||||
description: "Bloc témoignages voyageurs.",
|
||||
prefixes: ["testimonials."],
|
||||
},
|
||||
{
|
||||
id: "footer",
|
||||
label: "Pied de page",
|
||||
description: "Liens et mentions en pied de page.",
|
||||
prefixes: ["footer."],
|
||||
},
|
||||
];
|
||||
|
||||
export const HOME_PREFIXES: string[] = HOME_SECTIONS.flatMap((s) => s.prefixes);
|
||||
|
||||
export function isHomeKey(key: string): boolean {
|
||||
return HOME_PREFIXES.some((p) => key.startsWith(p));
|
||||
}
|
||||
72
src/lib/admin/translations.ts
Normal file
72
src/lib/admin/translations.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import "server-only";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import frMessages from "@/messages/fr.json";
|
||||
import enMessages from "@/messages/en.json";
|
||||
|
||||
const BASE: Record<"fr" | "en", Record<string, string>> = {
|
||||
fr: frMessages as Record<string, string>,
|
||||
en: enMessages as Record<string, string>,
|
||||
};
|
||||
|
||||
export type TranslationRow = {
|
||||
key: string;
|
||||
baseFr: string;
|
||||
baseEn: string;
|
||||
overrideFr: string | null;
|
||||
overrideEn: string | null;
|
||||
updatedAt: Date | null;
|
||||
updatedBy: string | null;
|
||||
};
|
||||
|
||||
export async function listTranslationsForKeys(prefixes: string[]): Promise<TranslationRow[]> {
|
||||
// Toutes les clés du fichier FR (canonique) qui matchent un préfixe.
|
||||
const allKeys = Object.keys(BASE.fr).filter((k) => prefixes.some((p) => k.startsWith(p)));
|
||||
allKeys.sort();
|
||||
|
||||
const overrides = await prisma.translation.findMany({
|
||||
where: { key: { in: allKeys } },
|
||||
select: { key: true, lang: true, value: true, updatedAt: true, updatedBy: true },
|
||||
});
|
||||
type Override = (typeof overrides)[number];
|
||||
const overrideMap = new Map<string, Map<string, Override>>();
|
||||
for (const o of overrides) {
|
||||
if (!overrideMap.has(o.key)) overrideMap.set(o.key, new Map());
|
||||
overrideMap.get(o.key)!.set(o.lang, o);
|
||||
}
|
||||
|
||||
return allKeys.map((key) => {
|
||||
const rowFr = overrideMap.get(key)?.get("fr");
|
||||
const rowEn = overrideMap.get(key)?.get("en");
|
||||
const lastTs = Math.max(rowFr?.updatedAt.getTime() ?? 0, rowEn?.updatedAt.getTime() ?? 0);
|
||||
const lastEditor = (rowFr?.updatedAt ?? new Date(0)) > (rowEn?.updatedAt ?? new Date(0))
|
||||
? rowFr?.updatedBy ?? null
|
||||
: rowEn?.updatedBy ?? null;
|
||||
return {
|
||||
key,
|
||||
baseFr: BASE.fr[key] ?? "",
|
||||
baseEn: BASE.en[key] ?? "",
|
||||
overrideFr: rowFr?.value ?? null,
|
||||
overrideEn: rowEn?.value ?? null,
|
||||
updatedAt: lastTs > 0 ? new Date(lastTs) : null,
|
||||
updatedBy: lastEditor,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function upsertTranslation(
|
||||
key: string,
|
||||
lang: "fr" | "en",
|
||||
value: string,
|
||||
actor: string | null,
|
||||
): Promise<void> {
|
||||
await prisma.translation.upsert({
|
||||
where: { key_lang: { key, lang } },
|
||||
create: { key, lang, value, updatedBy: actor },
|
||||
update: { value, updatedBy: actor },
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteTranslationOverride(key: string, lang: "fr" | "en"): Promise<void> {
|
||||
await prisma.translation.delete({ where: { key_lang: { key, lang } } }).catch(() => {});
|
||||
}
|
||||
59
src/lib/i18n/overrides.ts
Normal file
59
src/lib/i18n/overrides.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import "server-only";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import type { Locale } from "./types";
|
||||
|
||||
type Cache = {
|
||||
fr: Map<string, string>;
|
||||
en: Map<string, string>;
|
||||
loadedAt: number;
|
||||
};
|
||||
|
||||
const TTL_MS = 10_000;
|
||||
let cache: Cache | null = null;
|
||||
let inflight: Promise<Cache> | null = null;
|
||||
|
||||
async function refresh(): Promise<Cache> {
|
||||
const rows = await prisma.translation.findMany({
|
||||
select: { key: true, lang: true, value: true },
|
||||
});
|
||||
const fr = new Map<string, string>();
|
||||
const en = new Map<string, string>();
|
||||
for (const r of rows) {
|
||||
if (r.lang === "fr") fr.set(r.key, r.value);
|
||||
else if (r.lang === "en") en.set(r.key, r.value);
|
||||
}
|
||||
cache = { fr, en, loadedAt: Date.now() };
|
||||
return cache;
|
||||
}
|
||||
|
||||
async function loadCache(): Promise<Cache> {
|
||||
if (cache && Date.now() - cache.loadedAt < TTL_MS) return cache;
|
||||
if (inflight) return inflight;
|
||||
inflight = refresh().finally(() => {
|
||||
inflight = null;
|
||||
});
|
||||
return inflight;
|
||||
}
|
||||
|
||||
export async function getTranslationOverride(key: string, lang: Locale): Promise<string | undefined> {
|
||||
try {
|
||||
const c = await loadCache();
|
||||
return c[lang].get(key);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTranslationOverridesMap(lang: Locale): Promise<Map<string, string>> {
|
||||
try {
|
||||
const c = await loadCache();
|
||||
return c[lang];
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
export function invalidateTranslationCache(): void {
|
||||
cache = null;
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import { isPluginEnabled } from "@/lib/plugins/server";
|
|||
|
||||
import frMessages from "@/messages/fr.json";
|
||||
import enMessages from "@/messages/en.json";
|
||||
import { getTranslationOverride, getTranslationOverridesMap } from "./overrides";
|
||||
|
||||
const DICTS: Record<Locale, Record<string, string>> = {
|
||||
fr: frMessages as Record<string, string>,
|
||||
|
|
@ -55,6 +56,8 @@ export async function getLocale(): Promise<Locale> {
|
|||
*/
|
||||
export async function t(key: string, locale?: Locale): Promise<string> {
|
||||
const lang = locale ?? (await getLocale());
|
||||
const override = await getTranslationOverride(key, lang);
|
||||
if (override !== undefined) return override;
|
||||
const dict = DICTS[lang];
|
||||
const fallback = DICTS.fr;
|
||||
return dict[key] ?? fallback[key] ?? key;
|
||||
|
|
@ -63,8 +66,14 @@ export async function t(key: string, locale?: Locale): Promise<string> {
|
|||
/**
|
||||
* dict(locale) : retourne le dico complet pour passer en prop client-side.
|
||||
* Garde le bundle léger (le client ne reçoit qu'une langue à la fois).
|
||||
* Les overrides DB sont fusionnés par-dessus le fichier de base.
|
||||
*/
|
||||
export async function dict(locale?: Locale): Promise<Record<string, string>> {
|
||||
const lang = locale ?? (await getLocale());
|
||||
return DICTS[lang];
|
||||
const base = DICTS[lang];
|
||||
const overrides = await getTranslationOverridesMap(lang);
|
||||
if (overrides.size === 0) return base;
|
||||
const merged: Record<string, string> = { ...base };
|
||||
for (const [k, v] of overrides) merged[k] = v;
|
||||
return merged;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue