From 5d3f77962a0580b0b51649fb6bf0e90b4e017627 Mon Sep 17 00:00:00 2001 From: MOH Date: Sun, 15 Mar 2026 04:06:33 +0100 Subject: [PATCH] Add admin site settings sections and locale defaults --- app/_admin/site-settings/actions.ts | 112 ++- app/_admin/site-settings/brand/page.tsx | 73 ++ .../site-settings/localization/page.tsx | 67 ++ app/_admin/site-settings/page.tsx | 66 +- .../site-settings/brand/page.tsx | 1 + .../site-settings/localization/page.tsx | 1 + app/api/site/default-locale/route.ts | 20 + app/layout.tsx | 10 +- app/root/site-settings/brand/page.tsx | 1 + app/root/site-settings/localization/page.tsx | 1 + components/admin/admin-dashboard-shell.tsx | 14 +- components/admin/site-settings-form.tsx | 778 ++++++++++-------- lib/admin-navigation.ts | 24 +- lib/locale.ts | 10 +- lib/metadata.ts | 18 +- lib/site-icons.tsx | 4 +- lib/site-settings.ts | 33 + lib/site-theme.ts | 134 +++ middleware.ts | 51 +- tests/app-config.test.ts | 23 + tests/metadata.test.ts | 8 +- 21 files changed, 993 insertions(+), 456 deletions(-) create mode 100644 app/_admin/site-settings/brand/page.tsx create mode 100644 app/_admin/site-settings/localization/page.tsx create mode 100644 app/admin-internal/site-settings/brand/page.tsx create mode 100644 app/admin-internal/site-settings/localization/page.tsx create mode 100644 app/api/site/default-locale/route.ts create mode 100644 app/root/site-settings/brand/page.tsx create mode 100644 app/root/site-settings/localization/page.tsx create mode 100644 lib/site-theme.ts diff --git a/app/_admin/site-settings/actions.ts b/app/_admin/site-settings/actions.ts index 8e7c3eb..43ad089 100644 --- a/app/_admin/site-settings/actions.ts +++ b/app/_admin/site-settings/actions.ts @@ -12,10 +12,16 @@ import { SITE_SETTINGS_FAVICON_FIELD_KEY, SITE_SETTINGS_LOGO_DARK_FIELD_KEY, SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY, + getSiteSettings, updateSiteSettings, } from "@/lib/app-config"; import { getAdminAppPath, toInternalAdminPath } from "@/lib/admin-routing"; -import { PAGE_TITLE_TOKEN, type SiteSettings } from "@/lib/site-settings"; +import { + PAGE_TITLE_TOKEN, + normalizeSiteDefaultLocale, + normalizeSitePrimaryColor, + type SiteSettings, +} from "@/lib/site-settings"; import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; import { replaceEntityMediaUsages } from "@/lib/media"; import { resolveMediaSelection } from "@/lib/media-service"; @@ -91,12 +97,17 @@ async function revalidateSiteSettingsPages() { } export async function saveSiteSettingsAction(formData: FormData) { + return saveSiteBrandSettingsAction(formData); +} + +export async function saveSiteBrandSettingsAction(formData: FormData) { await ensureAdmin(); const createdMediaAssetIds: string[] = []; const uploadedPaths: string[] = []; try { + const currentSettings = await getSiteSettings(); const siteLogoLightMedia = parseJsonObject(formData.get("siteLogoLightMedia"), "siteLogoLightMedia"); const siteLogoDarkMedia = parseJsonObject(formData.get("siteLogoDarkMedia"), "siteLogoDarkMedia"); const faviconMedia = parseJsonObject(formData.get("faviconMedia"), "faviconMedia"); @@ -106,41 +117,12 @@ export async function saveSiteSettingsAction(formData: FormData) { ); 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(), - }, + ...currentSettings, + brand: { + primaryColor: normalizeSitePrimaryColor(formData.get("primaryColor")), }, }; - 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 siteLogoLightSelection = siteLogoLightMedia ? await resolveMediaSelection({ media: mediaFieldInputSchema.parse(siteLogoLightMedia), @@ -278,7 +260,7 @@ export async function saveSiteSettingsAction(formData: FormData) { }); await revalidateSiteSettingsPages(); - redirect(withMessage(getAdminAppPath("/site-settings"), "success", "Einstellungen gespeichert.")); + redirect(withMessage(getAdminAppPath("/site-settings/brand"), "success", "Einstellungen gespeichert.")); } catch (error) { if (isRedirectError(error)) { throw error; @@ -291,6 +273,66 @@ export async function saveSiteSettingsAction(formData: FormData) { ? error.message : "Einstellungen konnten nicht gespeichert werden."; - redirect(withMessage(getAdminAppPath("/site-settings"), "error", message)); + redirect(withMessage(getAdminAppPath("/site-settings/brand"), "error", message)); + } +} + +export async function saveSiteLocalizationSettingsAction(formData: FormData) { + await ensureAdmin(); + + try { + const currentSettings = await getSiteSettings(); + const parsedSettings: SiteSettings = { + ...currentSettings, + defaultLocale: normalizeSiteDefaultLocale(formData.get("defaultLocale")), + 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.`); + } + } + + await updateSiteSettings(parsedSettings); + await revalidateSiteSettingsPages(); + redirect(withMessage(getAdminAppPath("/site-settings/localization"), "success", "Einstellungen gespeichert.")); + } catch (error) { + if (isRedirectError(error)) { + throw error; + } + + const message = + error instanceof Error + ? error.message + : "Einstellungen konnten nicht gespeichert werden."; + + redirect(withMessage(getAdminAppPath("/site-settings/localization"), "error", message)); } } diff --git a/app/_admin/site-settings/brand/page.tsx b/app/_admin/site-settings/brand/page.tsx new file mode 100644 index 0000000..c951fcf --- /dev/null +++ b/app/_admin/site-settings/brand/page.tsx @@ -0,0 +1,73 @@ +import { MediaKind } from "@prisma/client"; +import { redirect } from "next/navigation"; + +import { MotionFade } from "@/components/motion-fade"; +import { SiteSettingsForm } from "@/components/admin/site-settings-form"; +import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell"; +import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; +import { getAdminAppPath } from "@/lib/admin-routing"; +import { + getSiteSettings, + getSiteSettingsMediaBindings, +} from "@/lib/app-config"; +import { getMediaOptions } from "@/lib/media"; + +import { saveSiteBrandSettingsAction } from "../actions"; + +export const dynamic = "force-dynamic"; + +const copy = { + title: "Brand", + subtitle: "Primaerfarbe, Logos, Favicon und OG Assets verwalten.", + overview: "Uebersicht", + maintenance: "Wartungsmodus", + uiKit: "UI Kit", + media: "Media", + siteSettings: "Settings", + brandSettings: "Brand", + localizationSettings: "Localization", + smtp: "SMTP", + portfolio: "Portfolio", + logout: "Ausloggen", + backToSite: "Zur Website", +}; + +export default async function AdminSiteBrandSettingsPage() { + if (!(await isAdminAuthenticated())) { + redirect(getAdminAppPath("/")); + } + + async function logoutAction() { + "use server"; + + await clearAdminSessionCookie(); + redirect(getAdminAppPath("/")); + } + + const [siteSettings, mediaBindings, mediaOptions] = await Promise.all([ + getSiteSettings(), + getSiteSettingsMediaBindings(), + getMediaOptions({ kind: MediaKind.IMAGE }), + ]); + + return ( + + + + + + ); +} diff --git a/app/_admin/site-settings/localization/page.tsx b/app/_admin/site-settings/localization/page.tsx new file mode 100644 index 0000000..ed7a144 --- /dev/null +++ b/app/_admin/site-settings/localization/page.tsx @@ -0,0 +1,67 @@ +import { redirect } from "next/navigation"; + +import { MotionFade } from "@/components/motion-fade"; +import { SiteSettingsForm } from "@/components/admin/site-settings-form"; +import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell"; +import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; +import { getAdminAppPath } from "@/lib/admin-routing"; +import { + getDefaultSiteSettingsMediaBindings, + getSiteSettings, +} from "@/lib/app-config"; + +import { saveSiteLocalizationSettingsAction } from "../actions"; + +export const dynamic = "force-dynamic"; + +const copy = { + title: "Localization", + subtitle: "Seitennamen, Titelvorlagen und Standardsprache verwalten.", + overview: "Uebersicht", + maintenance: "Wartungsmodus", + uiKit: "UI Kit", + media: "Media", + siteSettings: "Settings", + brandSettings: "Brand", + localizationSettings: "Localization", + smtp: "SMTP", + portfolio: "Portfolio", + logout: "Ausloggen", + backToSite: "Zur Website", +}; + +export default async function AdminSiteLocalizationSettingsPage() { + if (!(await isAdminAuthenticated())) { + redirect(getAdminAppPath("/")); + } + + async function logoutAction() { + "use server"; + + await clearAdminSessionCookie(); + redirect(getAdminAppPath("/")); + } + + const siteSettings = await getSiteSettings(); + + return ( + + + + + + ); +} diff --git a/app/_admin/site-settings/page.tsx b/app/_admin/site-settings/page.tsx index bd3a15b..ef8b144 100644 --- a/app/_admin/site-settings/page.tsx +++ b/app/_admin/site-settings/page.tsx @@ -1,71 +1,9 @@ -import { MediaKind } from "@prisma/client"; import { redirect } from "next/navigation"; -import { MotionFade } from "@/components/motion-fade"; -import { SiteSettingsForm } from "@/components/admin/site-settings-form"; -import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell"; -import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; import { getAdminAppPath } from "@/lib/admin-routing"; -import { - getSiteSettings, - getSiteSettingsMediaBindings, -} from "@/lib/app-config"; -import { getMediaOptions } from "@/lib/media"; - -import { saveSiteSettingsAction } from "./actions"; export const dynamic = "force-dynamic"; -const copy = { - title: "Settings", - subtitle: "Globale Titel, Copy und Brand Assets verwalten.", - overview: "Uebersicht", - maintenance: "Wartungsmodus", - uiKit: "UI Kit", - media: "Media", - siteSettings: "Settings", - smtp: "SMTP", - portfolio: "Portfolio", - logout: "Ausloggen", - backToSite: "Zur Website", -}; - -export default async function AdminSiteSettingsPage() { - if (!(await isAdminAuthenticated())) { - redirect(getAdminAppPath("/")); - } - - async function logoutAction() { - "use server"; - - await clearAdminSessionCookie(); - redirect(getAdminAppPath("/")); - } - - const [siteSettings, mediaBindings, mediaOptions] = await Promise.all([ - getSiteSettings(), - getSiteSettingsMediaBindings(), - getMediaOptions({ kind: MediaKind.IMAGE }), - ]); - - return ( - -
- - - -
-
- ); +export default function AdminSiteSettingsPage() { + redirect(getAdminAppPath("/site-settings/brand")); } diff --git a/app/admin-internal/site-settings/brand/page.tsx b/app/admin-internal/site-settings/brand/page.tsx new file mode 100644 index 0000000..24e48bd --- /dev/null +++ b/app/admin-internal/site-settings/brand/page.tsx @@ -0,0 +1 @@ +export { default } from "../../../_admin/site-settings/brand/page"; diff --git a/app/admin-internal/site-settings/localization/page.tsx b/app/admin-internal/site-settings/localization/page.tsx new file mode 100644 index 0000000..357ac53 --- /dev/null +++ b/app/admin-internal/site-settings/localization/page.tsx @@ -0,0 +1 @@ +export { default } from "../../../_admin/site-settings/localization/page"; diff --git a/app/api/site/default-locale/route.ts b/app/api/site/default-locale/route.ts new file mode 100644 index 0000000..ed7e4fe --- /dev/null +++ b/app/api/site/default-locale/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; + +import { getSiteSettings } from "@/lib/app-config"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const settings = await getSiteSettings(); + + return NextResponse.json( + { + defaultLocale: settings.defaultLocale, + }, + { + headers: { + "Cache-Control": "no-store, max-age=0", + }, + }, + ); +} diff --git a/app/layout.tsx b/app/layout.tsx index f88ce41..dfdafeb 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -7,8 +7,10 @@ import { QueryToastBridge } from "@/components/admin/query-toast-bridge"; import { SoundProvider } from "@/components/sound-provider"; import { ThemeProvider } from "@/components/theme-provider"; import { Toaster } from "@/components/ui/toaster"; +import { getSiteSettings } from "@/lib/app-config"; import { buildAppMetadata } from "@/lib/metadata"; import { getDirection } from "@/lib/locale"; +import { buildSiteThemeStyleText } from "@/lib/site-theme"; import "./globals.css"; const museo = localFont({ @@ -82,10 +84,16 @@ export default async function RootLayout({ }>) { noStore(); - const locale = await getLocale().catch(() => "de"); + const [locale, siteSettings] = await Promise.all([ + getLocale().catch(() => "de"), + getSiteSettings(), + ]); return ( + + + diff --git a/app/root/site-settings/brand/page.tsx b/app/root/site-settings/brand/page.tsx new file mode 100644 index 0000000..24e48bd --- /dev/null +++ b/app/root/site-settings/brand/page.tsx @@ -0,0 +1 @@ +export { default } from "../../../_admin/site-settings/brand/page"; diff --git a/app/root/site-settings/localization/page.tsx b/app/root/site-settings/localization/page.tsx new file mode 100644 index 0000000..357ac53 --- /dev/null +++ b/app/root/site-settings/localization/page.tsx @@ -0,0 +1 @@ +export { default } from "../../../_admin/site-settings/localization/page"; diff --git a/components/admin/admin-dashboard-shell.tsx b/components/admin/admin-dashboard-shell.tsx index c0ed0a8..8b08ab0 100644 --- a/components/admin/admin-dashboard-shell.tsx +++ b/components/admin/admin-dashboard-shell.tsx @@ -3,8 +3,10 @@ import { FolderKanban, Globe2, ImageIcon, + Languages, LayoutDashboard, LogOut, + Palette, PlusSquare, ShieldAlert, SwatchBook, @@ -34,6 +36,8 @@ type AdminDashboardCopy = { portfolio: string; media: string; siteSettings: string; + brandSettings?: string; + localizationSettings?: string; marquee?: string; smtp?: string; contactProtection?: string; @@ -46,6 +50,7 @@ type AdminDashboardShellProps = { active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee"; smtpChild?: "settings" | "contact-protection"; portfolioChild?: "overview" | "projects" | "new-project" | "categories"; + siteSettingsChild?: "brand" | "localization"; logoutAction: () => Promise; headerTitle: string; headerDescription: string; @@ -60,6 +65,7 @@ export async function AdminDashboardShell({ active, smtpChild, portfolioChild, + siteSettingsChild, logoutAction, headerTitle, headerDescription, @@ -72,7 +78,7 @@ export async function AdminDashboardShell({ getSiteSettingsMediaBindings(), getMaintenanceMode(), ]); - const sidebarItems = getAdminNavigation(copy, active, smtpChild, portfolioChild); + const sidebarItems = getAdminNavigation(copy, active, smtpChild, portfolioChild, siteSettingsChild); const normalizedSidebarItems = sidebarItems.filter( (item) => item.href !== getAdminAppPath("/maintenance") && @@ -91,7 +97,11 @@ export async function AdminDashboardShell({ : active === "ui-kit" ? SwatchBook : active === "site-settings" - ? Globe2 + ? siteSettingsChild === "localization" + ? Languages + : siteSettingsChild === "brand" + ? Palette + : Globe2 : active === "marquee" ? Type : active === "smtp" diff --git a/components/admin/site-settings-form.tsx b/components/admin/site-settings-form.tsx index bdbca8d..792a60c 100644 --- a/components/admin/site-settings-form.tsx +++ b/components/admin/site-settings-form.tsx @@ -3,7 +3,17 @@ /* eslint-disable @next/next/no-img-element */ import { MediaKind } from "@prisma/client"; -import { Check, FileText, ImageIcon, Search, Type, Upload } from "lucide-react"; +import { + Check, + FileText, + Globe2, + ImageIcon, + Languages, + Palette, + Search, + Type, + Upload, +} from "lucide-react"; import { useEffect, useId, useMemo, useRef, useState } from "react"; import { AppCard } from "@/components/ui/app-card"; @@ -17,18 +27,27 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; import type { AppLocale } from "@/lib/locale"; import type { MediaOption } from "@/lib/media"; -import { cn } from "@/lib/utils"; +import { buildSiteThemeTokens } from "@/lib/site-theme"; import { PAGE_TITLE_TOKEN, SITE_NAME_TOKEN, type SiteSettings, type SiteSettingsMediaBindings, } from "@/lib/site-settings"; +import { cn } from "@/lib/utils"; type SiteSettingsFormProps = { + mode: "brand" | "localization"; action: (formData: FormData) => Promise; initialSettings: SiteSettings; initialBindings: SiteSettingsMediaBindings; @@ -49,24 +68,9 @@ const localeFields: Array<{ 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", - }, + { 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( @@ -105,18 +109,20 @@ function getMediaPreviewUrl( return fallbackUrl ?? ""; } -function buildPreviewTitle( - titleTemplate: string, - sampleTitle: string, - siteName: string, -) { +function buildPreviewTitle(titleTemplate: string, sampleTitle: string, siteName: string) { return titleTemplate.includes(PAGE_TITLE_TOKEN) - ? titleTemplate - .replace(PAGE_TITLE_TOKEN, sampleTitle) - .replaceAll(SITE_NAME_TOKEN, siteName) + ? titleTemplate.replace(PAGE_TITLE_TOKEN, sampleTitle).replaceAll(SITE_NAME_TOKEN, siteName) : `${sampleTitle} | ${siteName}`; } +function BadgeLike({ children }: { children: string }) { + return ( + + {children} + + ); +} + function MediaLibraryModal({ open, onOpenChange, @@ -367,15 +373,140 @@ function SiteSettingsMediaRow({ ); } -function BadgeLike({ children }: { children: string }) { +function LocalizedFieldsSection({ + settings, + setSettings, +}: { + settings: SiteSettings; + setSettings: React.Dispatch>; +}) { return ( - - {children} - +
+
+

Localized Titles And Copy

+

+ Nutze {PAGE_TITLE_TOKEN} und {SITE_NAME_TOKEN} fuer die globalen + Titelvorlagen pro Sprache. +

+
+ +
+ {localeFields.map((locale) => ( + +

{locale.label}

+ +
+ +
+ + + setSettings((current) => ({ + ...current, + locales: { + ...current.locales, + [locale.key]: { + ...current.locales[locale.key], + siteName: event.target.value, + }, + }, + })) + } + placeholder="Site Name" + className="pl-8" + /> +
+
+ +
+ +
+ + + setSettings((current) => ({ + ...current, + locales: { + ...current.locales, + [locale.key]: { + ...current.locales[locale.key], + titleTemplate: event.target.value, + }, + }, + })) + } + placeholder="Title Template" + className="pl-8" + /> +
+
+ +
+ +
+ + + setSettings((current) => ({ + ...current, + locales: { + ...current.locales, + [locale.key]: { + ...current.locales[locale.key], + siteDescription: event.target.value, + }, + }, + })) + } + placeholder="Site Description" + className="pl-8" + /> +
+
+ +
+ +
+ + + setSettings((current) => ({ + ...current, + locales: { + ...current.locales, + [locale.key]: { + ...current.locales[locale.key], + subhead: event.target.value, + }, + }, + })) + } + placeholder="Subhead" + className="pl-8" + /> +
+
+
+ ))} +
+
); } export function SiteSettingsForm({ + mode, action, initialSettings, initialBindings, @@ -383,353 +514,326 @@ export function SiteSettingsForm({ }: SiteSettingsFormProps) { const [settings, setSettings] = useState(initialSettings); const [siteLogoLight, setSiteLogoLight] = useState( - createImageFieldState( - initialBindings.siteLogoLight?.assetId, - initialBindings.siteLogoLight?.url, - "Site Logo Light", - ), + createImageFieldState(initialBindings.siteLogoLight?.assetId, initialBindings.siteLogoLight?.url, "Site Logo Light"), ); const [siteLogoDark, setSiteLogoDark] = useState( - createImageFieldState( - initialBindings.siteLogoDark?.assetId, - initialBindings.siteLogoDark?.url, - "Site Logo Dark", - ), + createImageFieldState(initialBindings.siteLogoDark?.assetId, initialBindings.siteLogoDark?.url, "Site Logo Dark"), ); const [favicon, setFavicon] = useState( - createImageFieldState( - initialBindings.favicon?.assetId, - initialBindings.favicon?.url, - "Favicon", - ), + createImageFieldState(initialBindings.favicon?.assetId, initialBindings.favicon?.url, "Favicon"), ); const [defaultOgImage, setDefaultOgImage] = useState( - createImageFieldState( - initialBindings.defaultOgImage?.assetId, - initialBindings.defaultOgImage?.url, - "Default OG Image", - ), + createImageFieldState(initialBindings.defaultOgImage?.assetId, initialBindings.defaultOgImage?.url, "Default OG Image"), ); - const siteLogoLightPreviewUrl = getMediaPreviewUrl( - siteLogoLight, - mediaOptions, - initialBindings.siteLogoLight?.url, - ); - const siteLogoDarkPreviewUrl = getMediaPreviewUrl( - siteLogoDark, - mediaOptions, - initialBindings.siteLogoDark?.url, - ); + const themeTokens = useMemo(() => buildSiteThemeTokens(settings.brand.primaryColor), [settings.brand.primaryColor]); + const siteLogoLightPreviewUrl = getMediaPreviewUrl(siteLogoLight, mediaOptions, initialBindings.siteLogoLight?.url); + const siteLogoDarkPreviewUrl = getMediaPreviewUrl(siteLogoDark, mediaOptions, initialBindings.siteLogoDark?.url); const faviconPreviewUrl = getMediaPreviewUrl(favicon, mediaOptions, initialBindings.favicon?.url); - const defaultOgImagePreviewUrl = getMediaPreviewUrl( - defaultOgImage, - mediaOptions, - initialBindings.defaultOgImage?.url, - ); + const defaultOgImagePreviewUrl = getMediaPreviewUrl(defaultOgImage, mediaOptions, initialBindings.defaultOgImage?.url); + const routePreview = settings.defaultLocale === "de" ? "/about" : `/${settings.defaultLocale}/about`; return (
-
-
-

Localized Titles And Copy

-

- Nutze - {" "} - {PAGE_TITLE_TOKEN} - {" "} - und - {" "} - {SITE_NAME_TOKEN} - {" "} - fuer die globalen Titelvorlagen pro Sprache. -

-
+ {mode === "brand" ? ( + <> +
+
+

Brand Foundation

+

+ Steuert die Primaerfarbe der Oberflaeche sowie die wichtigsten Brand Assets. +

+
-
- {localeFields.map((locale) => ( - -

{locale.label}

- -
- -
- - - setSettings((current) => ({ - ...current, - locales: { - ...current.locales, - [locale.key]: { - ...current.locales[locale.key], - siteName: event.target.value, + +
+
+ +
+ + setSettings((current) => ({ + ...current, + brand: { + ...current.brand, + primaryColor: event.target.value, }, - }, - })) - } - placeholder="Site Name" - className="pl-8" - /> + })) + } + className="h-11 w-20 rounded-nested p-1" + /> + + setSettings((current) => ({ + ...current, + brand: { + ...current.brand, + primaryColor: event.target.value, + }, + })) + } + placeholder="#dc5a35" + className="font-mono" + /> +
-
-
- -
- - - setSettings((current) => ({ - ...current, - locales: { - ...current.locales, - [locale.key]: { - ...current.locales[locale.key], - titleTemplate: event.target.value, - }, - }, - })) - } - placeholder="Title Template" - className="pl-8" - /> -
-
- -
- -
- - - setSettings((current) => ({ - ...current, - locales: { - ...current.locales, - [locale.key]: { - ...current.locales[locale.key], - siteDescription: event.target.value, - }, - }, - })) - } - placeholder="Site Description" - className="pl-8" - /> -
-
- -
- -
- - - setSettings((current) => ({ - ...current, - locales: { - ...current.locales, - [locale.key]: { - ...current.locales[locale.key], - subhead: event.target.value, - }, - }, - })) - } - placeholder="Subhead" - className="pl-8" - /> +
+
+

Light token

+

brand-primary

+
+
+

Dark token

+

brand-primary

+
- ))} -
-
+
-
-

Brand And Preview Images

+
+

Brand And Preview Images

- + - + - + - -
+ +
+ + ) : ( + <> +
+
+

Visitor Language Routing

+

+ Waehlt die Standardsprache fuer Besucher ohne Sprach-Prefix. Das ist die neue globale Spracheinstellung. +

+
+ + +
+
+ + +
+ +
+
+ + Route preview +
+

+ /about will open {routePreview}. +

+

+ Existing localized URLs stay available. Only prefix-free entry routes change. +

+
+
+
+
+ + + + )}

Preview

- -

Search Result

-
-
- {faviconPreviewUrl ? ( - Favicon - ) : ( -
- + {mode === "brand" ? ( + <> + +

Search Result

+
+
+ {faviconPreviewUrl ? ( + Favicon + ) : ( +
+ +
+ )} +

+ {buildPreviewTitle(settings.locales.de.titleTemplate, "About", settings.locales.de.siteName)} +

- )} -

- {buildPreviewTitle( - settings.locales.de.titleTemplate, - "About", - settings.locales.de.siteName, - )} -

-
-

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

-
- - - -

Brand Assets

-
- -

Light

- {siteLogoLightPreviewUrl ? ( - Site Logo Light - ) : ( -
No logo selected
- )} +

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

+
-
-

Dark

- {siteLogoDarkPreviewUrl ? ( - Site Logo Dark - ) : ( -
No logo selected
- )} -
-
- + +

Brand Assets

+
+ +

Light

+ {siteLogoLightPreviewUrl ? ( + Site Logo Light + ) : ( +
No logo selected
+ )} +
- -

Social Preview

-
- {defaultOgImagePreviewUrl ? ( - Default OG - ) : ( -
- No OG image selected +
+

Dark

+ {siteLogoDarkPreviewUrl ? ( + Site Logo Dark + ) : ( +
No logo selected
+ )} +
- )} -
-

- {buildPreviewTitle( - settings.locales.en.titleTemplate, - "Portfolio", - settings.locales.en.siteName, - )} -

-

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

-
-
-
+ - -

Locale Summary

-
- - - {localeFields.map((locale) => ( - - {locale.label} - -

- {buildPreviewTitle( - settings.locales[locale.key].titleTemplate, - locale.sampleTitle, - settings.locales[locale.key].siteName, - )} -

-

- {settings.locales[locale.key].subhead || "No subhead"} -

-
-
- ))} -
-
-
-
+ +

Social Preview

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

+ {buildPreviewTitle(settings.locales.en.titleTemplate, "Portfolio", settings.locales.en.siteName)} +

+

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

+
+
+ + + ) : ( + <> + +

Visitor Routing

+
+
+ + Default locale: {settings.defaultLocale.toUpperCase()} +
+

+ Prefix-free URLs resolve to {routePreview}. +

+
+
+ + +

Locale Summary

+
+ + + {localeFields.map((locale) => ( + + {locale.label} + +

+ {buildPreviewTitle( + settings.locales[locale.key].titleTemplate, + locale.sampleTitle, + settings.locales[locale.key].siteName, + )} +

+

+ {settings.locales[locale.key].subhead || "No subhead"} +

+
+
+ ))} +
+
+
+
+ + )}
+
- +
); diff --git a/lib/admin-navigation.ts b/lib/admin-navigation.ts index 2029ed0..aae65c6 100644 --- a/lib/admin-navigation.ts +++ b/lib/admin-navigation.ts @@ -2,8 +2,10 @@ import { FolderKanban, Globe2, ImageIcon, + Languages, LayoutDashboard, Mail, + Palette, PlusSquare, ShieldAlert, SwatchBook, @@ -20,6 +22,8 @@ type AdminNavigationCopy = { portfolio: string; media: string; siteSettings: string; + brandSettings?: string; + localizationSettings?: string; marquee?: string; smtp?: string; contactProtection?: string; @@ -39,6 +43,7 @@ export function getAdminNavigation( active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee", smtpChild?: "settings" | "contact-protection", portfolioChild?: "overview" | "projects" | "new-project" | "categories", + siteSettingsChild?: "brand" | "localization", ): AdminNavItem[] { return [ { @@ -67,9 +72,24 @@ export function getAdminNavigation( }, { label: copy.siteSettings, - href: getAdminAppPath("/site-settings"), + href: getAdminAppPath("/site-settings/brand"), icon: Globe2, - active: active === "site-settings", + active: active === "site-settings" && !siteSettingsChild, + expanded: active === "site-settings", + children: [ + { + label: copy.brandSettings ?? "Brand", + href: getAdminAppPath("/site-settings/brand"), + icon: Palette, + active: siteSettingsChild === "brand", + }, + { + label: copy.localizationSettings ?? "Localization", + href: getAdminAppPath("/site-settings/localization"), + icon: Languages, + active: siteSettingsChild === "localization", + }, + ], }, { label: copy.marquee ?? "Marquee", diff --git a/lib/locale.ts b/lib/locale.ts index 294e419..8cca476 100644 --- a/lib/locale.ts +++ b/lib/locale.ts @@ -29,11 +29,19 @@ export function stripLocalePrefix(pathname: string): string { } export function getLocalizedPath(locale: string, pathname = "/"): string { + return getLocalizedPathWithDefault(locale, pathname, routing.defaultLocale); +} + +export function getLocalizedPathWithDefault( + locale: string, + pathname = "/", + defaultLocale: AppLocale = routing.defaultLocale, +): string { const localeKey = resolveLocale(locale); const normalizedPath = pathname === "" ? "/" : pathname; const strippedPath = stripLocalePrefix(normalizedPath); - if (localeKey === routing.defaultLocale) { + if (localeKey === defaultLocale) { return strippedPath; } diff --git a/lib/metadata.ts b/lib/metadata.ts index f8bb4f1..1b61fff 100644 --- a/lib/metadata.ts +++ b/lib/metadata.ts @@ -11,7 +11,7 @@ import { getSiteSettings, getSiteSettingsMediaBindings, } from "./app-config"; -import { AppLocale, getLocalizedPath, resolveLocale } from "./locale"; +import { AppLocale, getLocalizedPath, getLocalizedPathWithDefault, resolveLocale } from "./locale"; import { buildSiteIconUrls } from "./site-icons"; function getSiteUrl(): URL { @@ -22,16 +22,16 @@ function toAbsoluteUrl(pathname: string): string { return new URL(pathname, getSiteUrl()).toString(); } -export function buildLocaleAlternates(pathname: string) { +export function buildLocaleAlternates(pathname: string, defaultLocale = routing.defaultLocale) { const languages = Object.fromEntries( - routing.locales.map((locale) => [locale, toAbsoluteUrl(getLocalizedPath(locale, pathname))]), + routing.locales.map((locale) => [locale, toAbsoluteUrl(getLocalizedPathWithDefault(locale, pathname, defaultLocale))]), ) as Record; return { - canonical: toAbsoluteUrl(getLocalizedPath(routing.defaultLocale, pathname)), + canonical: toAbsoluteUrl(getLocalizedPathWithDefault(defaultLocale, pathname, defaultLocale)), languages: { ...languages, - "x-default": toAbsoluteUrl(getLocalizedPath(routing.defaultLocale, pathname)), + "x-default": toAbsoluteUrl(getLocalizedPathWithDefault(defaultLocale, pathname, defaultLocale)), }, }; } @@ -68,7 +68,7 @@ export function buildAppMetadataFromConfig( settings: SiteSettings, bindings: SiteSettingsMediaBindings, ): Metadata { - const defaultLocaleSettings = settings.locales[routing.defaultLocale]; + const defaultLocaleSettings = settings.locales[settings.defaultLocale]; const openGraphImages = buildMetadataImages(bindings.defaultOgImage?.url); const siteIconUrls = buildSiteIconUrls({ siteName: defaultLocaleSettings.siteName, @@ -90,9 +90,9 @@ export function buildAppMetadataFromConfig( openGraph: { title: defaultLocaleSettings.siteName, description: defaultLocaleSettings.siteDescription, - url: toAbsoluteUrl(getLocalizedPath(routing.defaultLocale, "/")), + url: toAbsoluteUrl(getLocalizedPathWithDefault(settings.defaultLocale, "/", settings.defaultLocale)), siteName: defaultLocaleSettings.siteName, - locale: routing.defaultLocale, + locale: settings.defaultLocale, type: "website", images: openGraphImages, }, @@ -163,7 +163,7 @@ export function buildLocalizedMetadataFromConfig(input: { return { title: resolvedTitle, description: resolvedDescription, - alternates: buildLocaleAlternates(pathname), + alternates: buildLocaleAlternates(pathname, settings.defaultLocale), openGraph: { title: resolvedTitle, description: resolvedDescription, diff --git a/lib/site-icons.tsx b/lib/site-icons.tsx index 8d0fb21..dacbfdd 100644 --- a/lib/site-icons.tsx +++ b/lib/site-icons.tsx @@ -1,8 +1,6 @@ import { readFile } from "fs/promises"; import path from "path"; -import { routing } from "@/i18n/routing"; - import { getSiteSettings, getSiteSettingsMediaBindings } from "./app-config"; import { isManagedMediaFilePath, resolveMediaUploadPath } from "./media-storage"; @@ -66,7 +64,7 @@ export function buildSiteIconUrls(input: SiteIconInput): SiteIconUrls { export async function getDynamicSiteIconUrls(): Promise { const [settings, bindings] = await Promise.all([getSiteSettings(), getSiteSettingsMediaBindings()]); - const defaultLocaleSettings = settings.locales[routing.defaultLocale]; + const defaultLocaleSettings = settings.locales[settings.defaultLocale]; return buildSiteIconUrls({ siteName: defaultLocaleSettings.siteName, diff --git a/lib/site-settings.ts b/lib/site-settings.ts index fdd4895..36b2883 100644 --- a/lib/site-settings.ts +++ b/lib/site-settings.ts @@ -13,6 +13,7 @@ export const SITE_NAME_TOKEN = "{siteName}"; export const DEFAULT_SITE_NAME = "moh-sass"; export const DEFAULT_SITE_DESCRIPTION = "Multilingual Next.js base project"; +export const DEFAULT_SITE_PRIMARY_COLOR = "#dc5a35"; type SiteLocaleSettings = { siteName: string; @@ -21,7 +22,13 @@ type SiteLocaleSettings = { subhead: string; }; +type SiteBrandSettings = { + primaryColor: string; +}; + export type SiteSettings = { + defaultLocale: AppLocale; + brand: SiteBrandSettings; locales: Record; }; @@ -64,6 +71,20 @@ function normalizeSiteLocaleSettings( }; } +export function normalizeSiteDefaultLocale(input: unknown): AppLocale { + return input === "ar" || input === "en" || input === "de" ? input : "de"; +} + +export function normalizeSitePrimaryColor(input: unknown): string { + if (typeof input !== "string") { + return DEFAULT_SITE_PRIMARY_COLOR; + } + + const value = input.trim(); + + return /^#([0-9a-fA-F]{6})$/.test(value) ? value.toLowerCase() : DEFAULT_SITE_PRIMARY_COLOR; +} + export function getDefaultSiteSettingsMediaBindings(): SiteSettingsMediaBindings { return { siteLogoLight: null, @@ -75,6 +96,10 @@ export function getDefaultSiteSettingsMediaBindings(): SiteSettingsMediaBindings export function buildDefaultSiteSettings(fallbackName = DEFAULT_SITE_NAME): SiteSettings { return { + defaultLocale: "de", + brand: { + primaryColor: DEFAULT_SITE_PRIMARY_COLOR, + }, locales: { ar: { siteName: fallbackName, @@ -115,6 +140,14 @@ export function parseSiteSettingsValue( : {}; const siteSettings: SiteSettings = { + defaultLocale: normalizeSiteDefaultLocale(parsed.defaultLocale), + brand: { + primaryColor: normalizeSitePrimaryColor( + parsed.brand && typeof parsed.brand === "object" + ? (parsed.brand as Record).primaryColor + : undefined, + ), + }, locales: { ar: normalizeSiteLocaleSettings( locales.ar && typeof locales.ar === "object" ? (locales.ar as Record) : {}, diff --git a/lib/site-theme.ts b/lib/site-theme.ts new file mode 100644 index 0000000..fc5115d --- /dev/null +++ b/lib/site-theme.ts @@ -0,0 +1,134 @@ +import { + DEFAULT_SITE_PRIMARY_COLOR, + normalizeSitePrimaryColor, +} from "./site-settings"; + +type RgbColor = { + r: number; + g: number; + b: number; +}; + +type HslColor = { + h: number; + s: number; + l: number; +}; + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + +function hexToRgb(hex: string): RgbColor { + const normalized = normalizeSitePrimaryColor(hex).slice(1); + + return { + r: Number.parseInt(normalized.slice(0, 2), 16), + g: Number.parseInt(normalized.slice(2, 4), 16), + b: Number.parseInt(normalized.slice(4, 6), 16), + }; +} + +function rgbToHsl({ r, g, b }: RgbColor): HslColor { + const red = r / 255; + const green = g / 255; + const blue = b / 255; + const max = Math.max(red, green, blue); + const min = Math.min(red, green, blue); + const delta = max - min; + const lightness = (max + min) / 2; + + if (delta === 0) { + return { + h: 0, + s: 0, + l: Math.round(lightness * 100), + }; + } + + const saturation = + lightness > 0.5 + ? delta / (2 - max - min) + : delta / (max + min); + + let hue = 0; + + switch (max) { + case red: + hue = (green - blue) / delta + (green < blue ? 6 : 0); + break; + case green: + hue = (blue - red) / delta + 2; + break; + default: + hue = (red - green) / delta + 4; + break; + } + + return { + h: Math.round(hue * 60), + s: Math.round(saturation * 100), + l: Math.round(lightness * 100), + }; +} + +function toChannels(color: HslColor): string { + return `${Math.round(color.h)} ${Math.round(color.s)}% ${Math.round(color.l)}%`; +} + +function deriveSecondaryColor(color: HslColor, dark = false): HslColor { + return { + h: (color.h + 10) % 360, + s: clamp(color.s - (dark ? 2 : 8), 35, 95), + l: clamp(color.l + (dark ? 10 : 8), 20, 80), + }; +} + +function deriveDarkPrimary(color: HslColor): HslColor { + return { + h: color.h, + s: clamp(color.s, 40, 95), + l: clamp(color.l + 6, 30, 78), + }; +} + +export function buildSiteThemeTokens(primaryColor: string) { + const safePrimary = normalizeSitePrimaryColor(primaryColor || DEFAULT_SITE_PRIMARY_COLOR); + const basePrimary = rgbToHsl(hexToRgb(safePrimary)); + const darkPrimary = deriveDarkPrimary(basePrimary); + + return { + light: { + primary: toChannels(basePrimary), + secondary: toChannels(deriveSecondaryColor(basePrimary)), + }, + dark: { + primary: toChannels(darkPrimary), + secondary: toChannels(deriveSecondaryColor(darkPrimary, true)), + }, + }; +} + +export function buildSiteThemeStyleText(primaryColor: string) { + const tokens = buildSiteThemeTokens(primaryColor); + + return ` +:root { + --primary: ${tokens.light.primary}; + --ring: ${tokens.light.primary}; + --brand-primary: ${tokens.light.primary}; + --brand-secondary: ${tokens.light.secondary}; + --sidebar-primary: ${tokens.light.primary}; + --sidebar-ring: ${tokens.light.primary}; +} + +.dark { + --primary: ${tokens.dark.primary}; + --ring: ${tokens.dark.primary}; + --brand-primary: ${tokens.dark.primary}; + --brand-secondary: ${tokens.dark.secondary}; + --sidebar-primary: ${tokens.dark.primary}; + --sidebar-ring: ${tokens.dark.primary}; +} + `.trim(); +} diff --git a/middleware.ts b/middleware.ts index c0e1a1a..eb3e25c 100644 --- a/middleware.ts +++ b/middleware.ts @@ -14,9 +14,37 @@ import { isLegacyAdminPath, toInternalAdminPath, } from "./lib/admin-routing"; +import { getLocalizedPathWithDefault } from "./lib/locale"; const intlMiddleware = createMiddleware(routing); +async function getConfiguredDefaultLocale(request: NextRequest) { + try { + const response = await fetch(new URL("/api/site/default-locale", request.url), { + headers: { + "x-middleware-request": "1", + }, + cache: "no-store", + }); + + if (!response.ok) { + return routing.defaultLocale; + } + + const data = await response.json() as { defaultLocale?: string }; + + return data.defaultLocale === "ar" || data.defaultLocale === "en" || data.defaultLocale === "de" + ? data.defaultLocale + : routing.defaultLocale; + } catch { + return routing.defaultLocale; + } +} + +function hasLocalePrefix(pathname: string) { + return routing.locales.some((locale) => pathname === `/${locale}` || pathname.startsWith(`/${locale}/`)); +} + function getAdminBasicAuthUser(): string { return process.env.ADMIN_BASIC_AUTH_USER ?? ""; } @@ -111,7 +139,28 @@ export default async function middleware(request: NextRequest) { return response; } - if (pathname === "/de" || pathname.startsWith("/de/")) { + const configuredDefaultLocale = await getConfiguredDefaultLocale(request); + + if ( + configuredDefaultLocale !== routing.defaultLocale && + (pathname === "/de" || pathname.startsWith("/de/")) + ) { + return NextResponse.next(); + } + + if ( + configuredDefaultLocale !== routing.defaultLocale && + !hasLocalePrefix(pathname) + ) { + const redirectUrl = request.nextUrl.clone(); + redirectUrl.pathname = getLocalizedPathWithDefault(configuredDefaultLocale, pathname, routing.defaultLocale); + return NextResponse.redirect(redirectUrl, 307); + } + + if ( + configuredDefaultLocale === routing.defaultLocale && + (pathname === "/de" || pathname.startsWith("/de/")) + ) { const redirectUrl = request.nextUrl.clone(); const nextPath = pathname.slice(3) || "/"; redirectUrl.pathname = nextPath; diff --git a/tests/app-config.test.ts b/tests/app-config.test.ts index 392e707..8e55e15 100644 --- a/tests/app-config.test.ts +++ b/tests/app-config.test.ts @@ -9,6 +9,8 @@ describe("site settings helpers", () => { it("builds defaults from the fallback site name", () => { const settings = buildDefaultSiteSettings("Studio Moh"); + expect(settings.defaultLocale).toBe("de"); + expect(settings.brand.primaryColor).toBe("#dc5a35"); 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"); @@ -18,6 +20,10 @@ describe("site settings helpers", () => { it("merges stored values with safe defaults", () => { const settings = parseSiteSettingsValue( JSON.stringify({ + defaultLocale: "ar", + brand: { + primaryColor: "#112233", + }, locales: { en: { siteName: "Brand EN", @@ -34,6 +40,8 @@ describe("site settings helpers", () => { "Fallback Name", ); + expect(settings.defaultLocale).toBe("ar"); + expect(settings.brand.primaryColor).toBe("#112233"); expect(settings.locales.en.siteName).toBe("Brand EN"); expect(settings.locales.en.titleTemplate).toBe("{pageTitle} - {siteName}"); expect(settings.locales.en.siteDescription).toBe("English description"); @@ -66,4 +74,19 @@ describe("site settings helpers", () => { expect(settings.locales.ar.titleTemplate).toBe("{pageTitle} | {siteName}"); }); + + it("falls back for invalid default locale and invalid primary color", () => { + const settings = parseSiteSettingsValue( + JSON.stringify({ + defaultLocale: "fr", + brand: { + primaryColor: "red", + }, + }), + "Fallback Name", + ); + + expect(settings.defaultLocale).toBe("de"); + expect(settings.brand.primaryColor).toBe("#dc5a35"); + }); }); diff --git a/tests/metadata.test.ts b/tests/metadata.test.ts index 8c9ccfc..44b96c2 100644 --- a/tests/metadata.test.ts +++ b/tests/metadata.test.ts @@ -17,6 +17,8 @@ describe("metadata helpers", () => { it("builds root metadata with dynamic icons and social preview", () => { const settings = buildDefaultSiteSettings("Studio Moh"); + settings.defaultLocale = "ar"; + settings.locales.ar.siteName = "Studio Moh AR"; const metadata = buildAppMetadataFromConfig(settings, { siteLogoLight: null, siteLogoDark: null, @@ -32,12 +34,16 @@ describe("metadata helpers", () => { }, }); - expect(metadata.title).toBe("Studio Moh"); + expect(metadata.title).toBe("Studio Moh AR"); expect(metadata.icons).toEqual({ icon: [{ url: "/favicon.ico?v=v1" }], shortcut: [{ url: "/favicon.ico?v=v1" }], apple: [{ url: "/apple-icon.png?v=v1" }], }); + expect(metadata.openGraph).toMatchObject({ + locale: "ar", + url: "https://mohfarawati.de/", + }); expect(metadata.twitter).toMatchObject({ card: "summary_large_image", });