Add SEO settings and root admin save flows
CI / quality (push) Waiting to run

This commit is contained in:
MOH
2026-03-07 18:48:01 +01:00
parent f6a5a37676
commit f259dfc6bd
35 changed files with 1500 additions and 56 deletions
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
type RootLayoutProps = {
children: React.ReactNode;
};
export const metadata: Metadata = {
robots: {
index: false,
follow: false,
nocache: true,
googleBot: {
index: false,
follow: false,
noimageindex: true,
"max-image-preview": "none",
"max-snippet": -1,
"max-video-preview": -1,
},
},
};
export default function RootLayout({ children }: RootLayoutProps) {
return children;
}
+29 -16
View File
@@ -2,6 +2,7 @@ import { Power } from "lucide-react";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { FormSaveButton } from "@/components/root/form-save-button";
import { MotionFade } from "@/components/motion-fade";
import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
import { routing } from "@/i18n/routing";
@@ -10,8 +11,8 @@ import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"
import { getMaintenanceMode, setMaintenanceMode } from "@/lib/app-config";
import { AppCard } from "@/components/ui/app-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CardContent } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
export const dynamic = "force-dynamic";
@@ -22,12 +23,13 @@ const copy = {
maintenance: "Wartungsmodus",
uiKit: "UI Kit",
media: "Media",
siteSettings: "SEO",
portfolio: "Portfolio",
maintenanceText: "Wenn aktiv, werden alle Seiten auf die Coming Soon Seite weitergeleitet.",
maintenanceOn: "Aktiv",
maintenanceOff: "Inaktiv",
enableMaintenance: "Wartungsmodus aktivieren",
disableMaintenance: "Wartungsmodus deaktivieren",
selectLabel: "Status",
selectHint: "Aenderung wird erst nach Speichern uebernommen.",
logout: "Ausloggen",
backToSite: "Zur Website",
};
@@ -79,25 +81,36 @@ export default async function RootMaintenancePage() {
headerTitle={copy.title}
headerDescription={copy.subtitle}
headerActions={
<Badge variant={maintenanceEnabled ? "warning" : "success"}>
{maintenanceEnabled ? copy.maintenanceOn : copy.maintenanceOff}
</Badge>
<>
<Badge variant={maintenanceEnabled ? "warning" : "success"}>
{maintenanceEnabled ? copy.maintenanceOn : copy.maintenanceOff}
</Badge>
<FormSaveButton formId="maintenance-form" />
</>
}
>
<MotionFade delay={0.1}>
<AppCard>
<CardContent className="space-y-4 p-6">
<p className="text-sm text-muted-foreground">{copy.maintenanceText}</p>
<form action={updateMaintenanceMode}>
<input
type="hidden"
name="enabled"
value={maintenanceEnabled ? "false" : "true"}
/>
<Button type="submit">
<Power className="h-4 w-4" />
{maintenanceEnabled ? copy.disableMaintenance : copy.enableMaintenance}
</Button>
<form id="maintenance-form" action={updateMaintenanceMode} className="space-y-3">
<div className="space-y-2">
<Label htmlFor="enabled">{copy.selectLabel}</Label>
<select
id="enabled"
name="enabled"
defaultValue={maintenanceEnabled ? "true" : "false"}
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
>
<option value="false">{copy.maintenanceOff}</option>
<option value="true">{copy.maintenanceOn}</option>
</select>
<p className="text-xs text-muted-foreground">{copy.selectHint}</p>
</div>
<div className="inline-flex items-center gap-2 rounded-nested border border-border bg-surface-1 px-3 py-2 text-sm text-foreground">
<Power className="h-4 w-4 text-brand-primary" />
{maintenanceEnabled ? copy.maintenanceOn : copy.maintenanceOff}
</div>
</form>
</CardContent>
</AppCard>
+1
View File
@@ -25,6 +25,7 @@ const copy = {
maintenance: "Wartungsmodus",
uiKit: "UI Kit",
media: "Media",
siteSettings: "SEO",
portfolio: "Portfolio",
logout: "Ausloggen",
backToSite: "Zur Website",
+1
View File
@@ -30,6 +30,7 @@ const copy = {
maintenance: "Wartungsmodus",
uiKit: "UI Kit",
media: "Media",
siteSettings: "SEO",
portfolio: "Portfolio",
logout: "Ausloggen",
backToSite: "Zur Website",
+1
View File
@@ -22,6 +22,7 @@ const copy = {
overview: "Uebersicht",
maintenance: "Wartungsmodus",
uiKit: "UI Kit",
siteSettings: "SEO",
portfolio: "Portfolio",
logout: "Ausloggen",
backToSite: "Zur Website",
@@ -25,6 +25,7 @@ const copy = {
maintenance: "Wartungsmodus",
uiKit: "UI Kit",
media: "Media",
siteSettings: "SEO",
portfolio: "Portfolio",
logout: "Ausloggen",
backToSite: "Zur Website",
+1
View File
@@ -19,6 +19,7 @@ const copy = {
maintenance: "Wartungsmodus",
uiKit: "UI Kit",
media: "Media",
siteSettings: "SEO",
portfolio: "Portfolio",
logout: "Ausloggen",
backToSite: "Zur Website",
+1
View File
@@ -24,6 +24,7 @@ const copy = {
maintenance: "Wartungsmodus",
uiKit: "UI Kit",
media: "Media",
siteSettings: "SEO",
portfolio: "Portfolio",
logout: "Ausloggen",
backToSite: "Zur Website",
+227
View File
@@ -0,0 +1,227 @@
"use server";
import { MediaUsageType } from "@prisma/client";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { isRedirectError } from "next/dist/client/components/redirect";
import {
SITE_SETTINGS_DEFAULT_OG_IMAGE_FIELD_KEY,
SITE_SETTINGS_ENTITY_ID,
SITE_SETTINGS_ENTITY_TYPE,
SITE_SETTINGS_FAVICON_FIELD_KEY,
updateSiteSettings,
} from "@/lib/app-config";
import { PAGE_TITLE_TOKEN, type SiteSettings } from "@/lib/site-settings";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import { replaceEntityMediaUsages } from "@/lib/media";
import { resolveMediaSelection } from "@/lib/media-service";
import { routing } from "@/i18n/routing";
import { getLocalizedPath } from "@/lib/locale";
import { removeManagedMediaFile } from "@/lib/media-storage";
import { mediaFieldInputSchema } from "@/lib/media-validation";
import { prisma } from "@/lib/prisma";
function ensureAdmin() {
if (!isAdminAuthenticated()) {
clearAdminSessionCookie();
redirect("/root");
}
}
function withMessage(pathname: string, type: "success" | "error", message: string) {
const params = new URLSearchParams();
params.set(type, message);
return `${pathname}?${params.toString()}`;
}
function parseJsonObject(rawValue: FormDataEntryValue | null, key: string) {
if (typeof rawValue !== "string" || rawValue.trim() === "") {
return undefined;
}
try {
const parsed = JSON.parse(rawValue);
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
throw new Error(`${key} must be an object.`);
}
return parsed;
} catch {
throw new Error(`Invalid ${key} payload.`);
}
}
async function cleanupCreatedMedia(assetIds: string[], uploadedPaths: string[]) {
if (assetIds.length > 0) {
await prisma.mediaAsset.deleteMany({
where: {
id: {
in: Array.from(new Set(assetIds)),
},
},
});
}
for (const filePath of Array.from(new Set(uploadedPaths.filter(Boolean)))) {
await removeManagedMediaFile(filePath);
}
}
async function revalidateSiteSettingsPages() {
revalidatePath("/", "layout");
revalidatePath("/root");
revalidatePath("/root/site-settings");
revalidatePath("/coming-soon");
const publicPaths = ["/", "/about", "/portfolio", "/products", "/contact", "/success", "/coming-soon"];
for (const locale of routing.locales) {
revalidatePath(getLocalizedPath(locale), "layout");
for (const path of publicPaths) {
revalidatePath(getLocalizedPath(locale, path));
}
}
}
export async function saveSiteSettingsAction(formData: FormData) {
ensureAdmin();
const createdMediaAssetIds: string[] = [];
const uploadedPaths: string[] = [];
try {
const faviconMedia = parseJsonObject(formData.get("faviconMedia"), "faviconMedia");
const defaultOgImageMedia = parseJsonObject(
formData.get("defaultOgImageMedia"),
"defaultOgImageMedia",
);
const parsedSettings: SiteSettings = {
locales: {
ar: {
siteName: String(formData.get("siteNameAr") ?? "").trim(),
titleTemplate: String(formData.get("titleTemplateAr") ?? "").trim(),
siteDescription: String(formData.get("siteDescriptionAr") ?? "").trim(),
subhead: String(formData.get("subheadAr") ?? "").trim(),
},
en: {
siteName: String(formData.get("siteNameEn") ?? "").trim(),
titleTemplate: String(formData.get("titleTemplateEn") ?? "").trim(),
siteDescription: String(formData.get("siteDescriptionEn") ?? "").trim(),
subhead: String(formData.get("subheadEn") ?? "").trim(),
},
de: {
siteName: String(formData.get("siteNameDe") ?? "").trim(),
titleTemplate: String(formData.get("titleTemplateDe") ?? "").trim(),
siteDescription: String(formData.get("siteDescriptionDe") ?? "").trim(),
subhead: String(formData.get("subheadDe") ?? "").trim(),
},
},
};
for (const locale of routing.locales) {
if (!parsedSettings.locales[locale].siteName) {
throw new Error(`Site Name fuer ${locale} ist erforderlich.`);
}
if (
!parsedSettings.locales[locale].titleTemplate ||
!parsedSettings.locales[locale].titleTemplate.includes(PAGE_TITLE_TOKEN)
) {
throw new Error(`Title Template fuer ${locale} muss {pageTitle} enthalten.`);
}
}
const faviconSelection = faviconMedia
? await resolveMediaSelection({
media: mediaFieldInputSchema.parse(faviconMedia),
uploadFile: formData.get("faviconFile"),
folder: "site-settings",
fallbackLabel: `${parsedSettings.locales.de.siteName || parsedSettings.locales.en.siteName} favicon`,
required: false,
})
: {
assetId: null,
url: "",
createdAssetId: null,
uploadedUrl: null,
};
const defaultOgImageSelection = defaultOgImageMedia
? await resolveMediaSelection({
media: mediaFieldInputSchema.parse(defaultOgImageMedia),
uploadFile: formData.get("defaultOgImageFile"),
folder: "site-settings",
fallbackLabel: `${parsedSettings.locales.de.siteName || parsedSettings.locales.en.siteName} og-image`,
required: false,
})
: {
assetId: null,
url: "",
createdAssetId: null,
uploadedUrl: null,
};
if (faviconSelection.createdAssetId) {
createdMediaAssetIds.push(faviconSelection.createdAssetId);
}
if (faviconSelection.uploadedUrl) {
uploadedPaths.push(faviconSelection.uploadedUrl);
}
if (defaultOgImageSelection.createdAssetId) {
createdMediaAssetIds.push(defaultOgImageSelection.createdAssetId);
}
if (defaultOgImageSelection.uploadedUrl) {
uploadedPaths.push(defaultOgImageSelection.uploadedUrl);
}
await updateSiteSettings(parsedSettings);
await replaceEntityMediaUsages({
entityType: SITE_SETTINGS_ENTITY_TYPE,
entityId: SITE_SETTINGS_ENTITY_ID,
usages: [
...(faviconSelection.assetId
? [
{
assetId: faviconSelection.assetId,
usageType: MediaUsageType.GENERIC,
fieldKey: SITE_SETTINGS_FAVICON_FIELD_KEY,
},
]
: []),
...(defaultOgImageSelection.assetId
? [
{
assetId: defaultOgImageSelection.assetId,
usageType: MediaUsageType.GENERIC,
fieldKey: SITE_SETTINGS_DEFAULT_OG_IMAGE_FIELD_KEY,
},
]
: []),
],
});
await revalidateSiteSettingsPages();
redirect(withMessage("/root/site-settings", "success", "Einstellungen gespeichert."));
} catch (error) {
if (isRedirectError(error)) {
throw error;
}
await cleanupCreatedMedia(createdMediaAssetIds, uploadedPaths);
const message =
error instanceof Error
? error.message
: "Einstellungen konnten nicht gespeichert werden.";
redirect(withMessage("/root/site-settings", "error", message));
}
}
+96
View File
@@ -0,0 +1,96 @@
import { MediaKind } from "@prisma/client";
import { redirect } from "next/navigation";
import { MotionFade } from "@/components/motion-fade";
import { FormSaveButton } from "@/components/root/form-save-button";
import { SiteSettingsForm } from "@/components/root/site-settings-form";
import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import {
getSiteSettings,
getSiteSettingsMediaBindings,
} from "@/lib/app-config";
import { getMediaOptions } from "@/lib/media";
import { saveSiteSettingsAction } from "./actions";
export const dynamic = "force-dynamic";
const copy = {
title: "SEO",
subtitle: "Globale SEO Einstellungen, Titelstruktur und Standardbilder verwalten.",
overview: "Uebersicht",
maintenance: "Wartungsmodus",
uiKit: "UI Kit",
media: "Media",
siteSettings: "SEO",
portfolio: "Portfolio",
logout: "Ausloggen",
backToSite: "Zur Website",
};
type RootSiteSettingsPageProps = {
searchParams?: {
success?: string;
error?: string;
};
};
export default async function RootSiteSettingsPage({
searchParams,
}: RootSiteSettingsPageProps) {
if (!isAdminAuthenticated()) {
redirect("/root");
}
async function logoutAction() {
"use server";
clearAdminSessionCookie();
redirect("/root");
}
const [siteSettings, mediaBindings, mediaOptions] = await Promise.all([
getSiteSettings(),
getSiteSettingsMediaBindings(),
getMediaOptions({ kind: MediaKind.IMAGE }),
]);
return (
<RootDashboardShell
copy={copy}
active="site-settings"
logoutAction={logoutAction}
headerTitle={copy.title}
headerDescription={copy.subtitle}
headerActions={<FormSaveButton formId="site-settings-form" />}
>
<div className="space-y-6">
{searchParams?.success ? (
<MotionFade delay={0.1}>
<p className="rounded-nested border border-status-success/30 bg-status-success/10 px-4 py-3 text-sm text-status-success">
{searchParams.success}
</p>
</MotionFade>
) : null}
{searchParams?.error ? (
<MotionFade delay={0.12}>
<p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{searchParams.error}
</p>
</MotionFade>
) : null}
<MotionFade delay={0.16}>
<SiteSettingsForm
action={saveSiteSettingsAction}
initialSettings={siteSettings}
initialBindings={mediaBindings}
mediaOptions={mediaOptions}
/>
</MotionFade>
</div>
</RootDashboardShell>
);
}
+1
View File
@@ -14,6 +14,7 @@ const copy = {
overview: "Uebersicht",
uiKit: "UI Kit",
media: "Media",
siteSettings: "SEO",
portfolio: "Portfolio",
logout: "Ausloggen",
backToSite: "Zur Website",