From f259dfc6bd2bf2950f660506cd83fdaece8f3612 Mon Sep 17 00:00:00 2001 From: MOH Date: Sat, 7 Mar 2026 18:48:01 +0100 Subject: [PATCH] Add SEO settings and root admin save flows --- app/[locale]/(site)/about/page.tsx | 2 +- app/[locale]/(site)/contact/page.tsx | 2 +- app/[locale]/(site)/page.tsx | 11 +- app/[locale]/(site)/portfolio/[slug]/page.tsx | 4 +- app/[locale]/(site)/portfolio/page.tsx | 2 +- app/[locale]/(site)/products/[slug]/page.tsx | 4 +- app/[locale]/(site)/products/page.tsx | 2 +- app/[locale]/(site)/success/page.tsx | 2 +- app/[locale]/coming-soon/page.tsx | 2 +- app/layout.tsx | 9 +- app/robots.ts | 12 + app/root/layout.tsx | 25 ++ app/root/maintenance/page.tsx | 45 +- app/root/media/page.tsx | 1 + app/root/portfolio/categories/page.tsx | 1 + app/root/portfolio/page.tsx | 1 + app/root/portfolio/projects/[id]/page.tsx | 1 + app/root/portfolio/projects/new/page.tsx | 1 + app/root/portfolio/projects/page.tsx | 1 + app/root/site-settings/actions.ts | 227 ++++++++++ app/root/site-settings/page.tsx | 96 +++++ app/root/ui-kit/page.tsx | 1 + components/root/form-save-button.tsx | 61 +++ components/root/media-field-picker.tsx | 12 +- components/root/site-settings-form.tsx | 401 ++++++++++++++++++ lib/app-config.ts | 123 +++++- lib/locale.ts | 2 +- lib/media-storage.ts | 2 + lib/metadata.ts | 140 +++++- lib/root-navigation.ts | 10 +- lib/site-settings.ts | 152 +++++++ middleware.ts | 8 +- prisma/seed.js | 55 +++ tests/app-config.test.ts | 53 +++ tests/metadata.test.ts | 85 ++++ 35 files changed, 1500 insertions(+), 56 deletions(-) create mode 100644 app/robots.ts create mode 100644 app/root/layout.tsx create mode 100644 app/root/site-settings/actions.ts create mode 100644 app/root/site-settings/page.tsx create mode 100644 components/root/form-save-button.tsx create mode 100644 components/root/site-settings-form.tsx create mode 100644 lib/site-settings.ts create mode 100644 tests/app-config.test.ts create mode 100644 tests/metadata.test.ts diff --git a/app/[locale]/(site)/about/page.tsx b/app/[locale]/(site)/about/page.tsx index 540b191..53b0f21 100644 --- a/app/[locale]/(site)/about/page.tsx +++ b/app/[locale]/(site)/about/page.tsx @@ -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"), diff --git a/app/[locale]/(site)/contact/page.tsx b/app/[locale]/(site)/contact/page.tsx index a24261d..af3a176 100644 --- a/app/[locale]/(site)/contact/page.tsx +++ b/app/[locale]/(site)/contact/page.tsx @@ -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"), diff --git a/app/[locale]/(site)/page.tsx b/app/[locale]/(site)/page.tsx index f3463d2..467bcab 100644 --- a/app/[locale]/(site)/page.tsx +++ b/app/[locale]/(site)/page.tsx @@ -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 { 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, }); } diff --git a/app/[locale]/(site)/portfolio/[slug]/page.tsx b/app/[locale]/(site)/portfolio/[slug]/page.tsx index 1e5403e..70ee9cd 100644 --- a/app/[locale]/(site)/portfolio/[slug]/page.tsx +++ b/app/[locale]/(site)/portfolio/[slug]/page.tsx @@ -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), diff --git a/app/[locale]/(site)/portfolio/page.tsx b/app/[locale]/(site)/portfolio/page.tsx index 82646b3..d8ecd83 100644 --- a/app/[locale]/(site)/portfolio/page.tsx +++ b/app/[locale]/(site)/portfolio/page.tsx @@ -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"), diff --git a/app/[locale]/(site)/products/[slug]/page.tsx b/app/[locale]/(site)/products/[slug]/page.tsx index 5b2d965..b9cbf4c 100644 --- a/app/[locale]/(site)/products/[slug]/page.tsx +++ b/app/[locale]/(site)/products/[slug]/page.tsx @@ -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), diff --git a/app/[locale]/(site)/products/page.tsx b/app/[locale]/(site)/products/page.tsx index a079e47..639e680 100644 --- a/app/[locale]/(site)/products/page.tsx +++ b/app/[locale]/(site)/products/page.tsx @@ -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"), diff --git a/app/[locale]/(site)/success/page.tsx b/app/[locale]/(site)/success/page.tsx index 42a273e..44fa5c0 100644 --- a/app/[locale]/(site)/success/page.tsx +++ b/app/[locale]/(site)/success/page.tsx @@ -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"), diff --git a/app/[locale]/coming-soon/page.tsx b/app/[locale]/coming-soon/page.tsx index d167719..f967a8e 100644 --- a/app/[locale]/coming-soon/page.tsx +++ b/app/[locale]/coming-soon/page.tsx @@ -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`, diff --git a/app/layout.tsx b/app/layout.tsx index aaa8e7b..3625f22 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 { + return buildAppMetadata(); +} export default async function RootLayout({ children, diff --git a/app/robots.ts b/app/robots.ts new file mode 100644 index 0000000..d634558 --- /dev/null +++ b/app/robots.ts @@ -0,0 +1,12 @@ +import type { MetadataRoute } from "next"; + +export default function robots(): MetadataRoute.Robots { + return { + rules: [ + { + userAgent: "*", + disallow: ["/root"], + }, + ], + }; +} diff --git a/app/root/layout.tsx b/app/root/layout.tsx new file mode 100644 index 0000000..0f69049 --- /dev/null +++ b/app/root/layout.tsx @@ -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; +} diff --git a/app/root/maintenance/page.tsx b/app/root/maintenance/page.tsx index 36af63d..8948f49 100644 --- a/app/root/maintenance/page.tsx +++ b/app/root/maintenance/page.tsx @@ -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={ - - {maintenanceEnabled ? copy.maintenanceOn : copy.maintenanceOff} - + <> + + {maintenanceEnabled ? copy.maintenanceOn : copy.maintenanceOff} + + + } >

