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]>
This commit is contained in:
1 parent
6814e9e2c8
commit
286387d5ec
5 files changed
+294
-222
No files matched your search
+177
-117
@@ -6,6 +6,8 @@ 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,
|
||||
@@ -21,11 +23,155 @@ const ROWS_BY_TYPE: Record<ContentFieldDef["type"], number> = {
|
||||
video: 0,
|
||||
};
|
||||
|
||||
async function loadCurrentValues(): Promise<Map<string, { valueDe: string; valueEn: string | null }>> {
|
||||
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);
|
||||
@@ -52,123 +198,37 @@ export default async function AdminInhaltePage(props: PageProps<"/admin/inhalte"
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{groups.map((group) => {
|
||||
const fields = CONTENT_REGISTRY.filter((f) => f.group === group.key);
|
||||
return (
|
||||
<form
|
||||
key={group.key}
|
||||
action={updateSiteContentAction}
|
||||
className="mb-10 bg-background border border-ink/10 p-6 space-y-6"
|
||||
>
|
||||
<input type="hidden" name="group" value={group.key} />
|
||||
<h2 className="text-lg text-forest">{group.heading}</h2>
|
||||
<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>
|
||||
|
||||
{fields.map((def) => {
|
||||
const row = current.get(def.key);
|
||||
const rows = ROWS_BY_TYPE[def.type];
|
||||
const label = locale === "de" ? def.labelDe : def.labelEn;
|
||||
|
||||
if (!def.localized) {
|
||||
if (def.type === "image") {
|
||||
return (
|
||||
<AdminImageField
|
||||
key={def.key}
|
||||
name={def.key}
|
||||
label={label}
|
||||
initialValue={row?.valueDe ?? ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (def.type === "video") {
|
||||
return (
|
||||
<AdminVideoField
|
||||
key={def.key}
|
||||
name={def.key}
|
||||
label={label}
|
||||
initialValue={row?.valueDe ?? ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<label key={def.key} 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 key={def.key} 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>
|
||||
);
|
||||
})}
|
||||
|
||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||
{t.adminContentPage.save}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
})}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user