feat(admin): settings page with tabs, system checks and email test
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m26s
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m26s
"Inhalte" becomes Admin -> Einstellungen, grouped into tabs so each area stands on its own: Firma & Versand, Marke, Startseite, Seiten, Social Media, Rechtstexte and System & Tests. Tabs are plain links (?reiter=...), bookmarkable and usable without JavaScript; saving returns to the same tab. System & Tests brings everything that checks the running shop into one place: - integration overview (Stripe, Stripe webhook, Microsoft 365 mail, Microsoft sign-in, Turnstile, Google reviews, DHL, ApiCore): only whether each is set up and the names of missing variables, never a value; warns when Stripe runs on test keys or mixes test/live - the email test, now with the IPv4 network checks for smtp.office365.com:587 and login.microsoftonline.com:443 /admin/inhalte and /admin/e-mail-test redirect to the new page; the separate "E-Mail-Test" menu entry is gone. The tab parameter and the saved group are validated against a fixed list before they are used in a redirect. Docs point to the new location and cover the ENETUNREACH (IPv6) case. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
b5311fae83
commit
de82ad0233
17 files changed
+654
-288
No files matched your search
@@ -8,6 +8,7 @@ import { isValidUploadPath, isValidVideoUploadPath } from "@/lib/uploads";
|
||||
import { requireAdmin } from "./_shared";
|
||||
import { fail, succeed } from "@/lib/flash";
|
||||
import { normalizeContactEmail } from "@/lib/contact-email";
|
||||
import { settingsPathForGroup } from "@/lib/settings-tabs";
|
||||
|
||||
const COMPANY_EMAIL_KEY = "company.email";
|
||||
|
||||
@@ -16,7 +17,7 @@ const COMPANY_EMAIL_KEY = "company.email";
|
||||
* — jede Gruppen-Form sendet nur ihre eigenen Feldnamen, diese Action
|
||||
* verarbeitet einfach, was tatsaechlich in formData vorhanden ist. Nicht
|
||||
* lokalisierte Felder kommen als "{key}", lokalisierte als "de::{key}" /
|
||||
* "en::{key}" (siehe admin/inhalte/page.tsx).
|
||||
* "en::{key}" (siehe admin/einstellungen/content-form.tsx).
|
||||
*/
|
||||
export async function updateSiteContentAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
@@ -59,7 +60,7 @@ export async function updateSiteContentAction(formData: FormData): Promise<void>
|
||||
// Impressum ein Kontaktweg, der ins Leere fuehrt.
|
||||
if (def.key === COMPANY_EMAIL_KEY && normalizeContactEmail(valueDe) === null) {
|
||||
return fail(
|
||||
"/admin/inhalte",
|
||||
settingsPathForGroup(group),
|
||||
"Die Firmen-E-Mail ergibt keine gültige Adresse. Möglich sind z. B. [email protected], info [at] wabenhain.com oder info [á] wabenhain.com."
|
||||
);
|
||||
}
|
||||
@@ -85,5 +86,5 @@ export async function updateSiteContentAction(formData: FormData): Promise<void>
|
||||
});
|
||||
|
||||
revalidatePath("/", "layout");
|
||||
return succeed("/admin/inhalte", "Inhalte gespeichert.");
|
||||
return succeed(settingsPathForGroup(group), "Einstellungen gespeichert.");
|
||||
}
|
||||
@@ -1,17 +1,7 @@
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { AdminPageHeader } from "@/components/admin-list";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import { SmtpTestPanel } from "./smtp-test-panel";
|
||||
import { redirect } from "next/navigation";
|
||||
import { SETTINGS_PATH } from "@/lib/settings-tabs";
|
||||
|
||||
export default async function AdminEmailTestPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
return (
|
||||
<div>
|
||||
<BackLink href="/admin" label={t.adminWithdrawalsPage.backToDashboard} />
|
||||
<AdminPageHeader title={t.adminEmailTestPage.title} />
|
||||
<p className="mb-8 max-w-2xl text-sm text-ink/70">{t.adminEmailTestPage.intro}</p>
|
||||
<SmtpTestPanel />
|
||||
</div>
|
||||
);
|
||||
/** Der E-Mail-Test liegt jetzt in Admin → Einstellungen → System & Tests. */
|
||||
export default function AdminEmailTestRedirect(): never {
|
||||
redirect(`${SETTINGS_PATH}?reiter=system`);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
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 type { Dictionary } from "@/i18n/dictionary-type";
|
||||
import type { Locale } from "@/i18n/config";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
const ROWS_BY_TYPE: Record<ContentFieldDef["type"], number> = {
|
||||
text: 1,
|
||||
textarea: 3,
|
||||
// Auswahlfelder rendern ein <select> und nutzen ROWS_BY_TYPE nicht — Wert
|
||||
// nur vorhanden, damit dieses Record vollstaendig bleibt.
|
||||
select: 0,
|
||||
"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,
|
||||
};
|
||||
|
||||
export type CurrentValues = Map<string, { valueDe: string; valueEn: string | null }>;
|
||||
|
||||
export 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 — auf jedem Reiter vor den Textfeldern (fieldsForGroup). */
|
||||
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 ?? ""} />;
|
||||
}
|
||||
if (def.type === "select") {
|
||||
// Ohne gepflegten Wert ist der Vorgabewert vorausgewaehlt — genau der
|
||||
// gilt zur Laufzeit ohnehin, die Anzeige soll das nicht anders
|
||||
// darstellen als die Wirklichkeit.
|
||||
const current = row?.valueDe?.trim() ? row.valueDe : def.getDefault("de");
|
||||
return (
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{label}</span>
|
||||
<select
|
||||
name={def.key}
|
||||
defaultValue={current}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-heading"
|
||||
>
|
||||
{(def.options ?? []).map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{locale === "de" ? option.labelDe : option.labelEn}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
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-heading"
|
||||
/>
|
||||
) : (
|
||||
<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-heading"
|
||||
/>
|
||||
)}
|
||||
</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-heading"
|
||||
/>
|
||||
) : (
|
||||
<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-heading"
|
||||
/>
|
||||
)}
|
||||
</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-heading"
|
||||
/>
|
||||
) : (
|
||||
<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-heading"
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Ein <form> je Inhaltsgruppe auf ihrem Reiter (admin/einstellungen/page.tsx).
|
||||
* updateSiteContentAction liest pro Absenden nur die in DIESEM Formular
|
||||
* vorhandenen Registry-Felder (siehe admin/actions/content.ts) und springt
|
||||
* danach ueber das mitgeschickte group="..." auf denselben Reiter zurueck. */
|
||||
export 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-heading">{groupHeading}</h3>
|
||||
|
||||
{fields.map((def) => (
|
||||
<FieldEditor key={def.key} def={def} row={current.get(def.key)} locale={locale} t={t} />
|
||||
))}
|
||||
|
||||
<Button type="submit" className="px-5 py-2.5">
|
||||
{t.adminContentPage.save}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** Alle Felder einer Inhaltsgruppe, Bilder und Video zuerst — so steht auf jedem Reiter das Visuelle oben. */
|
||||
export function fieldsForGroup(group: ContentFieldDef["group"]): ContentFieldDef[] {
|
||||
const fields = CONTENT_REGISTRY.filter((f) => f.group === group);
|
||||
return [...fields.filter(isImageOrVideoField), ...fields.filter((f) => !isImageOrVideoField(f))];
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import Link from "next/link";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { AdminBadge } from "@/components/admin-list";
|
||||
import type { ContentFieldDef } from "@/lib/content-registry";
|
||||
import { readIntegrationStatus } from "@/lib/integration-status";
|
||||
import { SETTINGS_PATH, SETTINGS_TABS, parseSettingsTab, type SettingsTabId } from "@/lib/settings-tabs";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import type { Dictionary } from "@/i18n/dictionary-type";
|
||||
import { GroupForm, fieldsForGroup, loadCurrentValues } from "./content-form";
|
||||
import { SmtpTestPanel } from "./smtp-test-panel";
|
||||
|
||||
/**
|
||||
* Alle Einstellungen des Shops an einem Ort, nach Bereichen in Reiter
|
||||
* gegliedert (lib/settings-tabs.ts). Die Reiter sind echte Links
|
||||
* (?reiter=…): direkt aufrufbar, als Lesezeichen speicherbar und ohne
|
||||
* JavaScript bedienbar. Frueher getrennt: /admin/inhalte und
|
||||
* /admin/e-mail-test — beide leiten hierher um.
|
||||
*/
|
||||
export default async function AdminEinstellungenPage(props: PageProps<"/admin/einstellungen">) {
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
const s = t.adminContentPage;
|
||||
const { reiter } = await props.searchParams;
|
||||
const activeId = parseSettingsTab(reiter);
|
||||
const active = SETTINGS_TABS.find((tab) => tab.id === activeId) ?? SETTINGS_TABS[0];
|
||||
|
||||
const tabLabels: Record<SettingsTabId, string> = {
|
||||
firma: s.tabCompany,
|
||||
marke: s.tabBrand,
|
||||
startseite: s.tabHome,
|
||||
seiten: s.tabPages,
|
||||
social: s.tabSocial,
|
||||
recht: s.tabLegal,
|
||||
system: s.tabSystem,
|
||||
};
|
||||
const groupHeadings: Record<ContentFieldDef["group"], string> = {
|
||||
company: s.groupCompany,
|
||||
shipping: s.groupShipping,
|
||||
brand: s.groupBrand,
|
||||
home: s.groupHome,
|
||||
pages: s.groupPages,
|
||||
social: s.groupSocial,
|
||||
legal: s.groupLegal,
|
||||
};
|
||||
const current = active.groups.length > 0 ? await loadCurrentValues() : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BackLink href="/admin" label={s.backToDashboard} />
|
||||
<h1 className="mb-6 text-2xl text-heading">{s.title}</h1>
|
||||
|
||||
<nav aria-label={s.tabsLabel} className="-mx-1 mb-8 overflow-x-auto border-b border-ink/10">
|
||||
<ul className="flex min-w-max gap-1 px-1">
|
||||
{SETTINGS_TABS.map((tab) => {
|
||||
const isActive = tab.id === active.id;
|
||||
return (
|
||||
<li key={tab.id}>
|
||||
<Link
|
||||
href={`${SETTINGS_PATH}?reiter=${tab.id}`}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={`-mb-px block border-b-2 px-4 py-3 text-sm transition-colors ${
|
||||
isActive ? "border-honey text-heading" : "border-transparent text-ink/65 hover:border-ink/20 hover:text-heading"
|
||||
}`}
|
||||
>
|
||||
{tabLabels[tab.id]}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{active.id === "system" ? (
|
||||
<SystemTab t={t} />
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-8 max-w-2xl text-sm text-ink/65">{s.intro}</p>
|
||||
{current
|
||||
? active.groups.map((group) => (
|
||||
<GroupForm
|
||||
key={group}
|
||||
groupHeading={groupHeadings[group]}
|
||||
groupKeyValue={group}
|
||||
fields={fieldsForGroup(group)}
|
||||
current={current}
|
||||
locale={locale}
|
||||
t={t}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemTab({ t }: { t: Dictionary }) {
|
||||
const s = t.adminContentPage;
|
||||
const integrations = readIntegrationStatus();
|
||||
const warnings = { "stripe-test-mode": s.warningStripeTest, "stripe-mixed-mode": s.warningStripeMixed } as const;
|
||||
|
||||
return (
|
||||
<div className="space-y-12">
|
||||
<p className="max-w-2xl text-sm text-ink/65">{s.systemIntro}</p>
|
||||
|
||||
<section aria-labelledby="settings-integrations">
|
||||
<h2 id="settings-integrations" className="text-xl text-heading">
|
||||
{s.integrationsHeading}
|
||||
</h2>
|
||||
<p className="mb-5 mt-1 max-w-2xl text-sm text-ink/65">{s.integrationsIntro}</p>
|
||||
<ul className="max-w-3xl divide-y divide-ink/10 border-y border-ink/10">
|
||||
{integrations.map((integration) => (
|
||||
<li key={integration.key} className="flex flex-wrap items-start justify-between gap-x-6 gap-y-2 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-heading">{s.integrationNames[integration.key]}</p>
|
||||
<p className="mt-0.5 text-xs text-ink/60">{integration.required ? s.statusRequired : s.statusOptional}</p>
|
||||
{integration.warning ? (
|
||||
<p className="mt-2 text-sm text-red-700 dark:text-red-300">{warnings[integration.warning]}</p>
|
||||
) : null}
|
||||
{!integration.configured ? (
|
||||
<p className="mt-2 break-words font-mono text-xs text-ink/70">{s.statusMissing(integration.missing.join(", "))}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<AdminBadge tone={integration.configured ? (integration.warning ? "danger" : "positive") : integration.required ? "danger" : "neutral"}>
|
||||
{integration.configured ? s.statusConfigured : s.statusNotConfigured}
|
||||
</AdminBadge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="settings-email-test">
|
||||
<h2 id="settings-email-test" className="text-xl text-heading">
|
||||
{s.emailTestHeading}
|
||||
</h2>
|
||||
<p className="mb-5 mt-1 max-w-2xl text-sm text-ink/65">{t.adminEmailTestPage.intro}</p>
|
||||
<SmtpTestPanel />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File renamed without changes.
+20
-1
@@ -3,7 +3,7 @@
|
||||
import { useActionState } from "react";
|
||||
import { Button } from "@/components/ui";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
import { runSmtpTestAction, type SmtpTestState } from "./actions";
|
||||
import { runSmtpTestAction, type SmtpTestState } from "./smtp-test-actions";
|
||||
|
||||
export function SmtpTestPanel() {
|
||||
const { t } = useI18n();
|
||||
@@ -52,6 +52,25 @@ export function SmtpTestPanel() {
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{state && "network" in state ? (
|
||||
<div className="mt-6 text-sm">
|
||||
<p className="text-xs tracking-wide-caps text-ink/60">{s.networkHeading}</p>
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{[
|
||||
{ label: s.networkSmtp, check: state.network.smtp },
|
||||
{ label: s.networkEntra, check: state.network.entra },
|
||||
].map(({ label, check }) => (
|
||||
<li key={label} className="flex flex-wrap items-baseline gap-x-3">
|
||||
<span className="text-ink/80">{label}:</span>
|
||||
<span className={check.ok ? "text-heading" : "font-medium text-red-700 dark:text-red-300"}>
|
||||
{check.ok ? s.networkOk : s.networkFailed}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-ink/60">{check.detail}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1,255 +1,7 @@
|
||||
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";
|
||||
import { Button } from "@/components/ui";
|
||||
import { redirect } from "next/navigation";
|
||||
import { SETTINGS_PATH } from "@/lib/settings-tabs";
|
||||
|
||||
const ROWS_BY_TYPE: Record<ContentFieldDef["type"], number> = {
|
||||
text: 1,
|
||||
textarea: 3,
|
||||
// Auswahlfelder rendern ein <select> und nutzen ROWS_BY_TYPE nicht — Wert
|
||||
// nur vorhanden, damit dieses Record vollstaendig bleibt.
|
||||
select: 0,
|
||||
"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 ?? ""} />;
|
||||
}
|
||||
if (def.type === "select") {
|
||||
// Ohne gepflegten Wert ist der Vorgabewert vorausgewaehlt — genau der
|
||||
// gilt zur Laufzeit ohnehin, die Anzeige soll das nicht anders
|
||||
// darstellen als die Wirklichkeit.
|
||||
const current = row?.valueDe?.trim() ? row.valueDe : def.getDefault("de");
|
||||
return (
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{label}</span>
|
||||
<select
|
||||
name={def.key}
|
||||
defaultValue={current}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-heading"
|
||||
>
|
||||
{(def.options ?? []).map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{locale === "de" ? option.labelDe : option.labelEn}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
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-heading"
|
||||
/>
|
||||
) : (
|
||||
<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-heading"
|
||||
/>
|
||||
)}
|
||||
</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-heading"
|
||||
/>
|
||||
) : (
|
||||
<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-heading"
|
||||
/>
|
||||
)}
|
||||
</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-heading"
|
||||
/>
|
||||
) : (
|
||||
<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-heading"
|
||||
/>
|
||||
)}
|
||||
</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-heading">{groupHeading}</h3>
|
||||
|
||||
{fields.map((def) => (
|
||||
<FieldEditor key={def.key} def={def} row={current.get(def.key)} locale={locale} t={t} />
|
||||
))}
|
||||
|
||||
<Button type="submit" className="px-5 py-2.5">
|
||||
{t.adminContentPage.save}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function AdminInhaltePage() {
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
const current = await loadCurrentValues();
|
||||
|
||||
const groups: { key: ContentFieldDef["group"]; heading: string }[] = [
|
||||
{ key: "brand", heading: t.adminContentPage.groupBrand },
|
||||
{ key: "company", heading: t.adminContentPage.groupCompany },
|
||||
{ key: "shipping", heading: t.adminContentPage.groupShipping },
|
||||
{ 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-heading mb-2">{t.adminContentPage.title}</h1>
|
||||
<p className="text-sm text-ink/65 mb-8 max-w-2xl">{t.adminContentPage.intro}</p>
|
||||
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl text-heading 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-heading 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>
|
||||
);
|
||||
/** Frueher "Inhalte" — jetzt Teil von Admin → Einstellungen. Alte Lesezeichen fuehren weiter. */
|
||||
export default function AdminInhalteRedirect(): never {
|
||||
redirect(SETTINGS_PATH);
|
||||
}
|
||||
Reference in new issue
Block a user