This commit is contained in:
@@ -21,7 +21,7 @@ export async function generateMetadata({
|
||||
const localeKey = resolveLocale(locale);
|
||||
const t = await getTranslations({ locale: localeKey, namespace: "aboutPage" });
|
||||
|
||||
return buildLocalizedMetadata({
|
||||
return await buildLocalizedMetadata({
|
||||
locale: localeKey,
|
||||
pathname: "/about",
|
||||
title: t("title"),
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function generateMetadata({
|
||||
const localeKey = resolveLocale(locale);
|
||||
const t = await getTranslations({ locale: localeKey, namespace: "contactPage" });
|
||||
|
||||
return buildLocalizedMetadata({
|
||||
return await buildLocalizedMetadata({
|
||||
locale: localeKey,
|
||||
pathname: "/contact",
|
||||
title: t("title"),
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getTranslations } from "next-intl/server";
|
||||
|
||||
import { Container } from "@/components/layout/container";
|
||||
import { MotionFade } from "@/components/motion-fade";
|
||||
import { getSiteSettings } from "@/lib/app-config";
|
||||
import { buildLocalizedMetadata } from "@/lib/metadata";
|
||||
import { AppCard } from "@/components/ui/app-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -34,13 +35,17 @@ export async function generateMetadata({
|
||||
params: { locale },
|
||||
}: HomePageProps): Promise<Metadata> {
|
||||
const localeKey = resolveLocale(locale);
|
||||
const t = await getTranslations({ locale: localeKey, namespace: "homepage" });
|
||||
const [t, siteSettings] = await Promise.all([
|
||||
getTranslations({ locale: localeKey, namespace: "homepage" }),
|
||||
getSiteSettings(),
|
||||
]);
|
||||
|
||||
return buildLocalizedMetadata({
|
||||
return await buildLocalizedMetadata({
|
||||
locale: localeKey,
|
||||
pathname: "/",
|
||||
title: t("heroTitle"),
|
||||
title: siteSettings.locales[localeKey].siteName,
|
||||
description: t("heroText"),
|
||||
applyTitleTemplate: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ export async function generateMetadata({
|
||||
const item = await getPublishedPortfolioProjectBySlug(slug);
|
||||
|
||||
if (!item) {
|
||||
return buildLocalizedMetadata({
|
||||
return await buildLocalizedMetadata({
|
||||
locale: localeKey,
|
||||
pathname: `/portfolio/${slug}`,
|
||||
title: "Portfolio",
|
||||
@@ -66,7 +66,7 @@ export async function generateMetadata({
|
||||
});
|
||||
}
|
||||
|
||||
return buildLocalizedMetadata({
|
||||
return await buildLocalizedMetadata({
|
||||
locale: localeKey,
|
||||
pathname: `/portfolio/${slug}`,
|
||||
title: getLocalizedValue(item.title, localeKey),
|
||||
|
||||
@@ -32,7 +32,7 @@ export async function generateMetadata({
|
||||
const localeKey = resolveLocale(locale);
|
||||
const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" });
|
||||
|
||||
return buildLocalizedMetadata({
|
||||
return await buildLocalizedMetadata({
|
||||
locale: localeKey,
|
||||
pathname: "/portfolio",
|
||||
title: t("title"),
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function generateMetadata({
|
||||
const item = getProductItem(slug);
|
||||
|
||||
if (!item) {
|
||||
return buildLocalizedMetadata({
|
||||
return await buildLocalizedMetadata({
|
||||
locale: localeKey,
|
||||
pathname: `/products/${slug}`,
|
||||
title: "Products",
|
||||
@@ -45,7 +45,7 @@ export async function generateMetadata({
|
||||
});
|
||||
}
|
||||
|
||||
return buildLocalizedMetadata({
|
||||
return await buildLocalizedMetadata({
|
||||
locale: localeKey,
|
||||
pathname: `/products/${slug}`,
|
||||
title: pickText(item.name, localeKey),
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function generateMetadata({
|
||||
const localeKey = resolveLocale(locale);
|
||||
const t = await getTranslations({ locale: localeKey, namespace: "productsPage" });
|
||||
|
||||
return buildLocalizedMetadata({
|
||||
return await buildLocalizedMetadata({
|
||||
locale: localeKey,
|
||||
pathname: "/products",
|
||||
title: t("title"),
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function generateMetadata({
|
||||
const localeKey = resolveLocale(locale);
|
||||
const t = await getTranslations({ locale: localeKey, namespace: "successPage" });
|
||||
|
||||
return buildLocalizedMetadata({
|
||||
return await buildLocalizedMetadata({
|
||||
locale: localeKey,
|
||||
pathname: "/success",
|
||||
title: t("title"),
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function generateMetadata({
|
||||
const localeKey = resolveLocale(locale);
|
||||
const t = await getTranslations({ locale: localeKey, namespace: "comingSoon" });
|
||||
|
||||
return buildLocalizedMetadata({
|
||||
return await buildLocalizedMetadata({
|
||||
locale: localeKey,
|
||||
pathname: "/coming-soon",
|
||||
title: `${t("badge")} | moh-sass`,
|
||||
|
||||
+4
-5
@@ -1,14 +1,13 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getLocale } from "next-intl/server";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { buildAppMetadata } from "@/lib/metadata";
|
||||
import { getDirection } from "@/lib/locale";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "moh-sass",
|
||||
description: "Multilingual Next.js base project",
|
||||
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de"),
|
||||
};
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return buildAppMetadata();
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: "*",
|
||||
disallow: ["/root"],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
<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"
|
||||
<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"
|
||||
value={maintenanceEnabled ? "false" : "true"}
|
||||
/>
|
||||
<Button type="submit">
|
||||
<Power className="h-4 w-4" />
|
||||
{maintenanceEnabled ? copy.disableMaintenance : copy.enableMaintenance}
|
||||
</Button>
|
||||
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>
|
||||
|
||||
@@ -25,6 +25,7 @@ const copy = {
|
||||
maintenance: "Wartungsmodus",
|
||||
uiKit: "UI Kit",
|
||||
media: "Media",
|
||||
siteSettings: "SEO",
|
||||
portfolio: "Portfolio",
|
||||
logout: "Ausloggen",
|
||||
backToSite: "Zur Website",
|
||||
|
||||
@@ -30,6 +30,7 @@ const copy = {
|
||||
maintenance: "Wartungsmodus",
|
||||
uiKit: "UI Kit",
|
||||
media: "Media",
|
||||
siteSettings: "SEO",
|
||||
portfolio: "Portfolio",
|
||||
logout: "Ausloggen",
|
||||
backToSite: "Zur Website",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -19,6 +19,7 @@ const copy = {
|
||||
maintenance: "Wartungsmodus",
|
||||
uiKit: "UI Kit",
|
||||
media: "Media",
|
||||
siteSettings: "SEO",
|
||||
portfolio: "Portfolio",
|
||||
logout: "Ausloggen",
|
||||
backToSite: "Zur Website",
|
||||
|
||||
@@ -24,6 +24,7 @@ const copy = {
|
||||
maintenance: "Wartungsmodus",
|
||||
uiKit: "UI Kit",
|
||||
media: "Media",
|
||||
siteSettings: "SEO",
|
||||
portfolio: "Portfolio",
|
||||
logout: "Ausloggen",
|
||||
backToSite: "Zur Website",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ const copy = {
|
||||
overview: "Uebersicht",
|
||||
uiKit: "UI Kit",
|
||||
media: "Media",
|
||||
siteSettings: "SEO",
|
||||
portfolio: "Portfolio",
|
||||
logout: "Ausloggen",
|
||||
backToSite: "Zur Website",
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { Save } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type FormSaveButtonProps = {
|
||||
formId: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
function serializeForm(form: HTMLFormElement) {
|
||||
return JSON.stringify(
|
||||
Array.from(new FormData(form).entries()).map(([key, value]) => [
|
||||
key,
|
||||
value instanceof File ? `${value.name}:${value.size}:${value.type}` : value,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function FormSaveButton({
|
||||
formId,
|
||||
label = "Speichern",
|
||||
}: FormSaveButtonProps) {
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const form = document.getElementById(formId);
|
||||
|
||||
if (!(form instanceof HTMLFormElement)) {
|
||||
setIsDirty(false);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const initialSnapshot = serializeForm(form);
|
||||
|
||||
const updateDirtyState = () => {
|
||||
setIsDirty(serializeForm(form) !== initialSnapshot);
|
||||
};
|
||||
|
||||
updateDirtyState();
|
||||
|
||||
form.addEventListener("input", updateDirtyState);
|
||||
form.addEventListener("change", updateDirtyState);
|
||||
form.addEventListener("reset", updateDirtyState);
|
||||
|
||||
return () => {
|
||||
form.removeEventListener("input", updateDirtyState);
|
||||
form.removeEventListener("change", updateDirtyState);
|
||||
form.removeEventListener("reset", updateDirtyState);
|
||||
};
|
||||
}, [formId]);
|
||||
|
||||
return (
|
||||
<Button type="submit" form={formId} disabled={!isDirty}>
|
||||
<Save className="h-4 w-4" />
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,9 @@ type MediaFieldPickerProps = {
|
||||
options: MediaOption[];
|
||||
inputName: string;
|
||||
fileFieldName: string;
|
||||
fileLabel?: string;
|
||||
externalLabel?: string;
|
||||
libraryLabel?: string;
|
||||
accept?: string;
|
||||
};
|
||||
|
||||
@@ -36,6 +39,9 @@ export function MediaFieldPicker({
|
||||
options,
|
||||
inputName,
|
||||
fileFieldName,
|
||||
fileLabel,
|
||||
externalLabel,
|
||||
libraryLabel,
|
||||
accept,
|
||||
}: MediaFieldPickerProps) {
|
||||
const filteredOptions = options.filter((option) => option.kind === value.kind);
|
||||
@@ -100,14 +106,14 @@ export function MediaFieldPicker({
|
||||
|
||||
{value.mode === "upload" ? (
|
||||
<div className="space-y-2">
|
||||
<Label>{fileFieldName}</Label>
|
||||
<Label>{fileLabel ?? fileFieldName}</Label>
|
||||
<Input name={fileFieldName} type="file" accept={accept} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{value.mode === "external" ? (
|
||||
<div className="space-y-2">
|
||||
<Label>External URL</Label>
|
||||
<Label>{externalLabel ?? "External URL"}</Label>
|
||||
<Input
|
||||
value={value.url}
|
||||
onChange={(event) => onChange({ ...value, url: event.target.value })}
|
||||
@@ -118,7 +124,7 @@ export function MediaFieldPicker({
|
||||
|
||||
{value.mode === "library" ? (
|
||||
<div className="space-y-2">
|
||||
<Label>Media Library</Label>
|
||||
<Label>{libraryLabel ?? "Media Library"}</Label>
|
||||
<select
|
||||
value={value.assetId}
|
||||
onChange={(event) => onChange({ ...value, assetId: event.target.value })}
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
"use client";
|
||||
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
|
||||
import { MediaKind } from "@prisma/client";
|
||||
import { ImageIcon, Search, Type } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { MediaFieldPicker, type MediaFieldState } from "@/components/root/media-field-picker";
|
||||
import { AppCard } from "@/components/ui/app-card";
|
||||
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
PAGE_TITLE_TOKEN,
|
||||
SITE_NAME_TOKEN,
|
||||
type SiteSettings,
|
||||
type SiteSettingsMediaBindings,
|
||||
} from "@/lib/site-settings";
|
||||
import type { AppLocale } from "@/lib/locale";
|
||||
import type { MediaOption } from "@/lib/media";
|
||||
|
||||
type SiteSettingsFormProps = {
|
||||
action: (formData: FormData) => Promise<void>;
|
||||
initialSettings: SiteSettings;
|
||||
initialBindings: SiteSettingsMediaBindings;
|
||||
mediaOptions: MediaOption[];
|
||||
};
|
||||
|
||||
const localeFields: Array<{
|
||||
key: AppLocale;
|
||||
label: string;
|
||||
suffix: "Ar" | "En" | "De";
|
||||
sampleTitle: string;
|
||||
}> = [
|
||||
{
|
||||
key: "ar",
|
||||
label: "Arabic",
|
||||
suffix: "Ar",
|
||||
sampleTitle: "Home",
|
||||
},
|
||||
{
|
||||
key: "en",
|
||||
label: "English",
|
||||
suffix: "En",
|
||||
sampleTitle: "About",
|
||||
},
|
||||
{
|
||||
key: "de",
|
||||
label: "Deutsch",
|
||||
suffix: "De",
|
||||
sampleTitle: "Portfolio",
|
||||
},
|
||||
];
|
||||
|
||||
function createImageFieldState(
|
||||
assetId: string | null | undefined,
|
||||
url: string | null | undefined,
|
||||
label: string,
|
||||
): MediaFieldState {
|
||||
if (assetId) {
|
||||
return {
|
||||
mode: "library",
|
||||
assetId,
|
||||
url: "",
|
||||
label,
|
||||
kind: MediaKind.IMAGE,
|
||||
};
|
||||
}
|
||||
|
||||
if (url) {
|
||||
return {
|
||||
mode: "external",
|
||||
assetId: "",
|
||||
url,
|
||||
label,
|
||||
kind: MediaKind.IMAGE,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "upload",
|
||||
assetId: "",
|
||||
url: "",
|
||||
label,
|
||||
kind: MediaKind.IMAGE,
|
||||
};
|
||||
}
|
||||
|
||||
function getMediaPreviewUrl(
|
||||
value: MediaFieldState,
|
||||
options: MediaOption[],
|
||||
fallbackUrl: string | null | undefined,
|
||||
) {
|
||||
if (value.mode === "external") {
|
||||
return value.url || fallbackUrl || "";
|
||||
}
|
||||
|
||||
if (value.mode === "library") {
|
||||
return options.find((option) => option.id === value.assetId)?.url ?? fallbackUrl ?? "";
|
||||
}
|
||||
|
||||
return fallbackUrl ?? "";
|
||||
}
|
||||
|
||||
export function SiteSettingsForm({
|
||||
action,
|
||||
initialSettings,
|
||||
initialBindings,
|
||||
mediaOptions,
|
||||
}: SiteSettingsFormProps) {
|
||||
const [settings, setSettings] = useState(initialSettings);
|
||||
const [favicon, setFavicon] = useState<MediaFieldState>(
|
||||
createImageFieldState(
|
||||
initialBindings.favicon?.assetId,
|
||||
initialBindings.favicon?.url,
|
||||
"Favicon",
|
||||
),
|
||||
);
|
||||
const [defaultOgImage, setDefaultOgImage] = useState<MediaFieldState>(
|
||||
createImageFieldState(
|
||||
initialBindings.defaultOgImage?.assetId,
|
||||
initialBindings.defaultOgImage?.url,
|
||||
"Default OG Image",
|
||||
),
|
||||
);
|
||||
const faviconPreviewUrl = getMediaPreviewUrl(favicon, mediaOptions, initialBindings.favicon?.url);
|
||||
const defaultOgImagePreviewUrl = getMediaPreviewUrl(
|
||||
defaultOgImage,
|
||||
mediaOptions,
|
||||
initialBindings.defaultOgImage?.url,
|
||||
);
|
||||
|
||||
return (
|
||||
<form id="site-settings-form" action={action} className="space-y-6">
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,3fr)_minmax(320px,1fr)]">
|
||||
<div className="space-y-6">
|
||||
<AppCard>
|
||||
<CardHeader>
|
||||
<CardTitle>SEO Basics</CardTitle>
|
||||
<CardDescription>
|
||||
Diese Einstellungen bilden die globale Basis fuer Seitentitel, Description und Vorschau in Suchmaschinen.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nutze
|
||||
{" "}
|
||||
<code>{PAGE_TITLE_TOKEN}</code>
|
||||
{" "}
|
||||
fuer den aktuellen Seitentitel.
|
||||
und
|
||||
{" "}
|
||||
<code>{SITE_NAME_TOKEN}</code>
|
||||
{" "}
|
||||
fuer den Namen der aktuellen Sprache.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Beispiel:
|
||||
{" "}
|
||||
<code>{PAGE_TITLE_TOKEN} | {SITE_NAME_TOKEN}</code>
|
||||
</p>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
|
||||
<AppCard>
|
||||
<CardHeader>
|
||||
<CardTitle>Localized Titles And Copy</CardTitle>
|
||||
<CardDescription>
|
||||
Diese Werte gelten als globale Standards pro Sprache und werden verwendet, wenn eine Seite keinen eigenen Fallback hat.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 lg:grid-cols-3">
|
||||
{localeFields.map((locale) => (
|
||||
<div key={locale.key} className="space-y-4 rounded-surface border border-border bg-surface-1 p-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">{locale.label}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Globale Standardwerte fuer diese Sprache.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`siteName${locale.suffix}`}>Site Name</Label>
|
||||
<Input
|
||||
id={`siteName${locale.suffix}`}
|
||||
name={`siteName${locale.suffix}`}
|
||||
value={settings.locales[locale.key].siteName}
|
||||
onChange={(event) =>
|
||||
setSettings((current) => ({
|
||||
...current,
|
||||
locales: {
|
||||
...current.locales,
|
||||
[locale.key]: {
|
||||
...current.locales[locale.key],
|
||||
siteName: event.target.value,
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`titleTemplate${locale.suffix}`}>Title Template</Label>
|
||||
<Input
|
||||
id={`titleTemplate${locale.suffix}`}
|
||||
name={`titleTemplate${locale.suffix}`}
|
||||
value={settings.locales[locale.key].titleTemplate}
|
||||
onChange={(event) =>
|
||||
setSettings((current) => ({
|
||||
...current,
|
||||
locales: {
|
||||
...current.locales,
|
||||
[locale.key]: {
|
||||
...current.locales[locale.key],
|
||||
titleTemplate: event.target.value,
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
placeholder="{pageTitle} | {siteName}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`siteDescription${locale.suffix}`}>Site Description</Label>
|
||||
<Input
|
||||
id={`siteDescription${locale.suffix}`}
|
||||
name={`siteDescription${locale.suffix}`}
|
||||
value={settings.locales[locale.key].siteDescription}
|
||||
onChange={(event) =>
|
||||
setSettings((current) => ({
|
||||
...current,
|
||||
locales: {
|
||||
...current.locales,
|
||||
[locale.key]: {
|
||||
...current.locales[locale.key],
|
||||
siteDescription: event.target.value,
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`subhead${locale.suffix}`}>Subhead</Label>
|
||||
<Input
|
||||
id={`subhead${locale.suffix}`}
|
||||
name={`subhead${locale.suffix}`}
|
||||
value={settings.locales[locale.key].subhead}
|
||||
onChange={(event) =>
|
||||
setSettings((current) => ({
|
||||
...current,
|
||||
locales: {
|
||||
...current.locales,
|
||||
[locale.key]: {
|
||||
...current.locales[locale.key],
|
||||
subhead: event.target.value,
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
|
||||
<AppCard>
|
||||
<CardHeader>
|
||||
<CardTitle>Preview Images</CardTitle>
|
||||
<CardDescription>
|
||||
Favicon erscheint im Browser. Das Default OG Bild wird fuer Social Sharing genutzt, wenn eine Seite kein eigenes Bild liefert.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-6 xl:grid-cols-2">
|
||||
<MediaFieldPicker
|
||||
title="Favicon"
|
||||
value={favicon}
|
||||
onChange={setFavicon}
|
||||
options={mediaOptions}
|
||||
inputName="faviconMedia"
|
||||
fileFieldName="faviconFile"
|
||||
fileLabel="Upload Favicon"
|
||||
externalLabel="Favicon URL"
|
||||
libraryLabel="Favicon Library"
|
||||
accept=".png,.svg,.ico,image/png,image/svg+xml,image/x-icon,image/vnd.microsoft.icon"
|
||||
/>
|
||||
|
||||
<MediaFieldPicker
|
||||
title="Default OG Image"
|
||||
value={defaultOgImage}
|
||||
onChange={setDefaultOgImage}
|
||||
options={mediaOptions}
|
||||
inputName="defaultOgImageMedia"
|
||||
fileFieldName="defaultOgImageFile"
|
||||
fileLabel="Upload OG Image"
|
||||
externalLabel="OG Image URL"
|
||||
libraryLabel="OG Image Library"
|
||||
accept="image/*,.svg"
|
||||
/>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<div className="xl:sticky xl:top-6 xl:self-start">
|
||||
<AppCard>
|
||||
<CardHeader>
|
||||
<CardTitle>Preview</CardTitle>
|
||||
<CardDescription>Suchmaschine und Social Sharing Vorschau.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-4 text-sm">
|
||||
{localeFields.map((locale) => {
|
||||
const localeSettings = settings.locales[locale.key];
|
||||
const previewTitle = localeSettings.titleTemplate.includes(PAGE_TITLE_TOKEN)
|
||||
? localeSettings.titleTemplate
|
||||
.replace(PAGE_TITLE_TOKEN, locale.sampleTitle)
|
||||
.replaceAll(SITE_NAME_TOKEN, localeSettings.siteName)
|
||||
: `${locale.sampleTitle} | ${localeSettings.siteName}`;
|
||||
|
||||
return (
|
||||
<div key={locale.key} className="rounded-nested border border-border bg-background p-3">
|
||||
<p className="font-medium text-foreground">{locale.label}</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Example page title: {locale.sampleTitle}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-foreground">{previewTitle}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{localeSettings.siteDescription || "No description"}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{localeSettings.subhead || "No subhead"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<Search className="h-4 w-4 text-brand-primary" />
|
||||
Search Preview
|
||||
</div>
|
||||
<div className="rounded-nested border border-border bg-background p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{faviconPreviewUrl ? (
|
||||
<img src={faviconPreviewUrl} alt="Favicon" className="h-5 w-5 rounded-sm" />
|
||||
) : (
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded-sm border border-border bg-surface-1">
|
||||
<Type className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm font-medium text-brand-primary">
|
||||
{settings.locales.de.titleTemplate.includes(PAGE_TITLE_TOKEN)
|
||||
? settings.locales.de.titleTemplate
|
||||
.replace(PAGE_TITLE_TOKEN, "About")
|
||||
.replaceAll(SITE_NAME_TOKEN, settings.locales.de.siteName)
|
||||
: `About | ${settings.locales.de.siteName}`}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{settings.locales.de.siteDescription || "No description"}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground/80">
|
||||
{settings.locales.de.subhead || "No subhead"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<ImageIcon className="h-4 w-4 text-brand-secondary" />
|
||||
Social Preview
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-nested border border-border bg-background">
|
||||
{defaultOgImagePreviewUrl ? (
|
||||
<img src={defaultOgImagePreviewUrl} alt="Default OG" className="h-32 w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-32 items-center justify-center bg-surface-1 text-sm text-muted-foreground">
|
||||
No OG image selected
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2 p-4">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{settings.locales.en.titleTemplate.includes(PAGE_TITLE_TOKEN)
|
||||
? settings.locales.en.titleTemplate
|
||||
.replace(PAGE_TITLE_TOKEN, "Portfolio")
|
||||
.replaceAll(SITE_NAME_TOKEN, settings.locales.en.siteName)
|
||||
: `Portfolio | ${settings.locales.en.siteName}`}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{settings.locales.en.siteDescription || "No description"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
+111
-2
@@ -1,14 +1,45 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
import { prisma } from "./prisma";
|
||||
export const MAINTENANCE_MODE_KEY = "maintenance_mode";
|
||||
export {
|
||||
SITE_NAME_KEY,
|
||||
SITE_SETTINGS_DEFAULT_OG_IMAGE_FIELD_KEY,
|
||||
SITE_SETTINGS_ENTITY_ID,
|
||||
SITE_SETTINGS_ENTITY_TYPE,
|
||||
SITE_SETTINGS_FAVICON_FIELD_KEY,
|
||||
SITE_SETTINGS_KEY,
|
||||
DEFAULT_SITE_NAME,
|
||||
buildDefaultSiteSettings,
|
||||
getDefaultSiteSettingsMediaBindings,
|
||||
parseSiteSettingsValue,
|
||||
type SiteSettings,
|
||||
type SiteSettingsMediaBindings,
|
||||
} from "./site-settings";
|
||||
import {
|
||||
DEFAULT_SITE_NAME,
|
||||
SITE_NAME_KEY,
|
||||
SITE_SETTINGS_DEFAULT_OG_IMAGE_FIELD_KEY,
|
||||
SITE_SETTINGS_ENTITY_ID,
|
||||
SITE_SETTINGS_ENTITY_TYPE,
|
||||
SITE_SETTINGS_FAVICON_FIELD_KEY,
|
||||
SITE_SETTINGS_KEY,
|
||||
buildDefaultSiteSettings,
|
||||
getDefaultSiteSettingsMediaBindings,
|
||||
parseSiteSettingsValue,
|
||||
type SiteSettings,
|
||||
type SiteSettingsMediaBindings,
|
||||
} from "./site-settings";
|
||||
|
||||
export async function getMaintenanceMode(): Promise<boolean> {
|
||||
try {
|
||||
const config = await prisma.appConfig.findUnique({
|
||||
where: { key: MAINTENANCE_MODE_KEY },
|
||||
select: { value: true },
|
||||
});
|
||||
|
||||
return config?.value === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setMaintenanceMode(enabled: boolean): Promise<void> {
|
||||
@@ -23,3 +54,81 @@ export async function setMaintenanceMode(enabled: boolean): Promise<void> {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSiteSettings(): Promise<SiteSettings> {
|
||||
try {
|
||||
const configs = await prisma.appConfig.findMany({
|
||||
where: {
|
||||
key: {
|
||||
in: [SITE_SETTINGS_KEY, SITE_NAME_KEY],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
key: true,
|
||||
value: true,
|
||||
},
|
||||
});
|
||||
|
||||
const configMap = new Map(configs.map((config) => [config.key, config.value]));
|
||||
const fallbackName = configMap.get(SITE_NAME_KEY) ?? DEFAULT_SITE_NAME;
|
||||
|
||||
return parseSiteSettingsValue(configMap.get(SITE_SETTINGS_KEY), fallbackName);
|
||||
} catch {
|
||||
return buildDefaultSiteSettings();
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSiteSettings(settings: SiteSettings): Promise<void> {
|
||||
await prisma.appConfig.upsert({
|
||||
where: { key: SITE_SETTINGS_KEY },
|
||||
update: {
|
||||
value: JSON.stringify(settings),
|
||||
},
|
||||
create: {
|
||||
key: SITE_SETTINGS_KEY,
|
||||
value: JSON.stringify(settings),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSiteSettingsMediaBindings(): Promise<SiteSettingsMediaBindings> {
|
||||
try {
|
||||
const usages = await prisma.mediaUsage.findMany({
|
||||
where: {
|
||||
entityType: SITE_SETTINGS_ENTITY_TYPE,
|
||||
entityId: SITE_SETTINGS_ENTITY_ID,
|
||||
},
|
||||
include: {
|
||||
asset: {
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return usages.reduce<SiteSettingsMediaBindings>(
|
||||
(result, usage) => {
|
||||
if (usage.fieldKey === SITE_SETTINGS_FAVICON_FIELD_KEY) {
|
||||
result.favicon = {
|
||||
assetId: usage.asset.id,
|
||||
url: usage.asset.url,
|
||||
};
|
||||
}
|
||||
|
||||
if (usage.fieldKey === SITE_SETTINGS_DEFAULT_OG_IMAGE_FIELD_KEY) {
|
||||
result.defaultOgImage = {
|
||||
assetId: usage.asset.id,
|
||||
url: usage.asset.url,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
getDefaultSiteSettingsMediaBindings(),
|
||||
);
|
||||
} catch {
|
||||
return getDefaultSiteSettingsMediaBindings();
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { routing } from "@/i18n/routing";
|
||||
import { routing } from "../i18n/routing";
|
||||
|
||||
export type AppLocale = (typeof routing.locales)[number];
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ export const MEDIA_UPLOAD_ROOT = path.join(process.cwd(), "public", "uploads", "
|
||||
export const MAX_MEDIA_FILE_SIZE = 5 * 1024 * 1024;
|
||||
|
||||
const MIME_EXTENSIONS: Record<string, string> = {
|
||||
"image/x-icon": ".ico",
|
||||
"image/vnd.microsoft.icon": ".ico",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
|
||||
+132
-10
@@ -1,7 +1,17 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { routing } from "@/i18n/routing";
|
||||
import { AppLocale, getLocalizedPath, resolveLocale } from "@/lib/locale";
|
||||
import { routing } from "../i18n/routing";
|
||||
import {
|
||||
PAGE_TITLE_TOKEN,
|
||||
SITE_NAME_TOKEN,
|
||||
type SiteSettings,
|
||||
type SiteSettingsMediaBindings,
|
||||
} from "./site-settings";
|
||||
import {
|
||||
getSiteSettings,
|
||||
getSiteSettingsMediaBindings,
|
||||
} from "./app-config";
|
||||
import { AppLocale, getLocalizedPath, resolveLocale } from "./locale";
|
||||
|
||||
function getSiteUrl(): URL {
|
||||
return new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de");
|
||||
@@ -25,32 +35,144 @@ export function buildLocaleAlternates(pathname: string) {
|
||||
};
|
||||
}
|
||||
|
||||
export function applyTitleTemplateFn(title: string, template: string, siteName: string): string {
|
||||
const safeTemplate = template.includes(PAGE_TITLE_TOKEN)
|
||||
? template
|
||||
: `${PAGE_TITLE_TOKEN} | ${SITE_NAME_TOKEN}`;
|
||||
|
||||
return safeTemplate
|
||||
.replaceAll(SITE_NAME_TOKEN, siteName)
|
||||
.replace(PAGE_TITLE_TOKEN, title);
|
||||
}
|
||||
|
||||
function buildMetadataImages(imageUrl?: string | null) {
|
||||
if (!imageUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
url: toAbsoluteUrl(imageUrl),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export async function buildAppMetadata(): Promise<Metadata> {
|
||||
const [settings, bindings] = await Promise.all([getSiteSettings(), getSiteSettingsMediaBindings()]);
|
||||
|
||||
return buildAppMetadataFromConfig(settings, bindings);
|
||||
}
|
||||
|
||||
export function buildAppMetadataFromConfig(
|
||||
settings: SiteSettings,
|
||||
bindings: SiteSettingsMediaBindings,
|
||||
): Metadata {
|
||||
const defaultLocaleSettings = settings.locales[routing.defaultLocale];
|
||||
const openGraphImages = buildMetadataImages(bindings.defaultOgImage?.url);
|
||||
|
||||
return {
|
||||
metadataBase: getSiteUrl(),
|
||||
title: defaultLocaleSettings.siteName,
|
||||
description: defaultLocaleSettings.siteDescription,
|
||||
applicationName: defaultLocaleSettings.siteName,
|
||||
icons: bindings.favicon?.url
|
||||
? {
|
||||
icon: [bindings.favicon.url],
|
||||
shortcut: [bindings.favicon.url],
|
||||
apple: [bindings.favicon.url],
|
||||
}
|
||||
: undefined,
|
||||
openGraph: {
|
||||
title: defaultLocaleSettings.siteName,
|
||||
description: defaultLocaleSettings.siteDescription,
|
||||
url: toAbsoluteUrl(getLocalizedPath(routing.defaultLocale, "/")),
|
||||
siteName: defaultLocaleSettings.siteName,
|
||||
locale: routing.defaultLocale,
|
||||
type: "website",
|
||||
images: openGraphImages,
|
||||
},
|
||||
twitter: {
|
||||
card: openGraphImages ? "summary_large_image" : "summary",
|
||||
title: defaultLocaleSettings.siteName,
|
||||
description: defaultLocaleSettings.siteDescription,
|
||||
images: openGraphImages?.map((image) => image.url),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type LocalizedMetadataInput = {
|
||||
locale: string;
|
||||
pathname: string;
|
||||
title: string;
|
||||
description: string;
|
||||
description?: string;
|
||||
applyTitleTemplate?: boolean;
|
||||
};
|
||||
|
||||
export function buildLocalizedMetadata({
|
||||
export async function buildLocalizedMetadata({
|
||||
locale,
|
||||
pathname,
|
||||
title,
|
||||
description,
|
||||
}: LocalizedMetadataInput): Metadata {
|
||||
applyTitleTemplate,
|
||||
}: LocalizedMetadataInput): Promise<Metadata> {
|
||||
const localeKey = resolveLocale(locale);
|
||||
const [settings, bindings] = await Promise.all([getSiteSettings(), getSiteSettingsMediaBindings()]);
|
||||
|
||||
return buildLocalizedMetadataFromConfig({
|
||||
settings,
|
||||
bindings,
|
||||
locale: localeKey,
|
||||
pathname,
|
||||
title,
|
||||
description,
|
||||
applyTitleTemplate,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildLocalizedMetadataFromConfig(input: {
|
||||
settings: SiteSettings;
|
||||
bindings: SiteSettingsMediaBindings;
|
||||
locale: AppLocale;
|
||||
pathname: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
applyTitleTemplate?: boolean;
|
||||
}): Metadata {
|
||||
const {
|
||||
settings,
|
||||
bindings,
|
||||
locale,
|
||||
pathname,
|
||||
title,
|
||||
description,
|
||||
applyTitleTemplate = true,
|
||||
} = input;
|
||||
const localeKey = resolveLocale(locale);
|
||||
const localeSettings = settings.locales[localeKey];
|
||||
const resolvedDescription = description?.trim() || localeSettings.siteDescription;
|
||||
const resolvedTitle = applyTitleTemplate
|
||||
? applyTitleTemplateFn(title, localeSettings.titleTemplate, localeSettings.siteName)
|
||||
: title;
|
||||
const openGraphImages = buildMetadataImages(bindings.defaultOgImage?.url);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
title: resolvedTitle,
|
||||
description: resolvedDescription,
|
||||
alternates: buildLocaleAlternates(pathname),
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
title: resolvedTitle,
|
||||
description: resolvedDescription,
|
||||
url: toAbsoluteUrl(getLocalizedPath(localeKey, pathname)),
|
||||
siteName: "moh-sass",
|
||||
siteName: localeSettings.siteName,
|
||||
locale: localeKey,
|
||||
type: "website",
|
||||
images: openGraphImages,
|
||||
},
|
||||
twitter: {
|
||||
card: openGraphImages ? "summary_large_image" : "summary",
|
||||
title: resolvedTitle,
|
||||
description: resolvedDescription,
|
||||
images: openGraphImages?.map((image) => image.url),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
FolderKanban,
|
||||
Globe2,
|
||||
ImageIcon,
|
||||
LayoutDashboard,
|
||||
PlusSquare,
|
||||
@@ -15,6 +16,7 @@ type RootNavigationCopy = {
|
||||
uiKit: string;
|
||||
portfolio: string;
|
||||
media: string;
|
||||
siteSettings: string;
|
||||
};
|
||||
|
||||
export type RootNavItem = {
|
||||
@@ -27,7 +29,7 @@ export type RootNavItem = {
|
||||
|
||||
export function getRootNavigation(
|
||||
copy: RootNavigationCopy,
|
||||
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media",
|
||||
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings",
|
||||
portfolioChild?: "overview" | "projects" | "new-project" | "categories",
|
||||
): RootNavItem[] {
|
||||
return [
|
||||
@@ -55,6 +57,12 @@ export function getRootNavigation(
|
||||
icon: ImageIcon,
|
||||
active: active === "media",
|
||||
},
|
||||
{
|
||||
label: copy.siteSettings,
|
||||
href: "/root/site-settings",
|
||||
icon: Globe2,
|
||||
active: active === "site-settings",
|
||||
},
|
||||
{
|
||||
label: copy.portfolio,
|
||||
href: "/root/portfolio",
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { AppLocale } from "./locale";
|
||||
|
||||
export const SITE_NAME_KEY = "siteName";
|
||||
export const SITE_SETTINGS_KEY = "site_settings";
|
||||
export const SITE_SETTINGS_ENTITY_TYPE = "site-settings";
|
||||
export const SITE_SETTINGS_ENTITY_ID = "global";
|
||||
export const SITE_SETTINGS_FAVICON_FIELD_KEY = "favicon";
|
||||
export const SITE_SETTINGS_DEFAULT_OG_IMAGE_FIELD_KEY = "defaultOgImage";
|
||||
export const PAGE_TITLE_TOKEN = "{pageTitle}";
|
||||
export const SITE_NAME_TOKEN = "{siteName}";
|
||||
|
||||
export const DEFAULT_SITE_NAME = "moh-sass";
|
||||
export const DEFAULT_SITE_DESCRIPTION = "Multilingual Next.js base project";
|
||||
|
||||
type SiteLocaleSettings = {
|
||||
siteName: string;
|
||||
titleTemplate: string;
|
||||
siteDescription: string;
|
||||
subhead: string;
|
||||
};
|
||||
|
||||
export type SiteSettings = {
|
||||
locales: Record<AppLocale, SiteLocaleSettings>;
|
||||
};
|
||||
|
||||
export type SiteSettingsMediaBinding = {
|
||||
assetId: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type SiteSettingsMediaBindings = {
|
||||
favicon: SiteSettingsMediaBinding | null;
|
||||
defaultOgImage: SiteSettingsMediaBinding | null;
|
||||
};
|
||||
|
||||
function normalizeSiteLocaleSettings(
|
||||
input: unknown,
|
||||
fallbackName: string,
|
||||
fallbackDescription: string,
|
||||
): SiteLocaleSettings {
|
||||
const value = input && typeof input === "object" ? (input as Record<string, unknown>) : {};
|
||||
|
||||
return {
|
||||
siteName:
|
||||
typeof value.siteName === "string" && value.siteName.trim()
|
||||
? value.siteName.trim()
|
||||
: fallbackName,
|
||||
titleTemplate:
|
||||
typeof value.titleTemplate === "string" &&
|
||||
value.titleTemplate.trim() &&
|
||||
value.titleTemplate.includes(PAGE_TITLE_TOKEN)
|
||||
? value.titleTemplate.trim()
|
||||
: `${PAGE_TITLE_TOKEN} | ${SITE_NAME_TOKEN}`,
|
||||
siteDescription:
|
||||
typeof value.siteDescription === "string"
|
||||
? value.siteDescription.trim()
|
||||
: fallbackDescription,
|
||||
subhead: typeof value.subhead === "string" ? value.subhead.trim() : "",
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultSiteSettingsMediaBindings(): SiteSettingsMediaBindings {
|
||||
return {
|
||||
favicon: null,
|
||||
defaultOgImage: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDefaultSiteSettings(fallbackName = DEFAULT_SITE_NAME): SiteSettings {
|
||||
return {
|
||||
locales: {
|
||||
ar: {
|
||||
siteName: fallbackName,
|
||||
titleTemplate: `${PAGE_TITLE_TOKEN} | ${SITE_NAME_TOKEN}`,
|
||||
siteDescription: DEFAULT_SITE_DESCRIPTION,
|
||||
subhead: "",
|
||||
},
|
||||
en: {
|
||||
siteName: fallbackName,
|
||||
titleTemplate: `${PAGE_TITLE_TOKEN} | ${SITE_NAME_TOKEN}`,
|
||||
siteDescription: DEFAULT_SITE_DESCRIPTION,
|
||||
subhead: "",
|
||||
},
|
||||
de: {
|
||||
siteName: fallbackName,
|
||||
titleTemplate: `${PAGE_TITLE_TOKEN} | ${SITE_NAME_TOKEN}`,
|
||||
siteDescription: DEFAULT_SITE_DESCRIPTION,
|
||||
subhead: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSiteSettingsValue(
|
||||
rawValue: string | null | undefined,
|
||||
fallbackName = DEFAULT_SITE_NAME,
|
||||
): SiteSettings {
|
||||
const defaults = buildDefaultSiteSettings(fallbackName);
|
||||
|
||||
if (!rawValue) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawValue) as Record<string, unknown>;
|
||||
const locales = parsed.locales && typeof parsed.locales === "object"
|
||||
? (parsed.locales as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const siteSettings: SiteSettings = {
|
||||
locales: {
|
||||
ar: normalizeSiteLocaleSettings(
|
||||
{
|
||||
titleTemplate:
|
||||
typeof parsed.titleTemplate === "string" && parsed.titleTemplate.trim()
|
||||
? parsed.titleTemplate.trim()
|
||||
: undefined,
|
||||
...(locales.ar && typeof locales.ar === "object" ? (locales.ar as Record<string, unknown>) : {}),
|
||||
},
|
||||
defaults.locales.ar.siteName,
|
||||
defaults.locales.ar.siteDescription,
|
||||
),
|
||||
en: normalizeSiteLocaleSettings(
|
||||
{
|
||||
titleTemplate:
|
||||
typeof parsed.titleTemplate === "string" && parsed.titleTemplate.trim()
|
||||
? parsed.titleTemplate.trim()
|
||||
: undefined,
|
||||
...(locales.en && typeof locales.en === "object" ? (locales.en as Record<string, unknown>) : {}),
|
||||
},
|
||||
defaults.locales.en.siteName,
|
||||
defaults.locales.en.siteDescription,
|
||||
),
|
||||
de: normalizeSiteLocaleSettings(
|
||||
{
|
||||
titleTemplate:
|
||||
typeof parsed.titleTemplate === "string" && parsed.titleTemplate.trim()
|
||||
? parsed.titleTemplate.trim()
|
||||
: undefined,
|
||||
...(locales.de && typeof locales.de === "object" ? (locales.de as Record<string, unknown>) : {}),
|
||||
},
|
||||
defaults.locales.de.siteName,
|
||||
defaults.locales.de.siteDescription,
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
return siteSettings;
|
||||
} catch {
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -43,6 +43,9 @@ export default async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
const isRootBaseRoute = pathname === "/root" || pathname.startsWith("/root/");
|
||||
const isRootRoute = isRootBaseRoute;
|
||||
const rootRobotsHeaders = {
|
||||
"X-Robots-Tag": "noindex, nofollow, noarchive, nosnippet, noimageindex",
|
||||
};
|
||||
|
||||
if (pathname === "/de" || pathname.startsWith("/de/")) {
|
||||
const redirectUrl = request.nextUrl.clone();
|
||||
@@ -56,12 +59,15 @@ export default async function middleware(request: NextRequest) {
|
||||
status: 401,
|
||||
headers: {
|
||||
"WWW-Authenticate": 'Basic realm="Root Area", charset="UTF-8"',
|
||||
...rootRobotsHeaders,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (isRootBaseRoute) {
|
||||
return NextResponse.next();
|
||||
const response = NextResponse.next();
|
||||
response.headers.set("X-Robots-Tag", rootRobotsHeaders["X-Robots-Tag"]);
|
||||
return response;
|
||||
}
|
||||
|
||||
return intlMiddleware(request);
|
||||
|
||||
@@ -16,6 +16,61 @@ async function main() {
|
||||
create: { key: "siteName", value: "moh-sass" },
|
||||
});
|
||||
|
||||
await prisma.appConfig.upsert({
|
||||
where: { key: "site_settings" },
|
||||
update: {
|
||||
value: JSON.stringify({
|
||||
titleTemplate: "{pageTitle} | moh-sass",
|
||||
locales: {
|
||||
ar: {
|
||||
siteName: "moh-sass",
|
||||
titleTemplate: "{pageTitle} | {siteName}",
|
||||
siteDescription: "Multilingual Next.js base project",
|
||||
subhead: "",
|
||||
},
|
||||
en: {
|
||||
siteName: "moh-sass",
|
||||
titleTemplate: "{pageTitle} | {siteName}",
|
||||
siteDescription: "Multilingual Next.js base project",
|
||||
subhead: "",
|
||||
},
|
||||
de: {
|
||||
siteName: "moh-sass",
|
||||
titleTemplate: "{pageTitle} | {siteName}",
|
||||
siteDescription: "Multilingual Next.js base project",
|
||||
subhead: "",
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
create: {
|
||||
key: "site_settings",
|
||||
value: JSON.stringify({
|
||||
titleTemplate: "{pageTitle} | moh-sass",
|
||||
locales: {
|
||||
ar: {
|
||||
siteName: "moh-sass",
|
||||
titleTemplate: "{pageTitle} | {siteName}",
|
||||
siteDescription: "Multilingual Next.js base project",
|
||||
subhead: "",
|
||||
},
|
||||
en: {
|
||||
siteName: "moh-sass",
|
||||
titleTemplate: "{pageTitle} | {siteName}",
|
||||
siteDescription: "Multilingual Next.js base project",
|
||||
subhead: "",
|
||||
},
|
||||
de: {
|
||||
siteName: "moh-sass",
|
||||
titleTemplate: "{pageTitle} | {siteName}",
|
||||
siteDescription: "Multilingual Next.js base project",
|
||||
subhead: "",
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const brandCategory = await prisma.category.upsert({
|
||||
where: { slug: "branding" },
|
||||
update: {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildDefaultSiteSettings,
|
||||
parseSiteSettingsValue,
|
||||
} from "../lib/site-settings";
|
||||
|
||||
describe("site settings helpers", () => {
|
||||
it("builds defaults from the fallback site name", () => {
|
||||
const settings = buildDefaultSiteSettings("Studio Moh");
|
||||
|
||||
expect(settings.locales.ar.siteName).toBe("Studio Moh");
|
||||
expect(settings.locales.ar.titleTemplate).toBe("{pageTitle} | {siteName}");
|
||||
expect(settings.locales.en.siteDescription).toBe("Multilingual Next.js base project");
|
||||
expect(settings.locales.de.subhead).toBe("");
|
||||
});
|
||||
|
||||
it("merges stored values with safe defaults", () => {
|
||||
const settings = parseSiteSettingsValue(
|
||||
JSON.stringify({
|
||||
locales: {
|
||||
en: {
|
||||
siteName: "Brand EN",
|
||||
titleTemplate: "{pageTitle} - {siteName}",
|
||||
siteDescription: "English description",
|
||||
},
|
||||
de: {
|
||||
siteName: "Brand DE",
|
||||
titleTemplate: "{pageTitle} | {siteName} | Freelancer",
|
||||
subhead: "German subhead",
|
||||
},
|
||||
},
|
||||
}),
|
||||
"Fallback Name",
|
||||
);
|
||||
|
||||
expect(settings.locales.en.siteName).toBe("Brand EN");
|
||||
expect(settings.locales.en.titleTemplate).toBe("{pageTitle} - {siteName}");
|
||||
expect(settings.locales.en.siteDescription).toBe("English description");
|
||||
expect(settings.locales.en.subhead).toBe("");
|
||||
expect(settings.locales.ar.siteName).toBe("Fallback Name");
|
||||
expect(settings.locales.ar.titleTemplate).toBe("{pageTitle} | {siteName}");
|
||||
expect(settings.locales.de.siteDescription).toBe("Multilingual Next.js base project");
|
||||
expect(settings.locales.de.titleTemplate).toBe("{pageTitle} | {siteName} | Freelancer");
|
||||
expect(settings.locales.de.subhead).toBe("German subhead");
|
||||
});
|
||||
|
||||
it("falls back when stored json is invalid", () => {
|
||||
const settings = parseSiteSettingsValue("{invalid-json", "Fallback Name");
|
||||
|
||||
expect(settings).toEqual(buildDefaultSiteSettings("Fallback Name"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildDefaultSiteSettings } from "../lib/site-settings";
|
||||
import {
|
||||
applyTitleTemplateFn,
|
||||
buildAppMetadataFromConfig,
|
||||
buildLocalizedMetadataFromConfig,
|
||||
} from "../lib/metadata";
|
||||
|
||||
describe("metadata helpers", () => {
|
||||
it("applies the configured title template", () => {
|
||||
expect(applyTitleTemplateFn("About", "{pageTitle} | {siteName}", "Studio Moh")).toBe(
|
||||
"About | Studio Moh",
|
||||
);
|
||||
expect(applyTitleTemplateFn("About", "Studio Moh", "Studio Moh")).toBe("About | Studio Moh");
|
||||
});
|
||||
|
||||
it("builds root metadata with dynamic icons and social preview", () => {
|
||||
const settings = buildDefaultSiteSettings("Studio Moh");
|
||||
const metadata = buildAppMetadataFromConfig(settings, {
|
||||
favicon: {
|
||||
assetId: "fav",
|
||||
url: "/uploads/media/site-settings/favicon.svg",
|
||||
},
|
||||
defaultOgImage: {
|
||||
assetId: "og",
|
||||
url: "/uploads/media/site-settings/default-og.png",
|
||||
},
|
||||
});
|
||||
|
||||
expect(metadata.title).toBe("Studio Moh");
|
||||
expect(metadata.icons).toEqual({
|
||||
icon: ["/uploads/media/site-settings/favicon.svg"],
|
||||
shortcut: ["/uploads/media/site-settings/favicon.svg"],
|
||||
apple: ["/uploads/media/site-settings/favicon.svg"],
|
||||
});
|
||||
expect(metadata.twitter).toMatchObject({
|
||||
card: "summary_large_image",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to localized site description and omits icons when unset", () => {
|
||||
const settings = buildDefaultSiteSettings("Studio Moh");
|
||||
settings.locales.en.siteDescription = "English fallback description";
|
||||
settings.locales.en.titleTemplate = "{pageTitle} | {siteName} | Freelancer";
|
||||
const metadata = buildLocalizedMetadataFromConfig({
|
||||
settings,
|
||||
bindings: {
|
||||
favicon: null,
|
||||
defaultOgImage: null,
|
||||
},
|
||||
locale: "en",
|
||||
pathname: "/about",
|
||||
title: "About",
|
||||
});
|
||||
|
||||
expect(metadata.title).toBe("About | Studio Moh | Freelancer");
|
||||
expect(metadata.description).toBe("English fallback description");
|
||||
expect(metadata.openGraph?.siteName).toBe("Studio Moh");
|
||||
expect(metadata.twitter).toMatchObject({
|
||||
card: "summary",
|
||||
});
|
||||
});
|
||||
|
||||
it("can skip the title template for the homepage", () => {
|
||||
const settings = buildDefaultSiteSettings("Studio Moh");
|
||||
settings.locales.ar.siteName = "اسم الموقع";
|
||||
settings.locales.ar.titleTemplate = "{pageTitle} | {siteName}";
|
||||
|
||||
const metadata = buildLocalizedMetadataFromConfig({
|
||||
settings,
|
||||
bindings: {
|
||||
favicon: null,
|
||||
defaultOgImage: null,
|
||||
},
|
||||
locale: "ar",
|
||||
pathname: "/",
|
||||
title: "اسم الموقع",
|
||||
description: "وصف",
|
||||
applyTitleTemplate: false,
|
||||
});
|
||||
|
||||
expect(metadata.title).toBe("اسم الموقع");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user