diff --git a/app/apple-icon.png/route.ts b/app/apple-icon.png/route.ts new file mode 100644 index 0000000..06e8288 --- /dev/null +++ b/app/apple-icon.png/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; + +import { buildFallbackIconResponse, getDynamicSiteIconUrls } from "@/lib/site-icons"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +export async function GET() { + const iconUrls = await getDynamicSiteIconUrls(); + + if (iconUrls.faviconAssetUrl) { + const response = NextResponse.redirect(iconUrls.faviconAssetUrl, 307); + + response.headers.set("Cache-Control", "no-store, max-age=0"); + + return response; + } + + return buildFallbackIconResponse(iconUrls.siteName, 180); +} diff --git a/app/favicon.ico b/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/app/favicon.ico and /dev/null differ diff --git a/app/favicon.ico/route.ts b/app/favicon.ico/route.ts new file mode 100644 index 0000000..a833a32 --- /dev/null +++ b/app/favicon.ico/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; + +import { buildFallbackIconResponse, getDynamicSiteIconUrls } from "@/lib/site-icons"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +export async function GET() { + const iconUrls = await getDynamicSiteIconUrls(); + + if (iconUrls.faviconAssetUrl) { + const response = NextResponse.redirect(iconUrls.faviconAssetUrl, 307); + + response.headers.set("Cache-Control", "no-store, max-age=0"); + + return response; + } + + return buildFallbackIconResponse(iconUrls.siteName, 32); +} diff --git a/app/layout.tsx b/app/layout.tsx index 3625f22..8a330b3 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,11 +1,17 @@ import type { Metadata } from "next"; +import { unstable_noStore as noStore } from "next/cache"; import { getLocale } from "next-intl/server"; import { ThemeProvider } from "@/components/theme-provider"; import { buildAppMetadata } from "@/lib/metadata"; import { getDirection } from "@/lib/locale"; import "./globals.css"; +export const dynamic = "force-dynamic"; +export const revalidate = 0; + export async function generateMetadata(): Promise { + noStore(); + return buildAppMetadata(); } @@ -14,6 +20,8 @@ export default async function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { + noStore(); + const locale = await getLocale().catch(() => "de"); return ( diff --git a/app/manifest.ts b/app/manifest.ts new file mode 100644 index 0000000..85f6668 --- /dev/null +++ b/app/manifest.ts @@ -0,0 +1,30 @@ +import type { MetadataRoute } from "next"; + +import { getDynamicSiteIconUrls } from "@/lib/site-icons"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +export default async function manifest(): Promise { + const iconUrls = await getDynamicSiteIconUrls(); + + return { + name: iconUrls.siteName, + short_name: iconUrls.siteName, + icons: [ + { + src: iconUrls.appleIconHref, + sizes: "180x180", + type: "image/png", + }, + { + src: iconUrls.faviconHref, + sizes: "32x32", + type: "image/x-icon", + }, + ], + display: "standalone", + background_color: "#ffffff", + theme_color: "#111827", + }; +} diff --git a/app/uploads/media/[...segments]/route.ts b/app/uploads/media/[...segments]/route.ts index 9da8ed6..a62225e 100644 --- a/app/uploads/media/[...segments]/route.ts +++ b/app/uploads/media/[...segments]/route.ts @@ -13,6 +13,7 @@ type MediaFileRouteProps = { export const dynamic = "force-dynamic"; const CONTENT_TYPES: Record = { + ".ico": "image/x-icon", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", diff --git a/lib/metadata.ts b/lib/metadata.ts index 0bd57d2..8c011cc 100644 --- a/lib/metadata.ts +++ b/lib/metadata.ts @@ -12,6 +12,7 @@ import { getSiteSettingsMediaBindings, } from "./app-config"; import { AppLocale, getLocalizedPath, resolveLocale } from "./locale"; +import { buildSiteIconUrls } from "./site-icons"; function getSiteUrl(): URL { return new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "https://mohfarawati.de"); @@ -69,19 +70,23 @@ export function buildAppMetadataFromConfig( ): Metadata { const defaultLocaleSettings = settings.locales[routing.defaultLocale]; const openGraphImages = buildMetadataImages(bindings.defaultOgImage?.url); + const siteIconUrls = buildSiteIconUrls({ + siteName: defaultLocaleSettings.siteName, + faviconAssetId: bindings.favicon?.assetId, + faviconUrl: bindings.favicon?.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, + manifest: siteIconUrls.manifestHref, + icons: { + icon: [{ url: siteIconUrls.faviconHref }], + shortcut: [{ url: siteIconUrls.faviconHref }], + apple: [{ url: siteIconUrls.appleIconHref }], + }, openGraph: { title: defaultLocaleSettings.siteName, description: defaultLocaleSettings.siteDescription, diff --git a/lib/site-icons.tsx b/lib/site-icons.tsx new file mode 100644 index 0000000..f2d454a --- /dev/null +++ b/lib/site-icons.tsx @@ -0,0 +1,102 @@ +import { ImageResponse } from "next/og"; + +import { routing } from "@/i18n/routing"; + +import { getSiteSettings, getSiteSettingsMediaBindings } from "./app-config"; + +const INTERNAL_FAVICON_PATH = "/favicon.ico"; +const INTERNAL_APPLE_ICON_PATH = "/apple-icon.png"; +const INTERNAL_MANIFEST_PATH = "/manifest.webmanifest"; +const DEFAULT_ICON_VERSION = "default"; + +type SiteIconInput = { + siteName: string; + faviconAssetId?: string | null; + faviconUrl?: string | null; +}; + +type SiteIconUrls = { + siteName: string; + version: string; + faviconHref: string; + appleIconHref: string; + manifestHref: string; + faviconAssetUrl: string | null; +}; + +function appendVersionToUrl(url: string, version: string): string { + const base = url.startsWith("http://") || url.startsWith("https://") ? undefined : "https://local.invalid"; + const resolved = new URL(url, base); + + resolved.searchParams.set("v", version); + + if (base) { + return `${resolved.pathname}${resolved.search}${resolved.hash}`; + } + + return resolved.toString(); +} + +function getFallbackLabel(siteName: string): string { + const firstCharacter = siteName.trim().charAt(0).toUpperCase() || "M"; + + return firstCharacter; +} + +export function buildSiteIconUrls(input: SiteIconInput): SiteIconUrls { + const siteName = input.siteName.trim() || "Moh"; + const version = input.faviconAssetId?.trim() || DEFAULT_ICON_VERSION; + + return { + siteName, + version, + faviconHref: appendVersionToUrl(INTERNAL_FAVICON_PATH, version), + appleIconHref: appendVersionToUrl(INTERNAL_APPLE_ICON_PATH, version), + manifestHref: appendVersionToUrl(INTERNAL_MANIFEST_PATH, version), + faviconAssetUrl: input.faviconUrl ? appendVersionToUrl(input.faviconUrl, version) : null, + }; +} + +export async function getDynamicSiteIconUrls(): Promise { + const [settings, bindings] = await Promise.all([getSiteSettings(), getSiteSettingsMediaBindings()]); + const defaultLocaleSettings = settings.locales[routing.defaultLocale]; + + return buildSiteIconUrls({ + siteName: defaultLocaleSettings.siteName, + faviconAssetId: bindings.favicon?.assetId, + faviconUrl: bindings.favicon?.url, + }); +} + +export function buildFallbackIconResponse(siteName: string, size: number) { + const label = getFallbackLabel(siteName); + + return new ImageResponse( + ( +
+ {label} +
+ ), + { + width: size, + height: size, + headers: { + "Cache-Control": "no-store, max-age=0", + }, + }, + ); +}