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
+1 -1
View File
@@ -21,7 +21,7 @@ export async function generateMetadata({
const localeKey = resolveLocale(locale); const localeKey = resolveLocale(locale);
const t = await getTranslations({ locale: localeKey, namespace: "aboutPage" }); const t = await getTranslations({ locale: localeKey, namespace: "aboutPage" });
return buildLocalizedMetadata({ return await buildLocalizedMetadata({
locale: localeKey, locale: localeKey,
pathname: "/about", pathname: "/about",
title: t("title"), title: t("title"),
+1 -1
View File
@@ -26,7 +26,7 @@ export async function generateMetadata({
const localeKey = resolveLocale(locale); const localeKey = resolveLocale(locale);
const t = await getTranslations({ locale: localeKey, namespace: "contactPage" }); const t = await getTranslations({ locale: localeKey, namespace: "contactPage" });
return buildLocalizedMetadata({ return await buildLocalizedMetadata({
locale: localeKey, locale: localeKey,
pathname: "/contact", pathname: "/contact",
title: t("title"), title: t("title"),
+8 -3
View File
@@ -11,6 +11,7 @@ import { getTranslations } from "next-intl/server";
import { Container } from "@/components/layout/container"; import { Container } from "@/components/layout/container";
import { MotionFade } from "@/components/motion-fade"; import { MotionFade } from "@/components/motion-fade";
import { getSiteSettings } from "@/lib/app-config";
import { buildLocalizedMetadata } from "@/lib/metadata"; import { buildLocalizedMetadata } from "@/lib/metadata";
import { AppCard } from "@/components/ui/app-card"; import { AppCard } from "@/components/ui/app-card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -34,13 +35,17 @@ export async function generateMetadata({
params: { locale }, params: { locale },
}: HomePageProps): Promise<Metadata> { }: HomePageProps): Promise<Metadata> {
const localeKey = resolveLocale(locale); 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, locale: localeKey,
pathname: "/", pathname: "/",
title: t("heroTitle"), title: siteSettings.locales[localeKey].siteName,
description: t("heroText"), description: t("heroText"),
applyTitleTemplate: false,
}); });
} }
@@ -58,7 +58,7 @@ export async function generateMetadata({
const item = await getPublishedPortfolioProjectBySlug(slug); const item = await getPublishedPortfolioProjectBySlug(slug);
if (!item) { if (!item) {
return buildLocalizedMetadata({ return await buildLocalizedMetadata({
locale: localeKey, locale: localeKey,
pathname: `/portfolio/${slug}`, pathname: `/portfolio/${slug}`,
title: "Portfolio", title: "Portfolio",
@@ -66,7 +66,7 @@ export async function generateMetadata({
}); });
} }
return buildLocalizedMetadata({ return await buildLocalizedMetadata({
locale: localeKey, locale: localeKey,
pathname: `/portfolio/${slug}`, pathname: `/portfolio/${slug}`,
title: getLocalizedValue(item.title, localeKey), title: getLocalizedValue(item.title, localeKey),
+1 -1
View File
@@ -32,7 +32,7 @@ export async function generateMetadata({
const localeKey = resolveLocale(locale); const localeKey = resolveLocale(locale);
const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" }); const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" });
return buildLocalizedMetadata({ return await buildLocalizedMetadata({
locale: localeKey, locale: localeKey,
pathname: "/portfolio", pathname: "/portfolio",
title: t("title"), title: t("title"),
+2 -2
View File
@@ -37,7 +37,7 @@ export async function generateMetadata({
const item = getProductItem(slug); const item = getProductItem(slug);
if (!item) { if (!item) {
return buildLocalizedMetadata({ return await buildLocalizedMetadata({
locale: localeKey, locale: localeKey,
pathname: `/products/${slug}`, pathname: `/products/${slug}`,
title: "Products", title: "Products",
@@ -45,7 +45,7 @@ export async function generateMetadata({
}); });
} }
return buildLocalizedMetadata({ return await buildLocalizedMetadata({
locale: localeKey, locale: localeKey,
pathname: `/products/${slug}`, pathname: `/products/${slug}`,
title: pickText(item.name, localeKey), title: pickText(item.name, localeKey),
+1 -1
View File
@@ -23,7 +23,7 @@ export async function generateMetadata({
const localeKey = resolveLocale(locale); const localeKey = resolveLocale(locale);
const t = await getTranslations({ locale: localeKey, namespace: "productsPage" }); const t = await getTranslations({ locale: localeKey, namespace: "productsPage" });
return buildLocalizedMetadata({ return await buildLocalizedMetadata({
locale: localeKey, locale: localeKey,
pathname: "/products", pathname: "/products",
title: t("title"), title: t("title"),
+1 -1
View File
@@ -23,7 +23,7 @@ export async function generateMetadata({
const localeKey = resolveLocale(locale); const localeKey = resolveLocale(locale);
const t = await getTranslations({ locale: localeKey, namespace: "successPage" }); const t = await getTranslations({ locale: localeKey, namespace: "successPage" });
return buildLocalizedMetadata({ return await buildLocalizedMetadata({
locale: localeKey, locale: localeKey,
pathname: "/success", pathname: "/success",
title: t("title"), title: t("title"),
+1 -1
View File
@@ -25,7 +25,7 @@ export async function generateMetadata({
const localeKey = resolveLocale(locale); const localeKey = resolveLocale(locale);
const t = await getTranslations({ locale: localeKey, namespace: "comingSoon" }); const t = await getTranslations({ locale: localeKey, namespace: "comingSoon" });
return buildLocalizedMetadata({ return await buildLocalizedMetadata({
locale: localeKey, locale: localeKey,
pathname: "/coming-soon", pathname: "/coming-soon",
title: `${t("badge")} | moh-sass`, title: `${t("badge")} | moh-sass`,
+4 -5
View File
@@ -1,14 +1,13 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { getLocale } from "next-intl/server"; import { getLocale } from "next-intl/server";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { buildAppMetadata } from "@/lib/metadata";
import { getDirection } from "@/lib/locale"; import { getDirection } from "@/lib/locale";
import "./globals.css"; import "./globals.css";
export const metadata: Metadata = { export async function generateMetadata(): Promise<Metadata> {
title: "moh-sass", return buildAppMetadata();
description: "Multilingual Next.js base project", }
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de"),
};
export default async function RootLayout({ export default async function RootLayout({
children, children,
+12
View File
@@ -0,0 +1,12 @@
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
disallow: ["/root"],
},
],
};
}
+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 { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { FormSaveButton } from "@/components/root/form-save-button";
import { MotionFade } from "@/components/motion-fade"; import { MotionFade } from "@/components/motion-fade";
import { RootDashboardShell } from "@/components/root/root-dashboard-shell"; import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
import { routing } from "@/i18n/routing"; import { routing } from "@/i18n/routing";
@@ -10,8 +11,8 @@ import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"
import { getMaintenanceMode, setMaintenanceMode } from "@/lib/app-config"; import { getMaintenanceMode, setMaintenanceMode } from "@/lib/app-config";
import { AppCard } from "@/components/ui/app-card"; import { AppCard } from "@/components/ui/app-card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CardContent } from "@/components/ui/card"; import { CardContent } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -22,12 +23,13 @@ const copy = {
maintenance: "Wartungsmodus", maintenance: "Wartungsmodus",
uiKit: "UI Kit", uiKit: "UI Kit",
media: "Media", media: "Media",
siteSettings: "SEO",
portfolio: "Portfolio", portfolio: "Portfolio",
maintenanceText: "Wenn aktiv, werden alle Seiten auf die Coming Soon Seite weitergeleitet.", maintenanceText: "Wenn aktiv, werden alle Seiten auf die Coming Soon Seite weitergeleitet.",
maintenanceOn: "Aktiv", maintenanceOn: "Aktiv",
maintenanceOff: "Inaktiv", maintenanceOff: "Inaktiv",
enableMaintenance: "Wartungsmodus aktivieren", selectLabel: "Status",
disableMaintenance: "Wartungsmodus deaktivieren", selectHint: "Aenderung wird erst nach Speichern uebernommen.",
logout: "Ausloggen", logout: "Ausloggen",
backToSite: "Zur Website", backToSite: "Zur Website",
}; };
@@ -79,25 +81,36 @@ export default async function RootMaintenancePage() {
headerTitle={copy.title} headerTitle={copy.title}
headerDescription={copy.subtitle} headerDescription={copy.subtitle}
headerActions={ headerActions={
<Badge variant={maintenanceEnabled ? "warning" : "success"}> <>
{maintenanceEnabled ? copy.maintenanceOn : copy.maintenanceOff} <Badge variant={maintenanceEnabled ? "warning" : "success"}>
</Badge> {maintenanceEnabled ? copy.maintenanceOn : copy.maintenanceOff}
</Badge>
<FormSaveButton formId="maintenance-form" />
</>
} }
> >
<MotionFade delay={0.1}> <MotionFade delay={0.1}>
<AppCard> <AppCard>
<CardContent className="space-y-4 p-6"> <CardContent className="space-y-4 p-6">
<p className="text-sm text-muted-foreground">{copy.maintenanceText}</p> <p className="text-sm text-muted-foreground">{copy.maintenanceText}</p>
<form action={updateMaintenanceMode}> <form id="maintenance-form" action={updateMaintenanceMode} className="space-y-3">
<input <div className="space-y-2">
type="hidden" <Label htmlFor="enabled">{copy.selectLabel}</Label>
name="enabled" <select
value={maintenanceEnabled ? "false" : "true"} id="enabled"
/> name="enabled"
<Button type="submit"> defaultValue={maintenanceEnabled ? "true" : "false"}
<Power className="h-4 w-4" /> className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
{maintenanceEnabled ? copy.disableMaintenance : copy.enableMaintenance} >
</Button> <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> </form>
</CardContent> </CardContent>
</AppCard> </AppCard>
+1
View File
@@ -25,6 +25,7 @@ const copy = {
maintenance: "Wartungsmodus", maintenance: "Wartungsmodus",
uiKit: "UI Kit", uiKit: "UI Kit",
media: "Media", media: "Media",
siteSettings: "SEO",
portfolio: "Portfolio", portfolio: "Portfolio",
logout: "Ausloggen", logout: "Ausloggen",
backToSite: "Zur Website", backToSite: "Zur Website",
+1
View File
@@ -30,6 +30,7 @@ const copy = {
maintenance: "Wartungsmodus", maintenance: "Wartungsmodus",
uiKit: "UI Kit", uiKit: "UI Kit",
media: "Media", media: "Media",
siteSettings: "SEO",
portfolio: "Portfolio", portfolio: "Portfolio",
logout: "Ausloggen", logout: "Ausloggen",
backToSite: "Zur Website", backToSite: "Zur Website",
+1
View File
@@ -22,6 +22,7 @@ const copy = {
overview: "Uebersicht", overview: "Uebersicht",
maintenance: "Wartungsmodus", maintenance: "Wartungsmodus",
uiKit: "UI Kit", uiKit: "UI Kit",
siteSettings: "SEO",
portfolio: "Portfolio", portfolio: "Portfolio",
logout: "Ausloggen", logout: "Ausloggen",
backToSite: "Zur Website", backToSite: "Zur Website",
@@ -25,6 +25,7 @@ const copy = {
maintenance: "Wartungsmodus", maintenance: "Wartungsmodus",
uiKit: "UI Kit", uiKit: "UI Kit",
media: "Media", media: "Media",
siteSettings: "SEO",
portfolio: "Portfolio", portfolio: "Portfolio",
logout: "Ausloggen", logout: "Ausloggen",
backToSite: "Zur Website", backToSite: "Zur Website",
+1
View File
@@ -19,6 +19,7 @@ const copy = {
maintenance: "Wartungsmodus", maintenance: "Wartungsmodus",
uiKit: "UI Kit", uiKit: "UI Kit",
media: "Media", media: "Media",
siteSettings: "SEO",
portfolio: "Portfolio", portfolio: "Portfolio",
logout: "Ausloggen", logout: "Ausloggen",
backToSite: "Zur Website", backToSite: "Zur Website",
+1
View File
@@ -24,6 +24,7 @@ const copy = {
maintenance: "Wartungsmodus", maintenance: "Wartungsmodus",
uiKit: "UI Kit", uiKit: "UI Kit",
media: "Media", media: "Media",
siteSettings: "SEO",
portfolio: "Portfolio", portfolio: "Portfolio",
logout: "Ausloggen", logout: "Ausloggen",
backToSite: "Zur Website", 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", overview: "Uebersicht",
uiKit: "UI Kit", uiKit: "UI Kit",
media: "Media", media: "Media",
siteSettings: "SEO",
portfolio: "Portfolio", portfolio: "Portfolio",
logout: "Ausloggen", logout: "Ausloggen",
backToSite: "Zur Website", backToSite: "Zur Website",
+61
View File
@@ -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>
);
}
+9 -3
View File
@@ -24,6 +24,9 @@ type MediaFieldPickerProps = {
options: MediaOption[]; options: MediaOption[];
inputName: string; inputName: string;
fileFieldName: string; fileFieldName: string;
fileLabel?: string;
externalLabel?: string;
libraryLabel?: string;
accept?: string; accept?: string;
}; };
@@ -36,6 +39,9 @@ export function MediaFieldPicker({
options, options,
inputName, inputName,
fileFieldName, fileFieldName,
fileLabel,
externalLabel,
libraryLabel,
accept, accept,
}: MediaFieldPickerProps) { }: MediaFieldPickerProps) {
const filteredOptions = options.filter((option) => option.kind === value.kind); const filteredOptions = options.filter((option) => option.kind === value.kind);
@@ -100,14 +106,14 @@ export function MediaFieldPicker({
{value.mode === "upload" ? ( {value.mode === "upload" ? (
<div className="space-y-2"> <div className="space-y-2">
<Label>{fileFieldName}</Label> <Label>{fileLabel ?? fileFieldName}</Label>
<Input name={fileFieldName} type="file" accept={accept} /> <Input name={fileFieldName} type="file" accept={accept} />
</div> </div>
) : null} ) : null}
{value.mode === "external" ? ( {value.mode === "external" ? (
<div className="space-y-2"> <div className="space-y-2">
<Label>External URL</Label> <Label>{externalLabel ?? "External URL"}</Label>
<Input <Input
value={value.url} value={value.url}
onChange={(event) => onChange({ ...value, url: event.target.value })} onChange={(event) => onChange({ ...value, url: event.target.value })}
@@ -118,7 +124,7 @@ export function MediaFieldPicker({
{value.mode === "library" ? ( {value.mode === "library" ? (
<div className="space-y-2"> <div className="space-y-2">
<Label>Media Library</Label> <Label>{libraryLabel ?? "Media Library"}</Label>
<select <select
value={value.assetId} value={value.assetId}
onChange={(event) => onChange({ ...value, assetId: event.target.value })} onChange={(event) => onChange({ ...value, assetId: event.target.value })}
+401
View File
@@ -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>
);
}
+116 -7
View File
@@ -1,14 +1,45 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "./prisma";
export const MAINTENANCE_MODE_KEY = "maintenance_mode"; 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> { export async function getMaintenanceMode(): Promise<boolean> {
const config = await prisma.appConfig.findUnique({ try {
where: { key: MAINTENANCE_MODE_KEY }, const config = await prisma.appConfig.findUnique({
select: { value: true }, where: { key: MAINTENANCE_MODE_KEY },
}); select: { value: true },
});
return config?.value === "true"; return config?.value === "true";
} catch {
return false;
}
} }
export async function setMaintenanceMode(enabled: boolean): Promise<void> { 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
View File
@@ -1,4 +1,4 @@
import { routing } from "@/i18n/routing"; import { routing } from "../i18n/routing";
export type AppLocale = (typeof routing.locales)[number]; export type AppLocale = (typeof routing.locales)[number];
+2
View File
@@ -6,6 +6,8 @@ export const MEDIA_UPLOAD_ROOT = path.join(process.cwd(), "public", "uploads", "
export const MAX_MEDIA_FILE_SIZE = 5 * 1024 * 1024; export const MAX_MEDIA_FILE_SIZE = 5 * 1024 * 1024;
const MIME_EXTENSIONS: Record<string, string> = { const MIME_EXTENSIONS: Record<string, string> = {
"image/x-icon": ".ico",
"image/vnd.microsoft.icon": ".ico",
"image/jpeg": ".jpg", "image/jpeg": ".jpg",
"image/png": ".png", "image/png": ".png",
"image/webp": ".webp", "image/webp": ".webp",
+131 -9
View File
@@ -1,7 +1,17 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { routing } from "@/i18n/routing"; import { routing } from "../i18n/routing";
import { AppLocale, getLocalizedPath, resolveLocale } from "@/lib/locale"; 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 { function getSiteUrl(): URL {
return new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de"); 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 = { type LocalizedMetadataInput = {
locale: string; locale: string;
pathname: string; pathname: string;
title: string; title: string;
description: string; description?: string;
applyTitleTemplate?: boolean;
}; };
export function buildLocalizedMetadata({ export async function buildLocalizedMetadata({
locale, locale,
pathname, pathname,
title, title,
description, description,
}: LocalizedMetadataInput): Metadata { applyTitleTemplate,
}: LocalizedMetadataInput): Promise<Metadata> {
const localeKey = resolveLocale(locale); const localeKey = resolveLocale(locale);
const [settings, bindings] = await Promise.all([getSiteSettings(), getSiteSettingsMediaBindings()]);
return { return buildLocalizedMetadataFromConfig({
settings,
bindings,
locale: localeKey,
pathname,
title, title,
description, 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: resolvedTitle,
description: resolvedDescription,
alternates: buildLocaleAlternates(pathname), alternates: buildLocaleAlternates(pathname),
openGraph: { openGraph: {
title, title: resolvedTitle,
description, description: resolvedDescription,
url: toAbsoluteUrl(getLocalizedPath(localeKey, pathname)), url: toAbsoluteUrl(getLocalizedPath(localeKey, pathname)),
siteName: "moh-sass", siteName: localeSettings.siteName,
locale: localeKey, locale: localeKey,
type: "website", type: "website",
images: openGraphImages,
},
twitter: {
card: openGraphImages ? "summary_large_image" : "summary",
title: resolvedTitle,
description: resolvedDescription,
images: openGraphImages?.map((image) => image.url),
}, },
}; };
} }
+9 -1
View File
@@ -1,5 +1,6 @@
import { import {
FolderKanban, FolderKanban,
Globe2,
ImageIcon, ImageIcon,
LayoutDashboard, LayoutDashboard,
PlusSquare, PlusSquare,
@@ -15,6 +16,7 @@ type RootNavigationCopy = {
uiKit: string; uiKit: string;
portfolio: string; portfolio: string;
media: string; media: string;
siteSettings: string;
}; };
export type RootNavItem = { export type RootNavItem = {
@@ -27,7 +29,7 @@ export type RootNavItem = {
export function getRootNavigation( export function getRootNavigation(
copy: RootNavigationCopy, copy: RootNavigationCopy,
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media", active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings",
portfolioChild?: "overview" | "projects" | "new-project" | "categories", portfolioChild?: "overview" | "projects" | "new-project" | "categories",
): RootNavItem[] { ): RootNavItem[] {
return [ return [
@@ -55,6 +57,12 @@ export function getRootNavigation(
icon: ImageIcon, icon: ImageIcon,
active: active === "media", active: active === "media",
}, },
{
label: copy.siteSettings,
href: "/root/site-settings",
icon: Globe2,
active: active === "site-settings",
},
{ {
label: copy.portfolio, label: copy.portfolio,
href: "/root/portfolio", href: "/root/portfolio",
+152
View File
@@ -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
View File
@@ -43,6 +43,9 @@ export default async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
const isRootBaseRoute = pathname === "/root" || pathname.startsWith("/root/"); const isRootBaseRoute = pathname === "/root" || pathname.startsWith("/root/");
const isRootRoute = isRootBaseRoute; const isRootRoute = isRootBaseRoute;
const rootRobotsHeaders = {
"X-Robots-Tag": "noindex, nofollow, noarchive, nosnippet, noimageindex",
};
if (pathname === "/de" || pathname.startsWith("/de/")) { if (pathname === "/de" || pathname.startsWith("/de/")) {
const redirectUrl = request.nextUrl.clone(); const redirectUrl = request.nextUrl.clone();
@@ -56,12 +59,15 @@ export default async function middleware(request: NextRequest) {
status: 401, status: 401,
headers: { headers: {
"WWW-Authenticate": 'Basic realm="Root Area", charset="UTF-8"', "WWW-Authenticate": 'Basic realm="Root Area", charset="UTF-8"',
...rootRobotsHeaders,
}, },
}); });
} }
if (isRootBaseRoute) { if (isRootBaseRoute) {
return NextResponse.next(); const response = NextResponse.next();
response.headers.set("X-Robots-Tag", rootRobotsHeaders["X-Robots-Tag"]);
return response;
} }
return intlMiddleware(request); return intlMiddleware(request);
+55
View File
@@ -16,6 +16,61 @@ async function main() {
create: { key: "siteName", value: "moh-sass" }, 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({ const brandCategory = await prisma.category.upsert({
where: { slug: "branding" }, where: { slug: "branding" },
update: { update: {
+53
View File
@@ -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"));
});
});
+85
View File
@@ -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("اسم الموقع");
});
});