{copy.maintenanceText}

-
- - + +
+ + +

{copy.selectHint}

+
+
+ + {maintenanceEnabled ? copy.maintenanceOn : copy.maintenanceOff} +
diff --git a/app/root/media/page.tsx b/app/root/media/page.tsx index 331a17e..f4332a4 100644 --- a/app/root/media/page.tsx +++ b/app/root/media/page.tsx @@ -25,6 +25,7 @@ const copy = { maintenance: "Wartungsmodus", uiKit: "UI Kit", media: "Media", + siteSettings: "SEO", portfolio: "Portfolio", logout: "Ausloggen", backToSite: "Zur Website", diff --git a/app/root/portfolio/categories/page.tsx b/app/root/portfolio/categories/page.tsx index 0e04ca2..979686f 100644 --- a/app/root/portfolio/categories/page.tsx +++ b/app/root/portfolio/categories/page.tsx @@ -30,6 +30,7 @@ const copy = { maintenance: "Wartungsmodus", uiKit: "UI Kit", media: "Media", + siteSettings: "SEO", portfolio: "Portfolio", logout: "Ausloggen", backToSite: "Zur Website", diff --git a/app/root/portfolio/page.tsx b/app/root/portfolio/page.tsx index 2e292a2..b6af770 100644 --- a/app/root/portfolio/page.tsx +++ b/app/root/portfolio/page.tsx @@ -22,6 +22,7 @@ const copy = { overview: "Uebersicht", maintenance: "Wartungsmodus", uiKit: "UI Kit", + siteSettings: "SEO", portfolio: "Portfolio", logout: "Ausloggen", backToSite: "Zur Website", diff --git a/app/root/portfolio/projects/[id]/page.tsx b/app/root/portfolio/projects/[id]/page.tsx index 80e3d0c..799c876 100644 --- a/app/root/portfolio/projects/[id]/page.tsx +++ b/app/root/portfolio/projects/[id]/page.tsx @@ -25,6 +25,7 @@ const copy = { maintenance: "Wartungsmodus", uiKit: "UI Kit", media: "Media", + siteSettings: "SEO", portfolio: "Portfolio", logout: "Ausloggen", backToSite: "Zur Website", diff --git a/app/root/portfolio/projects/new/page.tsx b/app/root/portfolio/projects/new/page.tsx index 45e3038..5c071a4 100644 --- a/app/root/portfolio/projects/new/page.tsx +++ b/app/root/portfolio/projects/new/page.tsx @@ -19,6 +19,7 @@ const copy = { maintenance: "Wartungsmodus", uiKit: "UI Kit", media: "Media", + siteSettings: "SEO", portfolio: "Portfolio", logout: "Ausloggen", backToSite: "Zur Website", diff --git a/app/root/portfolio/projects/page.tsx b/app/root/portfolio/projects/page.tsx index e1fdcab..42e02ea 100644 --- a/app/root/portfolio/projects/page.tsx +++ b/app/root/portfolio/projects/page.tsx @@ -24,6 +24,7 @@ const copy = { maintenance: "Wartungsmodus", uiKit: "UI Kit", media: "Media", + siteSettings: "SEO", portfolio: "Portfolio", logout: "Ausloggen", backToSite: "Zur Website", diff --git a/app/root/site-settings/actions.ts b/app/root/site-settings/actions.ts new file mode 100644 index 0000000..31b2056 --- /dev/null +++ b/app/root/site-settings/actions.ts @@ -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)); + } +} diff --git a/app/root/site-settings/page.tsx b/app/root/site-settings/page.tsx new file mode 100644 index 0000000..2e8758c --- /dev/null +++ b/app/root/site-settings/page.tsx @@ -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 ( + } + > +
+ {searchParams?.success ? ( + +

+ {searchParams.success} +

+
+ ) : null} + + {searchParams?.error ? ( + +

+ {searchParams.error} +

+
+ ) : null} + + + + +
+
+ ); +} diff --git a/app/root/ui-kit/page.tsx b/app/root/ui-kit/page.tsx index d8cd51c..8e69823 100644 --- a/app/root/ui-kit/page.tsx +++ b/app/root/ui-kit/page.tsx @@ -14,6 +14,7 @@ const copy = { overview: "Uebersicht", uiKit: "UI Kit", media: "Media", + siteSettings: "SEO", portfolio: "Portfolio", logout: "Ausloggen", backToSite: "Zur Website", diff --git a/components/root/form-save-button.tsx b/components/root/form-save-button.tsx new file mode 100644 index 0000000..ca514ce --- /dev/null +++ b/components/root/form-save-button.tsx @@ -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 ( + + ); +} diff --git a/components/root/media-field-picker.tsx b/components/root/media-field-picker.tsx index 220fd82..b65ebf8 100644 --- a/components/root/media-field-picker.tsx +++ b/components/root/media-field-picker.tsx @@ -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" ? (
- +
) : null} {value.mode === "external" ? (
- + onChange({ ...value, url: event.target.value })} @@ -118,7 +124,7 @@ export function MediaFieldPicker({ {value.mode === "library" ? (
- + + setSettings((current) => ({ + ...current, + locales: { + ...current.locales, + [locale.key]: { + ...current.locales[locale.key], + siteName: event.target.value, + }, + }, + })) + } + /> +
+ +
+ + + setSettings((current) => ({ + ...current, + locales: { + ...current.locales, + [locale.key]: { + ...current.locales[locale.key], + titleTemplate: event.target.value, + }, + }, + })) + } + placeholder="{pageTitle} | {siteName}" + /> +
+ +
+ + + setSettings((current) => ({ + ...current, + locales: { + ...current.locales, + [locale.key]: { + ...current.locales[locale.key], + siteDescription: event.target.value, + }, + }, + })) + } + /> +
+ +
+ + + setSettings((current) => ({ + ...current, + locales: { + ...current.locales, + [locale.key]: { + ...current.locales[locale.key], + subhead: event.target.value, + }, + }, + })) + } + /> +
+
+ ))} + + + + + + Preview Images + + Favicon erscheint im Browser. Das Default OG Bild wird fuer Social Sharing genutzt, wenn eine Seite kein eigenes Bild liefert. + + + + + + + + + + +
+ + + Preview + Suchmaschine und Social Sharing Vorschau. + + +
+ {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 ( +
+

{locale.label}

+

+ Example page title: {locale.sampleTitle} +

+

{previewTitle}

+

+ {localeSettings.siteDescription || "No description"} +

+

+ {localeSettings.subhead || "No subhead"} +

+
+ ); + })} +
+ +
+ + Search Preview +
+
+
+ {faviconPreviewUrl ? ( + Favicon + ) : ( +
+ +
+ )} +

