This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user