This commit is contained in:
+116
-7
@@ -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
@@ -1,4 +1,4 @@
|
||||
import { routing } from "@/i18n/routing";
|
||||
import { routing } from "../i18n/routing";
|
||||
|
||||
export type AppLocale = (typeof routing.locales)[number];
|
||||
|
||||
|
||||
@@ -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
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user