This commit is contained in:
@@ -21,7 +21,7 @@ export async function generateMetadata({
|
|||||||
const localeKey = resolveLocale(locale);
|
const localeKey = resolveLocale(locale);
|
||||||
const t = await getTranslations({ locale: localeKey, namespace: "aboutPage" });
|
const t = await getTranslations({ locale: localeKey, namespace: "aboutPage" });
|
||||||
|
|
||||||
return buildLocalizedMetadata({
|
return await buildLocalizedMetadata({
|
||||||
locale: localeKey,
|
locale: localeKey,
|
||||||
pathname: "/about",
|
pathname: "/about",
|
||||||
title: t("title"),
|
title: t("title"),
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export async function generateMetadata({
|
|||||||
const localeKey = resolveLocale(locale);
|
const localeKey = resolveLocale(locale);
|
||||||
const t = await getTranslations({ locale: localeKey, namespace: "contactPage" });
|
const t = await getTranslations({ locale: localeKey, namespace: "contactPage" });
|
||||||
|
|
||||||
return buildLocalizedMetadata({
|
return await buildLocalizedMetadata({
|
||||||
locale: localeKey,
|
locale: localeKey,
|
||||||
pathname: "/contact",
|
pathname: "/contact",
|
||||||
title: t("title"),
|
title: t("title"),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { getTranslations } from "next-intl/server";
|
|||||||
|
|
||||||
import { Container } from "@/components/layout/container";
|
import { Container } from "@/components/layout/container";
|
||||||
import { MotionFade } from "@/components/motion-fade";
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
|
import { getSiteSettings } from "@/lib/app-config";
|
||||||
import { buildLocalizedMetadata } from "@/lib/metadata";
|
import { buildLocalizedMetadata } from "@/lib/metadata";
|
||||||
import { AppCard } from "@/components/ui/app-card";
|
import { AppCard } from "@/components/ui/app-card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -34,13 +35,17 @@ export async function generateMetadata({
|
|||||||
params: { locale },
|
params: { locale },
|
||||||
}: HomePageProps): Promise<Metadata> {
|
}: HomePageProps): Promise<Metadata> {
|
||||||
const localeKey = resolveLocale(locale);
|
const localeKey = resolveLocale(locale);
|
||||||
const t = await getTranslations({ locale: localeKey, namespace: "homepage" });
|
const [t, siteSettings] = await Promise.all([
|
||||||
|
getTranslations({ locale: localeKey, namespace: "homepage" }),
|
||||||
|
getSiteSettings(),
|
||||||
|
]);
|
||||||
|
|
||||||
return buildLocalizedMetadata({
|
return await buildLocalizedMetadata({
|
||||||
locale: localeKey,
|
locale: localeKey,
|
||||||
pathname: "/",
|
pathname: "/",
|
||||||
title: t("heroTitle"),
|
title: siteSettings.locales[localeKey].siteName,
|
||||||
description: t("heroText"),
|
description: t("heroText"),
|
||||||
|
applyTitleTemplate: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export async function generateMetadata({
|
|||||||
const item = await getPublishedPortfolioProjectBySlug(slug);
|
const item = await getPublishedPortfolioProjectBySlug(slug);
|
||||||
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return buildLocalizedMetadata({
|
return await buildLocalizedMetadata({
|
||||||
locale: localeKey,
|
locale: localeKey,
|
||||||
pathname: `/portfolio/${slug}`,
|
pathname: `/portfolio/${slug}`,
|
||||||
title: "Portfolio",
|
title: "Portfolio",
|
||||||
@@ -66,7 +66,7 @@ export async function generateMetadata({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildLocalizedMetadata({
|
return await buildLocalizedMetadata({
|
||||||
locale: localeKey,
|
locale: localeKey,
|
||||||
pathname: `/portfolio/${slug}`,
|
pathname: `/portfolio/${slug}`,
|
||||||
title: getLocalizedValue(item.title, localeKey),
|
title: getLocalizedValue(item.title, localeKey),
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export async function generateMetadata({
|
|||||||
const localeKey = resolveLocale(locale);
|
const localeKey = resolveLocale(locale);
|
||||||
const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" });
|
const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" });
|
||||||
|
|
||||||
return buildLocalizedMetadata({
|
return await buildLocalizedMetadata({
|
||||||
locale: localeKey,
|
locale: localeKey,
|
||||||
pathname: "/portfolio",
|
pathname: "/portfolio",
|
||||||
title: t("title"),
|
title: t("title"),
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export async function generateMetadata({
|
|||||||
const item = getProductItem(slug);
|
const item = getProductItem(slug);
|
||||||
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return buildLocalizedMetadata({
|
return await buildLocalizedMetadata({
|
||||||
locale: localeKey,
|
locale: localeKey,
|
||||||
pathname: `/products/${slug}`,
|
pathname: `/products/${slug}`,
|
||||||
title: "Products",
|
title: "Products",
|
||||||
@@ -45,7 +45,7 @@ export async function generateMetadata({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildLocalizedMetadata({
|
return await buildLocalizedMetadata({
|
||||||
locale: localeKey,
|
locale: localeKey,
|
||||||
pathname: `/products/${slug}`,
|
pathname: `/products/${slug}`,
|
||||||
title: pickText(item.name, localeKey),
|
title: pickText(item.name, localeKey),
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export async function generateMetadata({
|
|||||||
const localeKey = resolveLocale(locale);
|
const localeKey = resolveLocale(locale);
|
||||||
const t = await getTranslations({ locale: localeKey, namespace: "productsPage" });
|
const t = await getTranslations({ locale: localeKey, namespace: "productsPage" });
|
||||||
|
|
||||||
return buildLocalizedMetadata({
|
return await buildLocalizedMetadata({
|
||||||
locale: localeKey,
|
locale: localeKey,
|
||||||
pathname: "/products",
|
pathname: "/products",
|
||||||
title: t("title"),
|
title: t("title"),
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export async function generateMetadata({
|
|||||||
const localeKey = resolveLocale(locale);
|
const localeKey = resolveLocale(locale);
|
||||||
const t = await getTranslations({ locale: localeKey, namespace: "successPage" });
|
const t = await getTranslations({ locale: localeKey, namespace: "successPage" });
|
||||||
|
|
||||||
return buildLocalizedMetadata({
|
return await buildLocalizedMetadata({
|
||||||
locale: localeKey,
|
locale: localeKey,
|
||||||
pathname: "/success",
|
pathname: "/success",
|
||||||
title: t("title"),
|
title: t("title"),
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export async function generateMetadata({
|
|||||||
const localeKey = resolveLocale(locale);
|
const localeKey = resolveLocale(locale);
|
||||||
const t = await getTranslations({ locale: localeKey, namespace: "comingSoon" });
|
const t = await getTranslations({ locale: localeKey, namespace: "comingSoon" });
|
||||||
|
|
||||||
return buildLocalizedMetadata({
|
return await buildLocalizedMetadata({
|
||||||
locale: localeKey,
|
locale: localeKey,
|
||||||
pathname: "/coming-soon",
|
pathname: "/coming-soon",
|
||||||
title: `${t("badge")} | moh-sass`,
|
title: `${t("badge")} | moh-sass`,
|
||||||
|
|||||||
+4
-5
@@ -1,14 +1,13 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { getLocale } from "next-intl/server";
|
import { getLocale } from "next-intl/server";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
import { buildAppMetadata } from "@/lib/metadata";
|
||||||
import { getDirection } from "@/lib/locale";
|
import { getDirection } from "@/lib/locale";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
title: "moh-sass",
|
return buildAppMetadata();
|
||||||
description: "Multilingual Next.js base project",
|
}
|
||||||
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de"),
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
return {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
userAgent: "*",
|
||||||
|
disallow: ["/root"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
type RootLayoutProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
robots: {
|
||||||
|
index: false,
|
||||||
|
follow: false,
|
||||||
|
nocache: true,
|
||||||
|
googleBot: {
|
||||||
|
index: false,
|
||||||
|
follow: false,
|
||||||
|
noimageindex: true,
|
||||||
|
"max-image-preview": "none",
|
||||||
|
"max-snippet": -1,
|
||||||
|
"max-video-preview": -1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: RootLayoutProps) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Power } from "lucide-react";
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { FormSaveButton } from "@/components/root/form-save-button";
|
||||||
import { MotionFade } from "@/components/motion-fade";
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
|
import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
|
||||||
import { routing } from "@/i18n/routing";
|
import { routing } from "@/i18n/routing";
|
||||||
@@ -10,8 +11,8 @@ import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"
|
|||||||
import { getMaintenanceMode, setMaintenanceMode } from "@/lib/app-config";
|
import { getMaintenanceMode, setMaintenanceMode } from "@/lib/app-config";
|
||||||
import { AppCard } from "@/components/ui/app-card";
|
import { AppCard } from "@/components/ui/app-card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { CardContent } from "@/components/ui/card";
|
import { CardContent } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -22,12 +23,13 @@ const copy = {
|
|||||||
maintenance: "Wartungsmodus",
|
maintenance: "Wartungsmodus",
|
||||||
uiKit: "UI Kit",
|
uiKit: "UI Kit",
|
||||||
media: "Media",
|
media: "Media",
|
||||||
|
siteSettings: "SEO",
|
||||||
portfolio: "Portfolio",
|
portfolio: "Portfolio",
|
||||||
maintenanceText: "Wenn aktiv, werden alle Seiten auf die Coming Soon Seite weitergeleitet.",
|
maintenanceText: "Wenn aktiv, werden alle Seiten auf die Coming Soon Seite weitergeleitet.",
|
||||||
maintenanceOn: "Aktiv",
|
maintenanceOn: "Aktiv",
|
||||||
maintenanceOff: "Inaktiv",
|
maintenanceOff: "Inaktiv",
|
||||||
enableMaintenance: "Wartungsmodus aktivieren",
|
selectLabel: "Status",
|
||||||
disableMaintenance: "Wartungsmodus deaktivieren",
|
selectHint: "Aenderung wird erst nach Speichern uebernommen.",
|
||||||
logout: "Ausloggen",
|
logout: "Ausloggen",
|
||||||
backToSite: "Zur Website",
|
backToSite: "Zur Website",
|
||||||
};
|
};
|
||||||
@@ -79,25 +81,36 @@ export default async function RootMaintenancePage() {
|
|||||||
headerTitle={copy.title}
|
headerTitle={copy.title}
|
||||||
headerDescription={copy.subtitle}
|
headerDescription={copy.subtitle}
|
||||||
headerActions={
|
headerActions={
|
||||||
|
<>
|
||||||
<Badge variant={maintenanceEnabled ? "warning" : "success"}>
|
<Badge variant={maintenanceEnabled ? "warning" : "success"}>
|
||||||
{maintenanceEnabled ? copy.maintenanceOn : copy.maintenanceOff}
|
{maintenanceEnabled ? copy.maintenanceOn : copy.maintenanceOff}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<FormSaveButton formId="maintenance-form" />
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<MotionFade delay={0.1}>
|
<MotionFade delay={0.1}>
|
||||||
<AppCard>
|
<AppCard>
|
||||||
<CardContent className="space-y-4 p-6">
|
<CardContent className="space-y-4 p-6">
|
||||||
<p className="text-sm text-muted-foreground">{copy.maintenanceText}</p>
|
<p className="text-sm text-muted-foreground">{copy.maintenanceText}</p>
|
||||||
<form action={updateMaintenanceMode}>
|
<form id="maintenance-form" action={updateMaintenanceMode} className="space-y-3">
|
||||||
<input
|
<div className="space-y-2">
|
||||||
type="hidden"
|
<Label htmlFor="enabled">{copy.selectLabel}</Label>
|
||||||
|
<select
|
||||||
|
id="enabled"
|
||||||
name="enabled"
|
name="enabled"
|
||||||
value={maintenanceEnabled ? "false" : "true"}
|
defaultValue={maintenanceEnabled ? "true" : "false"}
|
||||||
/>
|
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
|
||||||
<Button type="submit">
|
>
|
||||||
<Power className="h-4 w-4" />
|
<option value="false">{copy.maintenanceOff}</option>
|
||||||
{maintenanceEnabled ? copy.disableMaintenance : copy.enableMaintenance}
|
<option value="true">{copy.maintenanceOn}</option>
|
||||||
</Button>
|
</select>
|
||||||
|
<p className="text-xs text-muted-foreground">{copy.selectHint}</p>
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-nested border border-border bg-surface-1 px-3 py-2 text-sm text-foreground">
|
||||||
|
<Power className="h-4 w-4 text-brand-primary" />
|
||||||
|
{maintenanceEnabled ? copy.maintenanceOn : copy.maintenanceOff}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const copy = {
|
|||||||
maintenance: "Wartungsmodus",
|
maintenance: "Wartungsmodus",
|
||||||
uiKit: "UI Kit",
|
uiKit: "UI Kit",
|
||||||
media: "Media",
|
media: "Media",
|
||||||
|
siteSettings: "SEO",
|
||||||
portfolio: "Portfolio",
|
portfolio: "Portfolio",
|
||||||
logout: "Ausloggen",
|
logout: "Ausloggen",
|
||||||
backToSite: "Zur Website",
|
backToSite: "Zur Website",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const copy = {
|
|||||||
maintenance: "Wartungsmodus",
|
maintenance: "Wartungsmodus",
|
||||||
uiKit: "UI Kit",
|
uiKit: "UI Kit",
|
||||||
media: "Media",
|
media: "Media",
|
||||||
|
siteSettings: "SEO",
|
||||||
portfolio: "Portfolio",
|
portfolio: "Portfolio",
|
||||||
logout: "Ausloggen",
|
logout: "Ausloggen",
|
||||||
backToSite: "Zur Website",
|
backToSite: "Zur Website",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const copy = {
|
|||||||
overview: "Uebersicht",
|
overview: "Uebersicht",
|
||||||
maintenance: "Wartungsmodus",
|
maintenance: "Wartungsmodus",
|
||||||
uiKit: "UI Kit",
|
uiKit: "UI Kit",
|
||||||
|
siteSettings: "SEO",
|
||||||
portfolio: "Portfolio",
|
portfolio: "Portfolio",
|
||||||
logout: "Ausloggen",
|
logout: "Ausloggen",
|
||||||
backToSite: "Zur Website",
|
backToSite: "Zur Website",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const copy = {
|
|||||||
maintenance: "Wartungsmodus",
|
maintenance: "Wartungsmodus",
|
||||||
uiKit: "UI Kit",
|
uiKit: "UI Kit",
|
||||||
media: "Media",
|
media: "Media",
|
||||||
|
siteSettings: "SEO",
|
||||||
portfolio: "Portfolio",
|
portfolio: "Portfolio",
|
||||||
logout: "Ausloggen",
|
logout: "Ausloggen",
|
||||||
backToSite: "Zur Website",
|
backToSite: "Zur Website",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const copy = {
|
|||||||
maintenance: "Wartungsmodus",
|
maintenance: "Wartungsmodus",
|
||||||
uiKit: "UI Kit",
|
uiKit: "UI Kit",
|
||||||
media: "Media",
|
media: "Media",
|
||||||
|
siteSettings: "SEO",
|
||||||
portfolio: "Portfolio",
|
portfolio: "Portfolio",
|
||||||
logout: "Ausloggen",
|
logout: "Ausloggen",
|
||||||
backToSite: "Zur Website",
|
backToSite: "Zur Website",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const copy = {
|
|||||||
maintenance: "Wartungsmodus",
|
maintenance: "Wartungsmodus",
|
||||||
uiKit: "UI Kit",
|
uiKit: "UI Kit",
|
||||||
media: "Media",
|
media: "Media",
|
||||||
|
siteSettings: "SEO",
|
||||||
portfolio: "Portfolio",
|
portfolio: "Portfolio",
|
||||||
logout: "Ausloggen",
|
logout: "Ausloggen",
|
||||||
backToSite: "Zur Website",
|
backToSite: "Zur Website",
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { MediaUsageType } from "@prisma/client";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { isRedirectError } from "next/dist/client/components/redirect";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SITE_SETTINGS_DEFAULT_OG_IMAGE_FIELD_KEY,
|
||||||
|
SITE_SETTINGS_ENTITY_ID,
|
||||||
|
SITE_SETTINGS_ENTITY_TYPE,
|
||||||
|
SITE_SETTINGS_FAVICON_FIELD_KEY,
|
||||||
|
updateSiteSettings,
|
||||||
|
} from "@/lib/app-config";
|
||||||
|
import { PAGE_TITLE_TOKEN, type SiteSettings } from "@/lib/site-settings";
|
||||||
|
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||||
|
import { replaceEntityMediaUsages } from "@/lib/media";
|
||||||
|
import { resolveMediaSelection } from "@/lib/media-service";
|
||||||
|
import { routing } from "@/i18n/routing";
|
||||||
|
import { getLocalizedPath } from "@/lib/locale";
|
||||||
|
import { removeManagedMediaFile } from "@/lib/media-storage";
|
||||||
|
import { mediaFieldInputSchema } from "@/lib/media-validation";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
function ensureAdmin() {
|
||||||
|
if (!isAdminAuthenticated()) {
|
||||||
|
clearAdminSessionCookie();
|
||||||
|
redirect("/root");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withMessage(pathname: string, type: "success" | "error", message: string) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set(type, message);
|
||||||
|
|
||||||
|
return `${pathname}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonObject(rawValue: FormDataEntryValue | null, key: string) {
|
||||||
|
if (typeof rawValue !== "string" || rawValue.trim() === "") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawValue);
|
||||||
|
|
||||||
|
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
|
||||||
|
throw new Error(`${key} must be an object.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Invalid ${key} payload.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupCreatedMedia(assetIds: string[], uploadedPaths: string[]) {
|
||||||
|
if (assetIds.length > 0) {
|
||||||
|
await prisma.mediaAsset.deleteMany({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
in: Array.from(new Set(assetIds)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const filePath of Array.from(new Set(uploadedPaths.filter(Boolean)))) {
|
||||||
|
await removeManagedMediaFile(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revalidateSiteSettingsPages() {
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
revalidatePath("/root");
|
||||||
|
revalidatePath("/root/site-settings");
|
||||||
|
revalidatePath("/coming-soon");
|
||||||
|
|
||||||
|
const publicPaths = ["/", "/about", "/portfolio", "/products", "/contact", "/success", "/coming-soon"];
|
||||||
|
|
||||||
|
for (const locale of routing.locales) {
|
||||||
|
revalidatePath(getLocalizedPath(locale), "layout");
|
||||||
|
|
||||||
|
for (const path of publicPaths) {
|
||||||
|
revalidatePath(getLocalizedPath(locale, path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSiteSettingsAction(formData: FormData) {
|
||||||
|
ensureAdmin();
|
||||||
|
|
||||||
|
const createdMediaAssetIds: string[] = [];
|
||||||
|
const uploadedPaths: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const faviconMedia = parseJsonObject(formData.get("faviconMedia"), "faviconMedia");
|
||||||
|
const defaultOgImageMedia = parseJsonObject(
|
||||||
|
formData.get("defaultOgImageMedia"),
|
||||||
|
"defaultOgImageMedia",
|
||||||
|
);
|
||||||
|
|
||||||
|
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(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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 faviconSelection = faviconMedia
|
||||||
|
? await resolveMediaSelection({
|
||||||
|
media: mediaFieldInputSchema.parse(faviconMedia),
|
||||||
|
uploadFile: formData.get("faviconFile"),
|
||||||
|
folder: "site-settings",
|
||||||
|
fallbackLabel: `${parsedSettings.locales.de.siteName || parsedSettings.locales.en.siteName} favicon`,
|
||||||
|
required: false,
|
||||||
|
})
|
||||||
|
: {
|
||||||
|
assetId: null,
|
||||||
|
url: "",
|
||||||
|
createdAssetId: null,
|
||||||
|
uploadedUrl: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultOgImageSelection = defaultOgImageMedia
|
||||||
|
? await resolveMediaSelection({
|
||||||
|
media: mediaFieldInputSchema.parse(defaultOgImageMedia),
|
||||||
|
uploadFile: formData.get("defaultOgImageFile"),
|
||||||
|
folder: "site-settings",
|
||||||
|
fallbackLabel: `${parsedSettings.locales.de.siteName || parsedSettings.locales.en.siteName} og-image`,
|
||||||
|
required: false,
|
||||||
|
})
|
||||||
|
: {
|
||||||
|
assetId: null,
|
||||||
|
url: "",
|
||||||
|
createdAssetId: null,
|
||||||
|
uploadedUrl: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (faviconSelection.createdAssetId) {
|
||||||
|
createdMediaAssetIds.push(faviconSelection.createdAssetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (faviconSelection.uploadedUrl) {
|
||||||
|
uploadedPaths.push(faviconSelection.uploadedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defaultOgImageSelection.createdAssetId) {
|
||||||
|
createdMediaAssetIds.push(defaultOgImageSelection.createdAssetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defaultOgImageSelection.uploadedUrl) {
|
||||||
|
uploadedPaths.push(defaultOgImageSelection.uploadedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateSiteSettings(parsedSettings);
|
||||||
|
await replaceEntityMediaUsages({
|
||||||
|
entityType: SITE_SETTINGS_ENTITY_TYPE,
|
||||||
|
entityId: SITE_SETTINGS_ENTITY_ID,
|
||||||
|
usages: [
|
||||||
|
...(faviconSelection.assetId
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
assetId: faviconSelection.assetId,
|
||||||
|
usageType: MediaUsageType.GENERIC,
|
||||||
|
fieldKey: SITE_SETTINGS_FAVICON_FIELD_KEY,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(defaultOgImageSelection.assetId
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
assetId: defaultOgImageSelection.assetId,
|
||||||
|
usageType: MediaUsageType.GENERIC,
|
||||||
|
fieldKey: SITE_SETTINGS_DEFAULT_OG_IMAGE_FIELD_KEY,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await revalidateSiteSettingsPages();
|
||||||
|
redirect(withMessage("/root/site-settings", "success", "Einstellungen gespeichert."));
|
||||||
|
} catch (error) {
|
||||||
|
if (isRedirectError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await cleanupCreatedMedia(createdMediaAssetIds, uploadedPaths);
|
||||||
|
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Einstellungen konnten nicht gespeichert werden.";
|
||||||
|
|
||||||
|
redirect(withMessage("/root/site-settings", "error", message));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { MediaKind } from "@prisma/client";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
|
import { FormSaveButton } from "@/components/root/form-save-button";
|
||||||
|
import { SiteSettingsForm } from "@/components/root/site-settings-form";
|
||||||
|
import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
|
||||||
|
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||||
|
import {
|
||||||
|
getSiteSettings,
|
||||||
|
getSiteSettingsMediaBindings,
|
||||||
|
} from "@/lib/app-config";
|
||||||
|
import { getMediaOptions } from "@/lib/media";
|
||||||
|
|
||||||
|
import { saveSiteSettingsAction } from "./actions";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const copy = {
|
||||||
|
title: "SEO",
|
||||||
|
subtitle: "Globale SEO Einstellungen, Titelstruktur und Standardbilder verwalten.",
|
||||||
|
overview: "Uebersicht",
|
||||||
|
maintenance: "Wartungsmodus",
|
||||||
|
uiKit: "UI Kit",
|
||||||
|
media: "Media",
|
||||||
|
siteSettings: "SEO",
|
||||||
|
portfolio: "Portfolio",
|
||||||
|
logout: "Ausloggen",
|
||||||
|
backToSite: "Zur Website",
|
||||||
|
};
|
||||||
|
|
||||||
|
type RootSiteSettingsPageProps = {
|
||||||
|
searchParams?: {
|
||||||
|
success?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function RootSiteSettingsPage({
|
||||||
|
searchParams,
|
||||||
|
}: RootSiteSettingsPageProps) {
|
||||||
|
if (!isAdminAuthenticated()) {
|
||||||
|
redirect("/root");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logoutAction() {
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
clearAdminSessionCookie();
|
||||||
|
redirect("/root");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [siteSettings, mediaBindings, mediaOptions] = await Promise.all([
|
||||||
|
getSiteSettings(),
|
||||||
|
getSiteSettingsMediaBindings(),
|
||||||
|
getMediaOptions({ kind: MediaKind.IMAGE }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RootDashboardShell
|
||||||
|
copy={copy}
|
||||||
|
active="site-settings"
|
||||||
|
logoutAction={logoutAction}
|
||||||
|
headerTitle={copy.title}
|
||||||
|
headerDescription={copy.subtitle}
|
||||||
|
headerActions={<FormSaveButton formId="site-settings-form" />}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{searchParams?.success ? (
|
||||||
|
<MotionFade delay={0.1}>
|
||||||
|
<p className="rounded-nested border border-status-success/30 bg-status-success/10 px-4 py-3 text-sm text-status-success">
|
||||||
|
{searchParams.success}
|
||||||
|
</p>
|
||||||
|
</MotionFade>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{searchParams?.error ? (
|
||||||
|
<MotionFade delay={0.12}>
|
||||||
|
<p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||||
|
{searchParams.error}
|
||||||
|
</p>
|
||||||
|
</MotionFade>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<MotionFade delay={0.16}>
|
||||||
|
<SiteSettingsForm
|
||||||
|
action={saveSiteSettingsAction}
|
||||||
|
initialSettings={siteSettings}
|
||||||
|
initialBindings={mediaBindings}
|
||||||
|
mediaOptions={mediaOptions}
|
||||||
|
/>
|
||||||
|
</MotionFade>
|
||||||
|
</div>
|
||||||
|
</RootDashboardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ const copy = {
|
|||||||
overview: "Uebersicht",
|
overview: "Uebersicht",
|
||||||
uiKit: "UI Kit",
|
uiKit: "UI Kit",
|
||||||
media: "Media",
|
media: "Media",
|
||||||
|
siteSettings: "SEO",
|
||||||
portfolio: "Portfolio",
|
portfolio: "Portfolio",
|
||||||
logout: "Ausloggen",
|
logout: "Ausloggen",
|
||||||
backToSite: "Zur Website",
|
backToSite: "Zur Website",
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Save } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
type FormSaveButtonProps = {
|
||||||
|
formId: string;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function serializeForm(form: HTMLFormElement) {
|
||||||
|
return JSON.stringify(
|
||||||
|
Array.from(new FormData(form).entries()).map(([key, value]) => [
|
||||||
|
key,
|
||||||
|
value instanceof File ? `${value.name}:${value.size}:${value.type}` : value,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormSaveButton({
|
||||||
|
formId,
|
||||||
|
label = "Speichern",
|
||||||
|
}: FormSaveButtonProps) {
|
||||||
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const form = document.getElementById(formId);
|
||||||
|
|
||||||
|
if (!(form instanceof HTMLFormElement)) {
|
||||||
|
setIsDirty(false);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialSnapshot = serializeForm(form);
|
||||||
|
|
||||||
|
const updateDirtyState = () => {
|
||||||
|
setIsDirty(serializeForm(form) !== initialSnapshot);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateDirtyState();
|
||||||
|
|
||||||
|
form.addEventListener("input", updateDirtyState);
|
||||||
|
form.addEventListener("change", updateDirtyState);
|
||||||
|
form.addEventListener("reset", updateDirtyState);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
form.removeEventListener("input", updateDirtyState);
|
||||||
|
form.removeEventListener("change", updateDirtyState);
|
||||||
|
form.removeEventListener("reset", updateDirtyState);
|
||||||
|
};
|
||||||
|
}, [formId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button type="submit" form={formId} disabled={!isDirty}>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,6 +24,9 @@ type MediaFieldPickerProps = {
|
|||||||
options: MediaOption[];
|
options: MediaOption[];
|
||||||
inputName: string;
|
inputName: string;
|
||||||
fileFieldName: string;
|
fileFieldName: string;
|
||||||
|
fileLabel?: string;
|
||||||
|
externalLabel?: string;
|
||||||
|
libraryLabel?: string;
|
||||||
accept?: string;
|
accept?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -36,6 +39,9 @@ export function MediaFieldPicker({
|
|||||||
options,
|
options,
|
||||||
inputName,
|
inputName,
|
||||||
fileFieldName,
|
fileFieldName,
|
||||||
|
fileLabel,
|
||||||
|
externalLabel,
|
||||||
|
libraryLabel,
|
||||||
accept,
|
accept,
|
||||||
}: MediaFieldPickerProps) {
|
}: MediaFieldPickerProps) {
|
||||||
const filteredOptions = options.filter((option) => option.kind === value.kind);
|
const filteredOptions = options.filter((option) => option.kind === value.kind);
|
||||||
@@ -100,14 +106,14 @@ export function MediaFieldPicker({
|
|||||||
|
|
||||||
{value.mode === "upload" ? (
|
{value.mode === "upload" ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{fileFieldName}</Label>
|
<Label>{fileLabel ?? fileFieldName}</Label>
|
||||||
<Input name={fileFieldName} type="file" accept={accept} />
|
<Input name={fileFieldName} type="file" accept={accept} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{value.mode === "external" ? (
|
{value.mode === "external" ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>External URL</Label>
|
<Label>{externalLabel ?? "External URL"}</Label>
|
||||||
<Input
|
<Input
|
||||||
value={value.url}
|
value={value.url}
|
||||||
onChange={(event) => onChange({ ...value, url: event.target.value })}
|
onChange={(event) => onChange({ ...value, url: event.target.value })}
|
||||||
@@ -118,7 +124,7 @@ export function MediaFieldPicker({
|
|||||||
|
|
||||||
{value.mode === "library" ? (
|
{value.mode === "library" ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Media Library</Label>
|
<Label>{libraryLabel ?? "Media Library"}</Label>
|
||||||
<select
|
<select
|
||||||
value={value.assetId}
|
value={value.assetId}
|
||||||
onChange={(event) => onChange({ ...value, assetId: event.target.value })}
|
onChange={(event) => onChange({ ...value, assetId: event.target.value })}
|
||||||
|
|||||||
@@ -0,0 +1,401 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
|
||||||
|
import { MediaKind } from "@prisma/client";
|
||||||
|
import { ImageIcon, Search, Type } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { MediaFieldPicker, type MediaFieldState } from "@/components/root/media-field-picker";
|
||||||
|
import { AppCard } from "@/components/ui/app-card";
|
||||||
|
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
PAGE_TITLE_TOKEN,
|
||||||
|
SITE_NAME_TOKEN,
|
||||||
|
type SiteSettings,
|
||||||
|
type SiteSettingsMediaBindings,
|
||||||
|
} from "@/lib/site-settings";
|
||||||
|
import type { AppLocale } from "@/lib/locale";
|
||||||
|
import type { MediaOption } from "@/lib/media";
|
||||||
|
|
||||||
|
type SiteSettingsFormProps = {
|
||||||
|
action: (formData: FormData) => Promise<void>;
|
||||||
|
initialSettings: SiteSettings;
|
||||||
|
initialBindings: SiteSettingsMediaBindings;
|
||||||
|
mediaOptions: MediaOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const localeFields: Array<{
|
||||||
|
key: AppLocale;
|
||||||
|
label: string;
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function createImageFieldState(
|
||||||
|
assetId: string | null | undefined,
|
||||||
|
url: string | null | undefined,
|
||||||
|
label: string,
|
||||||
|
): MediaFieldState {
|
||||||
|
if (assetId) {
|
||||||
|
return {
|
||||||
|
mode: "library",
|
||||||
|
assetId,
|
||||||
|
url: "",
|
||||||
|
label,
|
||||||
|
kind: MediaKind.IMAGE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
return {
|
||||||
|
mode: "external",
|
||||||
|
assetId: "",
|
||||||
|
url,
|
||||||
|
label,
|
||||||
|
kind: MediaKind.IMAGE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: "upload",
|
||||||
|
assetId: "",
|
||||||
|
url: "",
|
||||||
|
label,
|
||||||
|
kind: MediaKind.IMAGE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMediaPreviewUrl(
|
||||||
|
value: MediaFieldState,
|
||||||
|
options: MediaOption[],
|
||||||
|
fallbackUrl: string | null | undefined,
|
||||||
|
) {
|
||||||
|
if (value.mode === "external") {
|
||||||
|
return value.url || fallbackUrl || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.mode === "library") {
|
||||||
|
return options.find((option) => option.id === value.assetId)?.url ?? fallbackUrl ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackUrl ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SiteSettingsForm({
|
||||||
|
action,
|
||||||
|
initialSettings,
|
||||||
|
initialBindings,
|
||||||
|
mediaOptions,
|
||||||
|
}: SiteSettingsFormProps) {
|
||||||
|
const [settings, setSettings] = useState(initialSettings);
|
||||||
|
const [favicon, setFavicon] = useState<MediaFieldState>(
|
||||||
|
createImageFieldState(
|
||||||
|
initialBindings.favicon?.assetId,
|
||||||
|
initialBindings.favicon?.url,
|
||||||
|
"Favicon",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const [defaultOgImage, setDefaultOgImage] = useState<MediaFieldState>(
|
||||||
|
createImageFieldState(
|
||||||
|
initialBindings.defaultOgImage?.assetId,
|
||||||
|
initialBindings.defaultOgImage?.url,
|
||||||
|
"Default OG Image",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const faviconPreviewUrl = getMediaPreviewUrl(favicon, mediaOptions, initialBindings.favicon?.url);
|
||||||
|
const defaultOgImagePreviewUrl = getMediaPreviewUrl(
|
||||||
|
defaultOgImage,
|
||||||
|
mediaOptions,
|
||||||
|
initialBindings.defaultOgImage?.url,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form id="site-settings-form" action={action} className="space-y-6">
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,3fr)_minmax(320px,1fr)]">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<AppCard>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>SEO Basics</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Diese Einstellungen bilden die globale Basis fuer Seitentitel, Description und Vorschau in Suchmaschinen.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Nutze
|
||||||
|
{" "}
|
||||||
|
<code>{PAGE_TITLE_TOKEN}</code>
|
||||||
|
{" "}
|
||||||
|
fuer den aktuellen Seitentitel.
|
||||||
|
und
|
||||||
|
{" "}
|
||||||
|
<code>{SITE_NAME_TOKEN}</code>
|
||||||
|
{" "}
|
||||||
|
fuer den Namen der aktuellen Sprache.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Beispiel:
|
||||||
|
{" "}
|
||||||
|
<code>{PAGE_TITLE_TOKEN} | {SITE_NAME_TOKEN}</code>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<AppCard>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Localized Titles And Copy</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Diese Werte gelten als globale Standards pro Sprache und werden verwendet, wenn eine Seite keinen eigenen Fallback hat.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4 lg:grid-cols-3">
|
||||||
|
{localeFields.map((locale) => (
|
||||||
|
<div key={locale.key} className="space-y-4 rounded-surface border border-border bg-surface-1 p-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">{locale.label}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Globale Standardwerte fuer diese Sprache.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`siteName${locale.suffix}`}>Site Name</Label>
|
||||||
|
<Input
|
||||||
|
id={`siteName${locale.suffix}`}
|
||||||
|
name={`siteName${locale.suffix}`}
|
||||||
|
value={settings.locales[locale.key].siteName}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSettings((current) => ({
|
||||||
|
...current,
|
||||||
|
locales: {
|
||||||
|
...current.locales,
|
||||||
|
[locale.key]: {
|
||||||
|
...current.locales[locale.key],
|
||||||
|
siteName: event.target.value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`titleTemplate${locale.suffix}`}>Title Template</Label>
|
||||||
|
<Input
|
||||||
|
id={`titleTemplate${locale.suffix}`}
|
||||||
|
name={`titleTemplate${locale.suffix}`}
|
||||||
|
value={settings.locales[locale.key].titleTemplate}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSettings((current) => ({
|
||||||
|
...current,
|
||||||
|
locales: {
|
||||||
|
...current.locales,
|
||||||
|
[locale.key]: {
|
||||||
|
...current.locales[locale.key],
|
||||||
|
titleTemplate: event.target.value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="{pageTitle} | {siteName}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`siteDescription${locale.suffix}`}>Site Description</Label>
|
||||||
|
<Input
|
||||||
|
id={`siteDescription${locale.suffix}`}
|
||||||
|
name={`siteDescription${locale.suffix}`}
|
||||||
|
value={settings.locales[locale.key].siteDescription}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSettings((current) => ({
|
||||||
|
...current,
|
||||||
|
locales: {
|
||||||
|
...current.locales,
|
||||||
|
[locale.key]: {
|
||||||
|
...current.locales[locale.key],
|
||||||
|
siteDescription: event.target.value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`subhead${locale.suffix}`}>Subhead</Label>
|
||||||
|
<Input
|
||||||
|
id={`subhead${locale.suffix}`}
|
||||||
|
name={`subhead${locale.suffix}`}
|
||||||
|
value={settings.locales[locale.key].subhead}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSettings((current) => ({
|
||||||
|
...current,
|
||||||
|
locales: {
|
||||||
|
...current.locales,
|
||||||
|
[locale.key]: {
|
||||||
|
...current.locales[locale.key],
|
||||||
|
subhead: event.target.value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<AppCard>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Preview Images</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Favicon erscheint im Browser. Das Default OG Bild wird fuer Social Sharing genutzt, wenn eine Seite kein eigenes Bild liefert.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-6 xl:grid-cols-2">
|
||||||
|
<MediaFieldPicker
|
||||||
|
title="Favicon"
|
||||||
|
value={favicon}
|
||||||
|
onChange={setFavicon}
|
||||||
|
options={mediaOptions}
|
||||||
|
inputName="faviconMedia"
|
||||||
|
fileFieldName="faviconFile"
|
||||||
|
fileLabel="Upload Favicon"
|
||||||
|
externalLabel="Favicon URL"
|
||||||
|
libraryLabel="Favicon Library"
|
||||||
|
accept=".png,.svg,.ico,image/png,image/svg+xml,image/x-icon,image/vnd.microsoft.icon"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MediaFieldPicker
|
||||||
|
title="Default OG Image"
|
||||||
|
value={defaultOgImage}
|
||||||
|
onChange={setDefaultOgImage}
|
||||||
|
options={mediaOptions}
|
||||||
|
inputName="defaultOgImageMedia"
|
||||||
|
fileFieldName="defaultOgImageFile"
|
||||||
|
fileLabel="Upload OG Image"
|
||||||
|
externalLabel="OG Image URL"
|
||||||
|
libraryLabel="OG Image Library"
|
||||||
|
accept="image/*,.svg"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="xl:sticky xl:top-6 xl:self-start">
|
||||||
|
<AppCard>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Preview</CardTitle>
|
||||||
|
<CardDescription>Suchmaschine und Social Sharing Vorschau.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-4 text-sm">
|
||||||
|
{localeFields.map((locale) => {
|
||||||
|
const localeSettings = settings.locales[locale.key];
|
||||||
|
const previewTitle = localeSettings.titleTemplate.includes(PAGE_TITLE_TOKEN)
|
||||||
|
? localeSettings.titleTemplate
|
||||||
|
.replace(PAGE_TITLE_TOKEN, locale.sampleTitle)
|
||||||
|
.replaceAll(SITE_NAME_TOKEN, localeSettings.siteName)
|
||||||
|
: `${locale.sampleTitle} | ${localeSettings.siteName}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={locale.key} className="rounded-nested border border-border bg-background p-3">
|
||||||
|
<p className="font-medium text-foreground">{locale.label}</p>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
Example page title: {locale.sampleTitle}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-foreground">{previewTitle}</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{localeSettings.siteDescription || "No description"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
{localeSettings.subhead || "No subhead"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||||
|
<Search className="h-4 w-4 text-brand-primary" />
|
||||||
|
Search Preview
|
||||||
|
</div>
|
||||||
|
<div className="rounded-nested border border-border bg-background p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{faviconPreviewUrl ? (
|
||||||
|
<img src={faviconPreviewUrl} alt="Favicon" className="h-5 w-5 rounded-sm" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-5 w-5 items-center justify-center rounded-sm border border-border bg-surface-1">
|
||||||
|
<Type className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-sm font-medium text-brand-primary">
|
||||||
|
{settings.locales.de.titleTemplate.includes(PAGE_TITLE_TOKEN)
|
||||||
|
? settings.locales.de.titleTemplate
|
||||||
|
.replace(PAGE_TITLE_TOKEN, "About")
|
||||||
|
.replaceAll(SITE_NAME_TOKEN, settings.locales.de.siteName)
|
||||||
|
: `About | ${settings.locales.de.siteName}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
{settings.locales.de.siteDescription || "No description"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground/80">
|
||||||
|
{settings.locales.de.subhead || "No subhead"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||||
|
<ImageIcon className="h-4 w-4 text-brand-secondary" />
|
||||||
|
Social Preview
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden rounded-nested border border-border bg-background">
|
||||||
|
{defaultOgImagePreviewUrl ? (
|
||||||
|
<img src={defaultOgImagePreviewUrl} alt="Default OG" className="h-32 w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-32 items-center justify-center bg-surface-1 text-sm text-muted-foreground">
|
||||||
|
No OG image selected
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2 p-4">
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
{settings.locales.en.titleTemplate.includes(PAGE_TITLE_TOKEN)
|
||||||
|
? settings.locales.en.titleTemplate
|
||||||
|
.replace(PAGE_TITLE_TOKEN, "Portfolio")
|
||||||
|
.replaceAll(SITE_NAME_TOKEN, settings.locales.en.siteName)
|
||||||
|
: `Portfolio | ${settings.locales.en.siteName}`}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{settings.locales.en.siteDescription || "No description"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
+111
-2
@@ -1,14 +1,45 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
export const MAINTENANCE_MODE_KEY = "maintenance_mode";
|
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> {
|
export async function getMaintenanceMode(): Promise<boolean> {
|
||||||
|
try {
|
||||||
const config = await prisma.appConfig.findUnique({
|
const config = await prisma.appConfig.findUnique({
|
||||||
where: { key: MAINTENANCE_MODE_KEY },
|
where: { key: MAINTENANCE_MODE_KEY },
|
||||||
select: { value: true },
|
select: { value: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
return config?.value === "true";
|
return config?.value === "true";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setMaintenanceMode(enabled: boolean): Promise<void> {
|
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];
|
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;
|
export const MAX_MEDIA_FILE_SIZE = 5 * 1024 * 1024;
|
||||||
|
|
||||||
const MIME_EXTENSIONS: Record<string, string> = {
|
const MIME_EXTENSIONS: Record<string, string> = {
|
||||||
|
"image/x-icon": ".ico",
|
||||||
|
"image/vnd.microsoft.icon": ".ico",
|
||||||
"image/jpeg": ".jpg",
|
"image/jpeg": ".jpg",
|
||||||
"image/png": ".png",
|
"image/png": ".png",
|
||||||
"image/webp": ".webp",
|
"image/webp": ".webp",
|
||||||
|
|||||||
+132
-10
@@ -1,7 +1,17 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
import { routing } from "@/i18n/routing";
|
import { routing } from "../i18n/routing";
|
||||||
import { AppLocale, getLocalizedPath, resolveLocale } from "@/lib/locale";
|
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 {
|
function getSiteUrl(): URL {
|
||||||
return new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de");
|
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 = {
|
type LocalizedMetadataInput = {
|
||||||
locale: string;
|
locale: string;
|
||||||
pathname: string;
|
pathname: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description?: string;
|
||||||
|
applyTitleTemplate?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildLocalizedMetadata({
|
export async function buildLocalizedMetadata({
|
||||||
locale,
|
locale,
|
||||||
pathname,
|
pathname,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
}: LocalizedMetadataInput): Metadata {
|
applyTitleTemplate,
|
||||||
|
}: LocalizedMetadataInput): Promise<Metadata> {
|
||||||
const localeKey = resolveLocale(locale);
|
const localeKey = resolveLocale(locale);
|
||||||
|
const [settings, bindings] = await Promise.all([getSiteSettings(), getSiteSettingsMediaBindings()]);
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
title,
|
title: resolvedTitle,
|
||||||
description,
|
description: resolvedDescription,
|
||||||
alternates: buildLocaleAlternates(pathname),
|
alternates: buildLocaleAlternates(pathname),
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title,
|
title: resolvedTitle,
|
||||||
description,
|
description: resolvedDescription,
|
||||||
url: toAbsoluteUrl(getLocalizedPath(localeKey, pathname)),
|
url: toAbsoluteUrl(getLocalizedPath(localeKey, pathname)),
|
||||||
siteName: "moh-sass",
|
siteName: localeSettings.siteName,
|
||||||
locale: localeKey,
|
locale: localeKey,
|
||||||
type: "website",
|
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 {
|
import {
|
||||||
FolderKanban,
|
FolderKanban,
|
||||||
|
Globe2,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
PlusSquare,
|
PlusSquare,
|
||||||
@@ -15,6 +16,7 @@ type RootNavigationCopy = {
|
|||||||
uiKit: string;
|
uiKit: string;
|
||||||
portfolio: string;
|
portfolio: string;
|
||||||
media: string;
|
media: string;
|
||||||
|
siteSettings: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RootNavItem = {
|
export type RootNavItem = {
|
||||||
@@ -27,7 +29,7 @@ export type RootNavItem = {
|
|||||||
|
|
||||||
export function getRootNavigation(
|
export function getRootNavigation(
|
||||||
copy: RootNavigationCopy,
|
copy: RootNavigationCopy,
|
||||||
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media",
|
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings",
|
||||||
portfolioChild?: "overview" | "projects" | "new-project" | "categories",
|
portfolioChild?: "overview" | "projects" | "new-project" | "categories",
|
||||||
): RootNavItem[] {
|
): RootNavItem[] {
|
||||||
return [
|
return [
|
||||||
@@ -55,6 +57,12 @@ export function getRootNavigation(
|
|||||||
icon: ImageIcon,
|
icon: ImageIcon,
|
||||||
active: active === "media",
|
active: active === "media",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: copy.siteSettings,
|
||||||
|
href: "/root/site-settings",
|
||||||
|
icon: Globe2,
|
||||||
|
active: active === "site-settings",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: copy.portfolio,
|
label: copy.portfolio,
|
||||||
href: "/root/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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-1
@@ -43,6 +43,9 @@ export default async function middleware(request: NextRequest) {
|
|||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
const isRootBaseRoute = pathname === "/root" || pathname.startsWith("/root/");
|
const isRootBaseRoute = pathname === "/root" || pathname.startsWith("/root/");
|
||||||
const isRootRoute = isRootBaseRoute;
|
const isRootRoute = isRootBaseRoute;
|
||||||
|
const rootRobotsHeaders = {
|
||||||
|
"X-Robots-Tag": "noindex, nofollow, noarchive, nosnippet, noimageindex",
|
||||||
|
};
|
||||||
|
|
||||||
if (pathname === "/de" || pathname.startsWith("/de/")) {
|
if (pathname === "/de" || pathname.startsWith("/de/")) {
|
||||||
const redirectUrl = request.nextUrl.clone();
|
const redirectUrl = request.nextUrl.clone();
|
||||||
@@ -56,12 +59,15 @@ export default async function middleware(request: NextRequest) {
|
|||||||
status: 401,
|
status: 401,
|
||||||
headers: {
|
headers: {
|
||||||
"WWW-Authenticate": 'Basic realm="Root Area", charset="UTF-8"',
|
"WWW-Authenticate": 'Basic realm="Root Area", charset="UTF-8"',
|
||||||
|
...rootRobotsHeaders,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRootBaseRoute) {
|
if (isRootBaseRoute) {
|
||||||
return NextResponse.next();
|
const response = NextResponse.next();
|
||||||
|
response.headers.set("X-Robots-Tag", rootRobotsHeaders["X-Robots-Tag"]);
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
return intlMiddleware(request);
|
return intlMiddleware(request);
|
||||||
|
|||||||
@@ -16,6 +16,61 @@ async function main() {
|
|||||||
create: { key: "siteName", value: "moh-sass" },
|
create: { key: "siteName", value: "moh-sass" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await prisma.appConfig.upsert({
|
||||||
|
where: { key: "site_settings" },
|
||||||
|
update: {
|
||||||
|
value: JSON.stringify({
|
||||||
|
titleTemplate: "{pageTitle} | moh-sass",
|
||||||
|
locales: {
|
||||||
|
ar: {
|
||||||
|
siteName: "moh-sass",
|
||||||
|
titleTemplate: "{pageTitle} | {siteName}",
|
||||||
|
siteDescription: "Multilingual Next.js base project",
|
||||||
|
subhead: "",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
siteName: "moh-sass",
|
||||||
|
titleTemplate: "{pageTitle} | {siteName}",
|
||||||
|
siteDescription: "Multilingual Next.js base project",
|
||||||
|
subhead: "",
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
siteName: "moh-sass",
|
||||||
|
titleTemplate: "{pageTitle} | {siteName}",
|
||||||
|
siteDescription: "Multilingual Next.js base project",
|
||||||
|
subhead: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
key: "site_settings",
|
||||||
|
value: JSON.stringify({
|
||||||
|
titleTemplate: "{pageTitle} | moh-sass",
|
||||||
|
locales: {
|
||||||
|
ar: {
|
||||||
|
siteName: "moh-sass",
|
||||||
|
titleTemplate: "{pageTitle} | {siteName}",
|
||||||
|
siteDescription: "Multilingual Next.js base project",
|
||||||
|
subhead: "",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
siteName: "moh-sass",
|
||||||
|
titleTemplate: "{pageTitle} | {siteName}",
|
||||||
|
siteDescription: "Multilingual Next.js base project",
|
||||||
|
subhead: "",
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
siteName: "moh-sass",
|
||||||
|
titleTemplate: "{pageTitle} | {siteName}",
|
||||||
|
siteDescription: "Multilingual Next.js base project",
|
||||||
|
subhead: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const brandCategory = await prisma.category.upsert({
|
const brandCategory = await prisma.category.upsert({
|
||||||
where: { slug: "branding" },
|
where: { slug: "branding" },
|
||||||
update: {
|
update: {
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildDefaultSiteSettings,
|
||||||
|
parseSiteSettingsValue,
|
||||||
|
} from "../lib/site-settings";
|
||||||
|
|
||||||
|
describe("site settings helpers", () => {
|
||||||
|
it("builds defaults from the fallback site name", () => {
|
||||||
|
const settings = buildDefaultSiteSettings("Studio Moh");
|
||||||
|
|
||||||
|
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");
|
||||||
|
expect(settings.locales.de.subhead).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges stored values with safe defaults", () => {
|
||||||
|
const settings = parseSiteSettingsValue(
|
||||||
|
JSON.stringify({
|
||||||
|
locales: {
|
||||||
|
en: {
|
||||||
|
siteName: "Brand EN",
|
||||||
|
titleTemplate: "{pageTitle} - {siteName}",
|
||||||
|
siteDescription: "English description",
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
siteName: "Brand DE",
|
||||||
|
titleTemplate: "{pageTitle} | {siteName} | Freelancer",
|
||||||
|
subhead: "German subhead",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"Fallback Name",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(settings.locales.en.siteName).toBe("Brand EN");
|
||||||
|
expect(settings.locales.en.titleTemplate).toBe("{pageTitle} - {siteName}");
|
||||||
|
expect(settings.locales.en.siteDescription).toBe("English description");
|
||||||
|
expect(settings.locales.en.subhead).toBe("");
|
||||||
|
expect(settings.locales.ar.siteName).toBe("Fallback Name");
|
||||||
|
expect(settings.locales.ar.titleTemplate).toBe("{pageTitle} | {siteName}");
|
||||||
|
expect(settings.locales.de.siteDescription).toBe("Multilingual Next.js base project");
|
||||||
|
expect(settings.locales.de.titleTemplate).toBe("{pageTitle} | {siteName} | Freelancer");
|
||||||
|
expect(settings.locales.de.subhead).toBe("German subhead");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back when stored json is invalid", () => {
|
||||||
|
const settings = parseSiteSettingsValue("{invalid-json", "Fallback Name");
|
||||||
|
|
||||||
|
expect(settings).toEqual(buildDefaultSiteSettings("Fallback Name"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { buildDefaultSiteSettings } from "../lib/site-settings";
|
||||||
|
import {
|
||||||
|
applyTitleTemplateFn,
|
||||||
|
buildAppMetadataFromConfig,
|
||||||
|
buildLocalizedMetadataFromConfig,
|
||||||
|
} from "../lib/metadata";
|
||||||
|
|
||||||
|
describe("metadata helpers", () => {
|
||||||
|
it("applies the configured title template", () => {
|
||||||
|
expect(applyTitleTemplateFn("About", "{pageTitle} | {siteName}", "Studio Moh")).toBe(
|
||||||
|
"About | Studio Moh",
|
||||||
|
);
|
||||||
|
expect(applyTitleTemplateFn("About", "Studio Moh", "Studio Moh")).toBe("About | Studio Moh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds root metadata with dynamic icons and social preview", () => {
|
||||||
|
const settings = buildDefaultSiteSettings("Studio Moh");
|
||||||
|
const metadata = buildAppMetadataFromConfig(settings, {
|
||||||
|
favicon: {
|
||||||
|
assetId: "fav",
|
||||||
|
url: "/uploads/media/site-settings/favicon.svg",
|
||||||
|
},
|
||||||
|
defaultOgImage: {
|
||||||
|
assetId: "og",
|
||||||
|
url: "/uploads/media/site-settings/default-og.png",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(metadata.title).toBe("Studio Moh");
|
||||||
|
expect(metadata.icons).toEqual({
|
||||||
|
icon: ["/uploads/media/site-settings/favicon.svg"],
|
||||||
|
shortcut: ["/uploads/media/site-settings/favicon.svg"],
|
||||||
|
apple: ["/uploads/media/site-settings/favicon.svg"],
|
||||||
|
});
|
||||||
|
expect(metadata.twitter).toMatchObject({
|
||||||
|
card: "summary_large_image",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to localized site description and omits icons when unset", () => {
|
||||||
|
const settings = buildDefaultSiteSettings("Studio Moh");
|
||||||
|
settings.locales.en.siteDescription = "English fallback description";
|
||||||
|
settings.locales.en.titleTemplate = "{pageTitle} | {siteName} | Freelancer";
|
||||||
|
const metadata = buildLocalizedMetadataFromConfig({
|
||||||
|
settings,
|
||||||
|
bindings: {
|
||||||
|
favicon: null,
|
||||||
|
defaultOgImage: null,
|
||||||
|
},
|
||||||
|
locale: "en",
|
||||||
|
pathname: "/about",
|
||||||
|
title: "About",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(metadata.title).toBe("About | Studio Moh | Freelancer");
|
||||||
|
expect(metadata.description).toBe("English fallback description");
|
||||||
|
expect(metadata.openGraph?.siteName).toBe("Studio Moh");
|
||||||
|
expect(metadata.twitter).toMatchObject({
|
||||||
|
card: "summary",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can skip the title template for the homepage", () => {
|
||||||
|
const settings = buildDefaultSiteSettings("Studio Moh");
|
||||||
|
settings.locales.ar.siteName = "اسم الموقع";
|
||||||
|
settings.locales.ar.titleTemplate = "{pageTitle} | {siteName}";
|
||||||
|
|
||||||
|
const metadata = buildLocalizedMetadataFromConfig({
|
||||||
|
settings,
|
||||||
|
bindings: {
|
||||||
|
favicon: null,
|
||||||
|
defaultOgImage: null,
|
||||||
|
},
|
||||||
|
locale: "ar",
|
||||||
|
pathname: "/",
|
||||||
|
title: "اسم الموقع",
|
||||||
|
description: "وصف",
|
||||||
|
applyTitleTemplate: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(metadata.title).toBe("اسم الموقع");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user