Files
Wabenhain/src/app/admin/inhalte/page.tsx
T
Nick SzaunigandClaude Sonnet 5 286387d5ec refactor: /admin/inhalte in Bilder/Text trennen, KI-untypische Formulierungen in Website-Texten
Content-Editor zeigt Bild-/Video-Felder jetzt gebuendelt oben und
Textfelder gebuendelt darunter (je Seite ein eigenes Speichern-Formular),
statt beides pro Bereich gemischt untereinander. Ausserdem alle
Gedankenstrich-Konstruktionen und aehnliche KI-Textmuster aus den
Website-Texten (de/en) entfernt und durch natuerlichere Formulierungen
ersetzt.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-11 15:48:03 +02:00

235 lines
8.2 KiB
TypeScript

import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { AdminImageField } from "@/components/admin-image-field";
import { AdminVideoField } from "@/components/admin-video-field";
import { CONTENT_REGISTRY, type ContentFieldDef } from "@/lib/content-registry";
import { updateSiteContentAction } from "../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
import type { Dictionary } from "@/i18n/dictionary-type";
import type { Locale } from "@/i18n/config";
const ROWS_BY_TYPE: Record<ContentFieldDef["type"], number> = {
text: 1,
textarea: 3,
"list-simple": 4,
"list-titled": 5,
"list-numbered": 6,
"richtext-sections": 14,
// Bild-/Video-Felder haben kein <textarea>, nutzen ROWS_BY_TYPE also nicht
// — Werte nur vorhanden, damit dieses Record<ContentFieldDef["type"], number>
// weiter vollstaendig ist.
image: 0,
video: 0,
};
type CurrentValues = Map<string, { valueDe: string; valueEn: string | null }>;
async function loadCurrentValues(): Promise<CurrentValues> {
const rows = await prisma.siteContent.findMany();
return new Map(rows.map((r) => [r.key, { valueDe: r.valueDe, valueEn: r.valueEn }]));
}
/** Bild- und Video-Felder gehoeren zum "Bilder"-Bereich, alles andere
* (Text/Listen/Rechtstexte) zum "Text"-Bereich — siehe AdminInhaltePage. */
function isImageOrVideoField(def: ContentFieldDef): boolean {
return def.type === "image" || def.type === "video";
}
function FieldEditor({
def,
row,
locale,
t,
}: {
def: ContentFieldDef;
row: { valueDe: string; valueEn: string | null } | undefined;
locale: Locale;
t: Dictionary;
}) {
const rows = ROWS_BY_TYPE[def.type];
const label = locale === "de" ? def.labelDe : def.labelEn;
if (!def.localized) {
if (def.type === "image") {
return <AdminImageField name={def.key} label={label} initialValue={row?.valueDe ?? ""} />;
}
if (def.type === "video") {
return <AdminVideoField name={def.key} label={label} initialValue={row?.valueDe ?? ""} />;
}
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
{def.type === "textarea" ? (
<textarea
name={def.key}
rows={rows}
defaultValue={row?.valueDe ?? ""}
placeholder={def.getDefault("de")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-forest"
/>
) : (
<input
type="text"
name={def.key}
defaultValue={row?.valueDe ?? ""}
placeholder={def.getDefault("de")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-forest"
/>
)}
</label>
);
}
const isTextarea = def.type !== "text";
return (
<div className="border-t border-ink/10 pt-5 first:border-t-0 first:pt-0">
<p className="text-sm text-ink/70 mb-2">{label}</p>
<div className="grid sm:grid-cols-2 gap-4">
<label className="block text-xs">
<span className="text-ink/70">{t.adminContentPage.deLabel}</span>
{isTextarea ? (
<textarea
name={`de::${def.key}`}
rows={rows}
defaultValue={row?.valueDe ?? ""}
placeholder={def.getDefault("de")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-forest"
/>
) : (
<input
type="text"
name={`de::${def.key}`}
defaultValue={row?.valueDe ?? ""}
placeholder={def.getDefault("de")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-forest"
/>
)}
</label>
<label className="block text-xs">
<span className="text-ink/70">{t.adminContentPage.enLabel}</span>
{isTextarea ? (
<textarea
name={`en::${def.key}`}
rows={rows}
defaultValue={row?.valueEn ?? ""}
placeholder={def.getDefault("en")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-forest"
/>
) : (
<input
type="text"
name={`en::${def.key}`}
defaultValue={row?.valueEn ?? ""}
placeholder={def.getDefault("en")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-forest"
/>
)}
</label>
</div>
</div>
);
}
/** Ein <form> pro Gruppe UND Bereich (Bilder/Text) — siehe AdminInhaltePage.
* Zwei Formulare koennen dieselbe group="..." fuers Audit-Log/Reload
* mitschicken, das ist unproblematisch: updateSiteContentAction liest pro
* Absenden nur die tatsaechlich in DIESEM Formular vorhandenen Registry-
* Felder (siehe admin/actions/content.ts), Felder aus dem jeweils anderen
* Formular fehlen im FormData schlicht und werden uebersprungen. */
function GroupForm({
groupHeading,
fields,
current,
groupKeyValue,
locale,
t,
}: {
groupHeading: string;
fields: ContentFieldDef[];
current: CurrentValues;
groupKeyValue: string;
locale: Locale;
t: Dictionary;
}) {
if (fields.length === 0) return null;
return (
<form
action={updateSiteContentAction}
className="mb-10 bg-background border border-ink/10 p-6 space-y-6"
>
<input type="hidden" name="group" value={groupKeyValue} />
<h3 className="text-lg text-forest">{groupHeading}</h3>
{fields.map((def) => (
<FieldEditor key={def.key} def={def} row={current.get(def.key)} locale={locale} t={t} />
))}
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminContentPage.save}
</button>
</form>
);
}
export default async function AdminInhaltePage(props: PageProps<"/admin/inhalte">) {
const locale = await getLocale();
const t = getDictionary(locale);
const { saved } = await props.searchParams;
const current = await loadCurrentValues();
const groups: { key: ContentFieldDef["group"]; heading: string }[] = [
{ key: "company", heading: t.adminContentPage.groupCompany },
{ key: "home", heading: t.adminContentPage.groupHome },
{ key: "pages", heading: t.adminContentPage.groupPages },
{ key: "social", heading: t.adminContentPage.groupSocial },
{ key: "legal", heading: t.adminContentPage.groupLegal },
];
return (
<div>
<BackLink href="/admin" label={t.adminContentPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-2">{t.adminContentPage.title}</h1>
<p className="text-sm text-ink/65 mb-8 max-w-2xl">{t.adminContentPage.intro}</p>
{saved ? (
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
{t.adminContentPage.saved}
</p>
) : null}
<section className="mb-12">
<h2 className="text-xl text-forest mb-1">{t.adminContentPage.sectionImages}</h2>
<p className="text-sm text-ink/65 mb-6 max-w-2xl">{t.adminContentPage.sectionImagesIntro}</p>
{groups.map((group) => (
<GroupForm
key={`images-${group.key}`}
groupHeading={group.heading}
groupKeyValue={group.key}
fields={CONTENT_REGISTRY.filter((f) => f.group === group.key && isImageOrVideoField(f))}
current={current}
locale={locale}
t={t}
/>
))}
</section>
<section>
<h2 className="text-xl text-forest mb-1">{t.adminContentPage.sectionText}</h2>
<p className="text-sm text-ink/65 mb-6 max-w-2xl">{t.adminContentPage.sectionTextIntro}</p>
{groups.map((group) => (
<GroupForm
key={`text-${group.key}`}
groupHeading={group.heading}
groupKeyValue={group.key}
fields={CONTENT_REGISTRY.filter((f) => f.group === group.key && !isImageOrVideoField(f))}
current={current}
locale={locale}
t={t}
/>
))}
</section>
</div>
);
}