+ {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}`} +

+
+

+ {settings.locales.de.siteDescription || "No description"} +

+

+ {settings.locales.de.subhead || "No subhead"} +

+
+ +
+ + Social Preview +
+
+ {defaultOgImagePreviewUrl ? ( + Default OG + ) : ( +
+ No OG image selected +
+ )} +
+

+ {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}`} +

+

+ {settings.locales.en.siteDescription || "No description"} +

+
+
+
+
+
+ + + ); +} diff --git a/lib/app-config.ts b/lib/app-config.ts index 8eee6f7..896bc1c 100644 --- a/lib/app-config.ts +++ b/lib/app-config.ts @@ -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 { - const config = await prisma.appConfig.findUnique({ - where: { key: MAINTENANCE_MODE_KEY }, - select: { value: true }, - }); + try { + const config = await prisma.appConfig.findUnique({ + 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 { @@ -23,3 +54,81 @@ export async function setMaintenanceMode(enabled: boolean): Promise { }, }); } + +export async function getSiteSettings(): Promise { + 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 { + 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 { + 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( + (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(); + } +} diff --git a/lib/locale.ts b/lib/locale.ts index f87240f..294e419 100644 --- a/lib/locale.ts +++ b/lib/locale.ts @@ -1,4 +1,4 @@ -import { routing } from "@/i18n/routing"; +import { routing } from "../i18n/routing"; export type AppLocale = (typeof routing.locales)[number]; diff --git a/lib/media-storage.ts b/lib/media-storage.ts index 4315ca8..e7d87ed 100644 --- a/lib/media-storage.ts +++ b/lib/media-storage.ts @@ -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 = { + "image/x-icon": ".ico", + "image/vnd.microsoft.icon": ".ico", "image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp", diff --git a/lib/metadata.ts b/lib/metadata.ts index 11c4a5c..0bd57d2 100644 --- a/lib/metadata.ts +++ b/lib/metadata.ts @@ -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 { + 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 { const localeKey = resolveLocale(locale); + const [settings, bindings] = await Promise.all([getSiteSettings(), getSiteSettingsMediaBindings()]); - return { + 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: 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), }, }; } diff --git a/lib/root-navigation.ts b/lib/root-navigation.ts index 9262e78..0373037 100644 --- a/lib/root-navigation.ts +++ b/lib/root-navigation.ts @@ -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", diff --git a/lib/site-settings.ts b/lib/site-settings.ts new file mode 100644 index 0000000..47c2837 --- /dev/null +++ b/lib/site-settings.ts @@ -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; +}; + +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) : {}; + + 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; + const locales = parsed.locales && typeof parsed.locales === "object" + ? (parsed.locales as Record) + : {}; + + 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) : {}), + }, + 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) : {}), + }, + 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) : {}), + }, + defaults.locales.de.siteName, + defaults.locales.de.siteDescription, + ), + }, + }; + + return siteSettings; + } catch { + return defaults; + } +} diff --git a/middleware.ts b/middleware.ts index 0092bf1..8385be6 100644 --- a/middleware.ts +++ b/middleware.ts @@ -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); diff --git a/prisma/seed.js b/prisma/seed.js index 0a15a55..ddf88cd 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -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: { diff --git a/tests/app-config.test.ts b/tests/app-config.test.ts new file mode 100644 index 0000000..d7722d3 --- /dev/null +++ b/tests/app-config.test.ts @@ -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")); + }); +}); diff --git a/tests/metadata.test.ts b/tests/metadata.test.ts new file mode 100644 index 0000000..1122c04 --- /dev/null +++ b/tests/metadata.test.ts @@ -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("اسم الموقع"); + }); +});