layout.tsx 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. import type { Metadata } from "next";
  2. import { Geist, Geist_Mono, Archivo_Black } from "next/font/google";
  3. import Script from "next/script";
  4. import React from "react";
  5. import "./globals.scss";
  6. import { SignalRProvider } from '@/contexts/signalrProvider';
  7. import { AuthProvider } from "@/contexts/authProvider";
  8. import { MemberProvider } from "@/contexts/memberProvider";
  9. import { ConfigProvider } from "@/contexts/configProvider";
  10. import { CartProvider } from "@/contexts/cartProvider";
  11. import { ThemeProvider } from "@/contexts/themeProvider";
  12. import { Toaster } from "@/components/ui/sonner";
  13. import { getAccessToken, getSignalRChatUrl, getSignalRAppUrl } from "@/lib/utils/server";
  14. import { fetchConfig, fetchNavMenu } from "@/lib/api/system";
  15. import { getInitialMember } from "@/lib/api/auth";
  16. const geistSans = Geist({
  17. variable: "--font-geist-sans",
  18. subsets: ["latin"],
  19. });
  20. const geistMono = Geist_Mono({
  21. variable: "--font-geist-mono",
  22. subsets: ["latin"],
  23. });
  24. const archivoBlack = Archivo_Black({
  25. variable: "--font-brand",
  26. subsets: ["latin"],
  27. weight: "400",
  28. display: "swap",
  29. });
  30. type ColorSchemeEnum = 'normal' | 'light' | 'dark' | 'light dark' | 'dark light' | 'only light';
  31. function parseMetaAdds(html: string | null | undefined)
  32. {
  33. if (!html) return { other: {} as Record<string, string>, themeColor: [] as { media?: string; color: string }[], colorScheme: undefined as ColorSchemeEnum | undefined };
  34. const other: Record<string, string> = {};
  35. const themeColor: { media?: string; color: string }[] = [];
  36. const validColorSchemes: ColorSchemeEnum[] = ['normal', 'light', 'dark', 'light dark', 'dark light', 'only light'];
  37. let colorScheme: ColorSchemeEnum | undefined;
  38. const tagRegex = /<meta\s+([^>]+)\/?>/gi;
  39. const attrRegex = /(\w[\w-]*)=["']([^"']*?)["']/gi;
  40. let tagMatch;
  41. while ((tagMatch = tagRegex.exec(html)) !== null) {
  42. const attrs: Record<string, string> = {};
  43. let attrMatch;
  44. while ((attrMatch = attrRegex.exec(tagMatch[1])) !== null) {
  45. attrs[attrMatch[1].toLowerCase()] = attrMatch[2];
  46. }
  47. const name = attrs['name'];
  48. const content = attrs['content'];
  49. if (!name || !content) continue;
  50. if (name === 'theme-color') {
  51. themeColor.push(attrs['media'] ? { media: attrs['media'], color: content } : { color: content });
  52. } else if (name === 'color-scheme') {
  53. if (validColorSchemes.includes(content as ColorSchemeEnum)) {
  54. colorScheme = content as ColorSchemeEnum;
  55. }
  56. } else {
  57. other[name] = content;
  58. }
  59. }
  60. return { other, themeColor, colorScheme };
  61. }
  62. export async function generateMetadata(): Promise<Metadata> {
  63. const config = (await fetchConfig())?.data;
  64. const metaAdds = parseMetaAdds(config?.meta?.adds);
  65. return {
  66. title: config?.basic?.siteName ?? '개미투자',
  67. description: config?.meta?.description ?? '',
  68. keywords: config?.meta?.keywords ?? '',
  69. authors: config?.meta?.author ? [{ name: config.meta.author }] : undefined,
  70. applicationName: config?.meta.applicationName,
  71. generator: config?.meta.generator,
  72. robots: config?.meta.robots,
  73. ...(metaAdds.themeColor.length > 0 && { themeColor: metaAdds.themeColor }),
  74. ...(metaAdds.colorScheme && { colorScheme: metaAdds.colorScheme }),
  75. other: {
  76. 'naver-site-verification': '18dc0d1c5cb466a765e5f5093f94638ed6537d1c',
  77. ...metaAdds.other,
  78. },
  79. };
  80. }
  81. export default async function RootLayout({
  82. children,
  83. }: Readonly<{
  84. children: React.ReactNode;
  85. }>) {
  86. const accessToken = await getAccessToken();
  87. const signalRChatUrl = await getSignalRChatUrl();
  88. const signalRAppUrl = await getSignalRAppUrl();
  89. const config = (await fetchConfig())?.data;
  90. const navMenuRes = await fetchNavMenu();
  91. const navMenu = navMenuRes?.data?.items ?? [];
  92. const configWithMenu = config ? { ...config, navMenu } : config;
  93. const initialMember = await getInitialMember();
  94. return (
  95. <html lang="ko" suppressHydrationWarning>
  96. <head>
  97. {/* 테마/밀도 FOUC 방지 — 첫 페인트 전에 html class/data 속성 확정 (기본 dark/normal, localStorage 영속) */}
  98. <script
  99. dangerouslySetInnerHTML={{
  100. __html: "(function(){var e=document.documentElement;try{var t=localStorage.getItem('az-theme')||localStorage.getItem('bitforum-theme');t=t==='light'?'light':'dark';var d=localStorage.getItem('az-density');if(d!=='compact'&&d!=='expanded'){d='normal';}e.classList.add(t);e.dataset.theme=t;e.dataset.density=d;}catch(r){e.classList.add('dark');e.dataset.theme='dark';e.dataset.density='normal';}})();"
  101. }}
  102. />
  103. <link rel="manifest" href="/manifest.json" />
  104. <Script src="https://www.googletagmanager.com/gtag/js?id=G-DY6YFW4CTM" strategy="afterInteractive" />
  105. <Script id="gtag-init" strategy="afterInteractive">
  106. {`
  107. window.dataLayer = window.dataLayer || [];
  108. function gtag(){dataLayer.push(arguments);}
  109. gtag('js', new Date());
  110. gtag('config', 'G-DY6YFW4CTM');
  111. `}
  112. </Script>
  113. </head>
  114. <body className={`${geistSans.variable} ${geistMono.variable} ${archivoBlack.variable} antialiased`}>
  115. <ThemeProvider>
  116. <SignalRProvider accessToken={accessToken} signalRChatUrl={signalRChatUrl} signalRAppUrl={signalRAppUrl}>
  117. <AuthProvider initialAuthenticated={!!initialMember}>
  118. <MemberProvider initialMember={initialMember}>
  119. <ConfigProvider initialConfig={configWithMenu}>
  120. <CartProvider>
  121. {children}
  122. </CartProvider>
  123. </ConfigProvider>
  124. </MemberProvider>
  125. </AuthProvider>
  126. </SignalRProvider>
  127. <Toaster />
  128. </ThemeProvider>
  129. </body>
  130. </html>
  131. );
  132. }