Add admin site settings sections and locale defaults

This commit is contained in:
MOH
2026-03-15 04:06:33 +01:00
parent 3e9edc5873
commit 5d3f77962a
21 changed files with 993 additions and 456 deletions
+22 -2
View File
@@ -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",
+9 -1
View File
@@ -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;
}
+9 -9
View File
@@ -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<AppLocale, string>;
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,
+1 -3
View File
@@ -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<SiteIconUrls> {
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,
+33
View File
@@ -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<AppLocale, SiteLocaleSettings>;
};
@@ -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<string, unknown>).primaryColor
: undefined,
),
},
locales: {
ar: normalizeSiteLocaleSettings(
locales.ar && typeof locales.ar === "object" ? (locales.ar as Record<string, unknown>) : {},
+134
View File
@@ -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();
}