219 lines
6.6 KiB
TypeScript
219 lines
6.6 KiB
TypeScript
import type { Metadata } from "next";
|
||
import { Toaster } from "sonner";
|
||
import { ThemeProvider } from "@/components/theme-provider";
|
||
import { FaviconUpdater } from "@/components/favicon-updater";
|
||
import { ImageDeliveryOptimizer } from "@/components/image-delivery-optimizer";
|
||
import { getSiteSettings } from "@/lib/db/queries";
|
||
|
||
import "./globals.css";
|
||
import { SessionProvider } from "next-auth/react";
|
||
|
||
const metadataBase = resolveMetadataBase();
|
||
|
||
export async function generateMetadata(): Promise<Metadata> {
|
||
const settings = await getSiteSettings().catch(() => null);
|
||
const siteName = String(settings?.siteName || "").trim() || "AI Chatbot";
|
||
|
||
return {
|
||
metadataBase,
|
||
title: {
|
||
template: `%s | ${siteName}`,
|
||
default: siteName,
|
||
},
|
||
description:
|
||
"AI design workspace for Canvas, Agent workflows, image editing, 3D/CAD, and local AI operations.",
|
||
icons: {
|
||
icon: [
|
||
{
|
||
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",
|
||
shortcut: "/api/favicon",
|
||
},
|
||
};
|
||
}
|
||
|
||
function resolveMetadataBase() {
|
||
const candidate =
|
||
process.env.NEXT_PUBLIC_APP_URL || process.env.AUTH_URL || process.env.NEXTAUTH_URL;
|
||
|
||
if (candidate) {
|
||
try {
|
||
return new URL(candidate);
|
||
} catch {
|
||
// Keep metadata generation resilient when local env files contain placeholders.
|
||
}
|
||
}
|
||
|
||
return new URL("http://localhost:3000");
|
||
}
|
||
|
||
export const viewport = {
|
||
maximumScale: 1, // Disable auto-zoom on mobile Safari
|
||
};
|
||
|
||
const LIGHT_THEME_COLOR = "hsl(0 0% 100%)";
|
||
const DARK_THEME_COLOR = "hsl(240deg 10% 3.92%)";
|
||
const THEME_COLOR_SCRIPT = `\
|
||
(function() {
|
||
var html = document.documentElement;
|
||
var meta = document.querySelector('meta[name="theme-color"]');
|
||
if (!meta) {
|
||
meta = document.createElement('meta');
|
||
meta.setAttribute('name', 'theme-color');
|
||
document.head.appendChild(meta);
|
||
}
|
||
function updateThemeColor() {
|
||
var isDark = html.classList.contains('dark');
|
||
meta.setAttribute('content', isDark ? '${DARK_THEME_COLOR}' : '${LIGHT_THEME_COLOR}');
|
||
}
|
||
var observer = new MutationObserver(updateThemeColor);
|
||
observer.observe(html, { attributes: true, attributeFilter: ['class'] });
|
||
updateThemeColor();
|
||
})();`;
|
||
|
||
const CHUNK_ERROR_RECOVERY_SCRIPT = `\
|
||
(function() {
|
||
// 安全地访问sessionStorage(某些浏览器设置可能禁止访问)
|
||
function safeGetSessionStorage(key) {
|
||
try {
|
||
return sessionStorage.getItem(key);
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function safeSetSessionStorage(key, value) {
|
||
try {
|
||
sessionStorage.setItem(key, value);
|
||
return true;
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function safeRemoveSessionStorage(key) {
|
||
try {
|
||
sessionStorage.removeItem(key);
|
||
return true;
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
var RECOVERY_KEY = 'chunk-reload-attempted-v2';
|
||
var lastReloadAt = Number(safeGetSessionStorage(RECOVERY_KEY) || '0') || 0;
|
||
|
||
function shouldAutoRecover(message) {
|
||
if (!message) return false;
|
||
var normalized = String(message).toLowerCase();
|
||
return (
|
||
normalized.indexOf('chunkloaderror') !== -1 ||
|
||
normalized.indexOf('failed to load chunk') !== -1 ||
|
||
normalized.indexOf('loading chunk') !== -1 ||
|
||
normalized.indexOf('failed to fetch dynamically imported module') !== -1 ||
|
||
normalized.indexOf('error loading dynamically imported module') !== -1 ||
|
||
normalized.indexOf('importing a module script failed') !== -1 ||
|
||
normalized.indexOf('module script load failed') !== -1 ||
|
||
normalized.indexOf('/_next/static/chunks/') !== -1 ||
|
||
normalized.indexOf('failed to find server action') !== -1 ||
|
||
normalized.indexOf('server components render') !== -1 ||
|
||
normalized.indexOf('application error: a client-side exception') !== -1
|
||
);
|
||
}
|
||
|
||
function triggerAutoRecover(reason) {
|
||
var now = Date.now();
|
||
if (lastReloadAt && now - lastReloadAt < 30000) return;
|
||
lastReloadAt = now;
|
||
console.warn('[RuntimeRecover] 检测到前端运行时异常,自动刷新页面...', reason || '');
|
||
safeSetSessionStorage(RECOVERY_KEY, String(now));
|
||
setTimeout(function() {
|
||
window.location.reload();
|
||
}, 800);
|
||
}
|
||
|
||
window.addEventListener('error', function(event) {
|
||
var message = '';
|
||
if (event && event.message) message += event.message;
|
||
if (event && event.filename) message += ' ' + event.filename;
|
||
if (event && event.error && event.error.message) {
|
||
message += ' ' + event.error.message;
|
||
}
|
||
if (shouldAutoRecover(message)) {
|
||
triggerAutoRecover(message);
|
||
}
|
||
}, true);
|
||
|
||
window.addEventListener('unhandledrejection', function(event) {
|
||
var reason = event && event.reason;
|
||
var reasonText = '';
|
||
if (typeof reason === 'string') reasonText = reason;
|
||
else if (reason && typeof reason.message === 'string') reasonText = reason.message;
|
||
if (shouldAutoRecover(reasonText)) {
|
||
triggerAutoRecover(reasonText);
|
||
}
|
||
});
|
||
|
||
// 页面稳定一段时间后清除标记,避免旧资源异常无限刷新。
|
||
window.addEventListener('load', function() {
|
||
setTimeout(function() {
|
||
safeRemoveSessionStorage(RECOVERY_KEY);
|
||
}, 120000);
|
||
});
|
||
})();`;
|
||
|
||
export default function RootLayout({
|
||
children,
|
||
}: Readonly<{
|
||
children: React.ReactNode;
|
||
}>) {
|
||
return (
|
||
<html
|
||
// `next-themes` injects an extra classname to the body element to avoid
|
||
// visual flicker before hydration. Hence the `suppressHydrationWarning`
|
||
// prop is necessary to avoid the React hydration mismatch warning.
|
||
// https://github.com/pacocoursey/next-themes?tab=readme-ov-file#with-app
|
||
lang="zh-CN"
|
||
suppressHydrationWarning
|
||
>
|
||
<head>
|
||
<script
|
||
dangerouslySetInnerHTML={{
|
||
__html: THEME_COLOR_SCRIPT,
|
||
}}
|
||
/>
|
||
<script
|
||
dangerouslySetInnerHTML={{
|
||
__html: CHUNK_ERROR_RECOVERY_SCRIPT,
|
||
}}
|
||
/>
|
||
</head>
|
||
<body className="antialiased">
|
||
<FaviconUpdater />
|
||
<ImageDeliveryOptimizer />
|
||
<ThemeProvider
|
||
attribute="class"
|
||
defaultTheme="system"
|
||
disableTransitionOnChange
|
||
enableSystem
|
||
>
|
||
<Toaster position="top-center" />
|
||
<SessionProvider>{children}</SessionProvider>
|
||
</ThemeProvider>
|
||
</body>
|
||
</html>
|
||
);
|
||
}
|