diff --git a/app/api/favicon/route.ts b/app/api/favicon/route.ts index 123858ca4..a3bad1ca2 100644 --- a/app/api/favicon/route.ts +++ b/app/api/favicon/route.ts @@ -8,6 +8,7 @@ export const dynamic = "force-dynamic"; export const revalidate = 0; const DEFAULT_CONTENT_TYPE = "image/x-icon"; +const DARK_FAVICON_CONTENT_TYPE = "image/png"; const FETCH_TIMEOUT_MS = 10_000; function noStoreHeaders(contentType: string) { @@ -56,27 +57,103 @@ function resolveFaviconUrl(rawUrl: string, requestUrl: string) { } } -async function readLocalFallbackFavicon() { - const file = await readFile(join(process.cwd(), "public", "default-favicon.ico")); +async function readLocalFallbackFavicon(preferDark = false) { + const file = await readFile( + join(process.cwd(), "public", "default-favicon.ico") + ); + if (preferDark) { + const transformed = await transformFaviconForDark(file).catch(() => null); + const fallback = + transformed || (await renderDarkFallbackFavicon().catch(() => null)); + if (fallback) { + return new NextResponse(fallback, { + status: 200, + headers: noStoreHeaders(DARK_FAVICON_CONTENT_TYPE), + }); + } + } return new NextResponse(file, { status: 200, headers: noStoreHeaders(DEFAULT_CONTENT_TYPE), }); } +async function renderDarkFallbackFavicon() { + const sharp = (await import("sharp")).default; + const svg = `\ + + + + +`; + return await sharp(Buffer.from(svg)).png().toBuffer(); +} + +async function transformFaviconForDark(buffer: Buffer | ArrayBuffer) { + const sharp = (await import("sharp")).default; + const inputBuffer = Buffer.isBuffer(buffer) + ? buffer + : Buffer.from(new Uint8Array(buffer)); + const createBaseImage = () => + sharp(inputBuffer, { + animated: false, + failOn: "none", + }).resize({ + width: 64, + height: 64, + fit: "contain", + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }); + + const { data, info } = await createBaseImage() + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + let visiblePixelCount = 0; + let luminanceTotal = 0; + + for (let index = 0; index < data.length; index += info.channels) { + const alpha = data[index + 3] ?? 255; + if (alpha < 24) continue; + + const red = data[index] ?? 0; + const green = data[index + 1] ?? 0; + const blue = data[index + 2] ?? 0; + luminanceTotal += 0.2126 * red + 0.7152 * green + 0.0722 * blue; + visiblePixelCount += 1; + } + + const averageLuminance = + visiblePixelCount > 0 ? luminanceTotal / visiblePixelCount : 0; + const needsInvertForDark = averageLuminance < 150; + const output = createBaseImage(); + + if (needsInvertForDark) { + output.negate({ alpha: false }); + } + + return await output + .png() + .toBuffer(); +} + export async function GET(request: Request) { try { + const requestUrl = new URL(request.url); + const preferDark = requestUrl.searchParams.get("theme") === "dark"; const settings = await getSiteSettings(); - const rawFaviconUrl = String(settings?.favicon || "").trim(); + const rawFaviconUrl = String( + (preferDark && settings?.siteLogoDark) || settings?.favicon || "" + ).trim(); if (!rawFaviconUrl) { - return await readLocalFallbackFavicon(); + return await readLocalFallbackFavicon(preferDark); } const faviconUrl = resolveFaviconUrl(rawFaviconUrl, request.url); if (!faviconUrl) { console.error("[favicon] Invalid favicon URL:", rawFaviconUrl); - return await readLocalFallbackFavicon(); + return await readLocalFallbackFavicon(preferDark); } const targetUrl = `${faviconUrl}${faviconUrl.includes("?") ? "&" : "?"}v=${Date.now()}`; @@ -104,19 +181,35 @@ export async function GET(request: Request) { status: upstream.status, statusText: upstream.statusText, }); - return await readLocalFallbackFavicon(); + return await readLocalFallbackFavicon(preferDark); } const buffer = await upstream.arrayBuffer(); const contentType = upstream.headers.get("content-type") || inferContentType(faviconUrl); + if (preferDark) { + const transformed = await transformFaviconForDark(buffer).catch((error) => { + console.error("[favicon] Failed to transform dark favicon:", error); + return null; + }); + const darkFavicon = + transformed || (await renderDarkFallbackFavicon().catch(() => null)); + if (darkFavicon) { + return new NextResponse(darkFavicon, { + status: 200, + headers: noStoreHeaders(DARK_FAVICON_CONTENT_TYPE), + }); + } + } + return new NextResponse(buffer, { status: 200, headers: noStoreHeaders(contentType), }); } catch (error) { console.error("[favicon] Failed to proxy favicon:", error); - return await readLocalFallbackFavicon(); + const preferDark = new URL(request.url).searchParams.get("theme") === "dark"; + return await readLocalFallbackFavicon(preferDark); } } diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx index 50fd0b668..c2d0ed6f7 100644 --- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -9034,6 +9034,22 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) { const resolvedAgentProjectTitle = hasActiveAgentProject ? String(agentProjectTitle || "").trim() || "未命名生图项目" : "未选择项目"; + const isDarkCanvasTheme = resolvedTheme === "dark"; + const lightCanvasBrandLogo = String(siteSettingsData?.siteLogo || "").trim(); + const darkCanvasBrandLogo = String(siteSettingsData?.siteLogoDark || "").trim(); + const hasSeparateDarkCanvasBrandLogo = + Boolean(darkCanvasBrandLogo) && darkCanvasBrandLogo !== lightCanvasBrandLogo; + const canvasBrandLogo = String( + isDarkCanvasTheme + ? hasSeparateDarkCanvasBrandLogo + ? darkCanvasBrandLogo + : lightCanvasBrandLogo || darkCanvasBrandLogo + : lightCanvasBrandLogo || darkCanvasBrandLogo + ).trim(); + const shouldInvertCanvasBrandLogo = + isDarkCanvasTheme && + Boolean(canvasBrandLogo) && + !hasSeparateDarkCanvasBrandLogo; const brandEntry = (
- {siteSettingsData?.siteLogo || siteSettingsData?.siteLogoDark ? ( + {canvasBrandLogo ? ( {siteSettingsData?.siteName ) : ( diff --git a/app/layout.tsx b/app/layout.tsx index 0a774cc8c..f26b5bee7 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -24,7 +24,18 @@ export async function generateMetadata(): Promise { "AI design workspace for Canvas, Agent workflows, image editing, 3D/CAD, and local AI operations.", icons: { icon: [ - { url: "/api/favicon", sizes: "any" }, + { + url: "/api/favicon?theme=dark", + media: "(prefers-color-scheme: dark)", + sizes: "any", + type: "image/png", + }, + { + url: "/api/favicon", + media: "(prefers-color-scheme: light)", + sizes: "any", + }, + { url: "/api/favicon?theme=dark", sizes: "any", type: "image/png" }, { url: "/api/favicon", type: "image/x-icon" }, ], apple: "/api/favicon", diff --git a/components/canvas/canvas-logo-loading.tsx b/components/canvas/canvas-logo-loading.tsx index 73f273231..531fd401f 100644 --- a/components/canvas/canvas-logo-loading.tsx +++ b/components/canvas/canvas-logo-loading.tsx @@ -33,7 +33,9 @@ export function CanvasLogoLoadingView({ alt={resolvedSiteName} className={cn( "h-[34px] w-[34px] object-contain", - hasSeparateDarkLogo && "dark:hidden" + hasSeparateDarkLogo + ? "dark:hidden" + : "dark:brightness-0 dark:invert" )} src={lightLogo} /> diff --git a/components/favicon-updater.tsx b/components/favicon-updater.tsx index 047657792..9c41c507b 100644 --- a/components/favicon-updater.tsx +++ b/components/favicon-updater.tsx @@ -5,18 +5,26 @@ import { useEffect } from "react"; export function FaviconUpdater() { useEffect(() => { let isDisposed = false; + const mediaQuery = window.matchMedia?.("(prefers-color-scheme: dark)"); - const existingIcon = - document.querySelector('link[rel="icon"]')?.href || - document.querySelector('link[rel="shortcut icon"]')?.href || - document.querySelector('link[rel="apple-touch-icon"]')?.href; - const urlWithCache = existingIcon || "/api/favicon"; + const getFaviconUrl = () => { + const html = document.documentElement; + const hasDarkClass = html.classList.contains("dark"); + const hasLightClass = html.classList.contains("light"); + const prefersDark = Boolean(mediaQuery?.matches); + const isDark = hasDarkClass || (!hasLightClass && prefersDark); + return isDark ? "/api/favicon?theme=dark" : "/api/favicon"; + }; - Promise.resolve().then(() => { - if (isDisposed) { - return; - } + const updateFavicon = () => { + if (isDisposed) return; + const urlWithTheme = getFaviconUrl(); + const iconType = urlWithTheme.includes("theme=dark") + ? "image/png" + : "image/x-icon"; + const href = + `${urlWithTheme}${urlWithTheme.includes("?") ? "&" : "?"}v=${Date.now()}`; const managedSelector = 'link[rel="icon"],link[rel="shortcut icon"],link[rel="apple-touch-icon"]'; const existingLinks = document.querySelectorAll( @@ -24,10 +32,10 @@ export function FaviconUpdater() { ); existingLinks.forEach((link) => { - link.href = urlWithCache; + link.href = href; link.setAttribute("data-managed-favicon", "true"); - if (link.rel === "icon") { - link.type = "image/x-icon"; + if (link.rel === "icon" || link.rel === "shortcut icon") { + link.type = iconType; link.setAttribute("sizes", "any"); } }); @@ -41,7 +49,7 @@ export function FaviconUpdater() { link.rel = rel; document.head.appendChild(link); } - link.href = urlWithCache; + link.href = href; link.setAttribute("data-managed-favicon", "true"); if (type) { link.type = type; @@ -51,13 +59,26 @@ export function FaviconUpdater() { } }; - ensureLink("icon", "image/x-icon"); + ensureLink("icon", iconType); ensureLink("apple-touch-icon"); - ensureLink("shortcut icon"); + ensureLink("shortcut icon", iconType); + }; + + const observer = new MutationObserver(updateFavicon); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + mediaQuery?.addEventListener?.("change", updateFavicon); + + Promise.resolve().then(() => { + updateFavicon(); }); return () => { isDisposed = true; + observer.disconnect(); + mediaQuery?.removeEventListener?.("change", updateFavicon); }; }, []);