Add admin site settings sections and locale defaults
This commit is contained in:
+22
-2
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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>) : {},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user