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]>
235 lines
8.2 KiB
TypeScript
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>
|
|
);
|
|
}
|