Add SEO settings and root admin save flows
CI / quality (push) Waiting to run

This commit is contained in:
MOH
2026-03-07 18:48:01 +01:00
parent f6a5a37676
commit f259dfc6bd
35 changed files with 1500 additions and 56 deletions
+116 -7
View File
@@ -1,14 +1,45 @@
import { prisma } from "@/lib/prisma";
import { prisma } from "./prisma";
export const MAINTENANCE_MODE_KEY = "maintenance_mode";
export {
SITE_NAME_KEY,
SITE_SETTINGS_DEFAULT_OG_IMAGE_FIELD_KEY,
SITE_SETTINGS_ENTITY_ID,
SITE_SETTINGS_ENTITY_TYPE,
SITE_SETTINGS_FAVICON_FIELD_KEY,
SITE_SETTINGS_KEY,
DEFAULT_SITE_NAME,
buildDefaultSiteSettings,
getDefaultSiteSettingsMediaBindings,
parseSiteSettingsValue,
type SiteSettings,
type SiteSettingsMediaBindings,
} from "./site-settings";
import {
DEFAULT_SITE_NAME,
SITE_NAME_KEY,
SITE_SETTINGS_DEFAULT_OG_IMAGE_FIELD_KEY,
SITE_SETTINGS_ENTITY_ID,
SITE_SETTINGS_ENTITY_TYPE,
SITE_SETTINGS_FAVICON_FIELD_KEY,
SITE_SETTINGS_KEY,
buildDefaultSiteSettings,
getDefaultSiteSettingsMediaBindings,
parseSiteSettingsValue,
type SiteSettings,
type SiteSettingsMediaBindings,
} from "./site-settings";
export async function getMaintenanceMode(): Promise<boolean> {
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<void> {
@@ -23,3 +54,81 @@ export async function setMaintenanceMode(enabled: boolean): Promise<void> {
},
});
}
export async function getSiteSettings(): Promise<SiteSettings> {
try {
const configs = await prisma.appConfig.findMany({
where: {
key: {
in: [SITE_SETTINGS_KEY, SITE_NAME_KEY],
},
},
select: {
key: true,
value: true,
},
});
const configMap = new Map(configs.map((config) => [config.key, config.value]));
const fallbackName = configMap.get(SITE_NAME_KEY) ?? DEFAULT_SITE_NAME;
return parseSiteSettingsValue(configMap.get(SITE_SETTINGS_KEY), fallbackName);
} catch {
return buildDefaultSiteSettings();
}
}
export async function updateSiteSettings(settings: SiteSettings): Promise<void> {
await prisma.appConfig.upsert({
where: { key: SITE_SETTINGS_KEY },
update: {
value: JSON.stringify(settings),
},
create: {
key: SITE_SETTINGS_KEY,
value: JSON.stringify(settings),
},
});
}
export async function getSiteSettingsMediaBindings(): Promise<SiteSettingsMediaBindings> {
try {
const usages = await prisma.mediaUsage.findMany({
where: {
entityType: SITE_SETTINGS_ENTITY_TYPE,
entityId: SITE_SETTINGS_ENTITY_ID,
},
include: {
asset: {
select: {
id: true,
url: true,
},
},
},
});
return usages.reduce<SiteSettingsMediaBindings>(
(result, usage) => {
if (usage.fieldKey === SITE_SETTINGS_FAVICON_FIELD_KEY) {
result.favicon = {
assetId: usage.asset.id,
url: usage.asset.url,
};
}
if (usage.fieldKey === SITE_SETTINGS_DEFAULT_OG_IMAGE_FIELD_KEY) {
result.defaultOgImage = {
assetId: usage.asset.id,
url: usage.asset.url,
};
}
return result;
},
getDefaultSiteSettingsMediaBindings(),
);
} catch {
return getDefaultSiteSettingsMediaBindings();
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { routing } from "@/i18n/routing";
import { routing } from "../i18n/routing";
export type AppLocale = (typeof routing.locales)[number];
+2
View File
@@ -6,6 +6,8 @@ export const MEDIA_UPLOAD_ROOT = path.join(process.cwd(), "public", "uploads", "
export const MAX_MEDIA_FILE_SIZE = 5 * 1024 * 1024;
const MIME_EXTENSIONS: Record<string, string> = {
"image/x-icon": ".ico",
"image/vnd.microsoft.icon": ".ico",
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
+131 -9
View File
@@ -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<Metadata> {
const [settings, bindings] = await Promise.all([getSiteSettings(), getSiteSettingsMediaBindings()]);
return buildAppMetadataFromConfig(settings, bindings);
}
export function buildAppMetadataFromConfig(
settings: SiteSettings,
bindings: SiteSettingsMediaBindings,
): Metadata {
const defaultLocaleSettings = settings.locales[routing.defaultLocale];
const openGraphImages = buildMetadataImages(bindings.defaultOgImage?.url);
return {
metadataBase: getSiteUrl(),
title: defaultLocaleSettings.siteName,
description: defaultLocaleSettings.siteDescription,
applicationName: defaultLocaleSettings.siteName,
icons: bindings.favicon?.url
? {
icon: [bindings.favicon.url],
shortcut: [bindings.favicon.url],
apple: [bindings.favicon.url],
}
: undefined,
openGraph: {
title: defaultLocaleSettings.siteName,
description: defaultLocaleSettings.siteDescription,
url: toAbsoluteUrl(getLocalizedPath(routing.defaultLocale, "/")),
siteName: defaultLocaleSettings.siteName,
locale: routing.defaultLocale,
type: "website",
images: openGraphImages,
},
twitter: {
card: openGraphImages ? "summary_large_image" : "summary",
title: defaultLocaleSettings.siteName,
description: defaultLocaleSettings.siteDescription,
images: openGraphImages?.map((image) => image.url),
},
};
}
type LocalizedMetadataInput = {
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<Metadata> {
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),
},
};
}
+9 -1
View File
@@ -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",
+152
View File
@@ -0,0 +1,152 @@
import type { AppLocale } from "./locale";
export const SITE_NAME_KEY = "siteName";
export const SITE_SETTINGS_KEY = "site_settings";
export const SITE_SETTINGS_ENTITY_TYPE = "site-settings";
export const SITE_SETTINGS_ENTITY_ID = "global";
export const SITE_SETTINGS_FAVICON_FIELD_KEY = "favicon";
export const SITE_SETTINGS_DEFAULT_OG_IMAGE_FIELD_KEY = "defaultOgImage";
export const PAGE_TITLE_TOKEN = "{pageTitle}";
export const SITE_NAME_TOKEN = "{siteName}";
export const DEFAULT_SITE_NAME = "moh-sass";
export const DEFAULT_SITE_DESCRIPTION = "Multilingual Next.js base project";
type SiteLocaleSettings = {
siteName: string;
titleTemplate: string;
siteDescription: string;
subhead: string;
};
export type SiteSettings = {
locales: Record<AppLocale, SiteLocaleSettings>;
};
export type SiteSettingsMediaBinding = {
assetId: string;
url: string;
};
export type SiteSettingsMediaBindings = {
favicon: SiteSettingsMediaBinding | null;
defaultOgImage: SiteSettingsMediaBinding | null;
};
function normalizeSiteLocaleSettings(
input: unknown,
fallbackName: string,
fallbackDescription: string,
): SiteLocaleSettings {
const value = input && typeof input === "object" ? (input as Record<string, unknown>) : {};
return {
siteName:
typeof value.siteName === "string" && value.siteName.trim()
? value.siteName.trim()
: fallbackName,
titleTemplate:
typeof value.titleTemplate === "string" &&
value.titleTemplate.trim() &&
value.titleTemplate.includes(PAGE_TITLE_TOKEN)
? value.titleTemplate.trim()
: `${PAGE_TITLE_TOKEN} | ${SITE_NAME_TOKEN}`,
siteDescription:
typeof value.siteDescription === "string"
? value.siteDescription.trim()
: fallbackDescription,
subhead: typeof value.subhead === "string" ? value.subhead.trim() : "",
};
}
export function getDefaultSiteSettingsMediaBindings(): SiteSettingsMediaBindings {
return {
favicon: null,
defaultOgImage: null,
};
}
export function buildDefaultSiteSettings(fallbackName = DEFAULT_SITE_NAME): SiteSettings {
return {
locales: {
ar: {
siteName: fallbackName,
titleTemplate: `${PAGE_TITLE_TOKEN} | ${SITE_NAME_TOKEN}`,
siteDescription: DEFAULT_SITE_DESCRIPTION,
subhead: "",
},
en: {
siteName: fallbackName,
titleTemplate: `${PAGE_TITLE_TOKEN} | ${SITE_NAME_TOKEN}`,
siteDescription: DEFAULT_SITE_DESCRIPTION,
subhead: "",
},
de: {
siteName: fallbackName,
titleTemplate: `${PAGE_TITLE_TOKEN} | ${SITE_NAME_TOKEN}`,
siteDescription: DEFAULT_SITE_DESCRIPTION,
subhead: "",
},
},
};
}
export function parseSiteSettingsValue(
rawValue: string | null | undefined,
fallbackName = DEFAULT_SITE_NAME,
): SiteSettings {
const defaults = buildDefaultSiteSettings(fallbackName);
if (!rawValue) {
return defaults;
}
try {
const parsed = JSON.parse(rawValue) as Record<string, unknown>;
const locales = parsed.locales && typeof parsed.locales === "object"
? (parsed.locales as Record<string, unknown>)
: {};
const siteSettings: SiteSettings = {
locales: {
ar: normalizeSiteLocaleSettings(
{
titleTemplate:
typeof parsed.titleTemplate === "string" && parsed.titleTemplate.trim()
? parsed.titleTemplate.trim()
: undefined,
...(locales.ar && typeof locales.ar === "object" ? (locales.ar as Record<string, unknown>) : {}),
},
defaults.locales.ar.siteName,
defaults.locales.ar.siteDescription,
),
en: normalizeSiteLocaleSettings(
{
titleTemplate:
typeof parsed.titleTemplate === "string" && parsed.titleTemplate.trim()
? parsed.titleTemplate.trim()
: undefined,
...(locales.en && typeof locales.en === "object" ? (locales.en as Record<string, unknown>) : {}),
},
defaults.locales.en.siteName,
defaults.locales.en.siteDescription,
),
de: normalizeSiteLocaleSettings(
{
titleTemplate:
typeof parsed.titleTemplate === "string" && parsed.titleTemplate.trim()
? parsed.titleTemplate.trim()
: undefined,
...(locales.de && typeof locales.de === "object" ? (locales.de as Record<string, unknown>) : {}),
},
defaults.locales.de.siteName,
defaults.locales.de.siteDescription,
),
},
};
return siteSettings;
} catch {
return defaults;
}
}