layout.tsx 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  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. // 정규 주소(canonical). './' 는 현재 경로로 해석되므로
  67. // 쿼리스트링이 붙은 주소가 별도 페이지로 색인되는 것을 막는다.
  68. metadataBase: new URL('https://antooza.com'),
  69. alternates: { canonical: './' },
  70. title: config?.basic?.siteName ?? '개미투자',
  71. description: config?.meta?.description ?? '',
  72. keywords: config?.meta?.keywords ?? '',
  73. authors: config?.meta?.author ? [{ name: config.meta.author }] : undefined,
  74. applicationName: config?.meta.applicationName,
  75. generator: config?.meta.generator,
  76. robots: config?.meta.robots,
  77. ...(metaAdds.themeColor.length > 0 && { themeColor: metaAdds.themeColor }),
  78. ...(metaAdds.colorScheme && { colorScheme: metaAdds.colorScheme }),
  79. other: {
  80. 'naver-site-verification': '18dc0d1c5cb466a765e5f5093f94638ed6537d1c',
  81. ...metaAdds.other,
  82. },
  83. };
  84. }
  85. export default async function RootLayout({
  86. children,
  87. }: Readonly<{
  88. children: React.ReactNode;
  89. }>) {
  90. const accessToken = await getAccessToken();
  91. const signalRChatUrl = await getSignalRChatUrl();
  92. const signalRAppUrl = await getSignalRAppUrl();
  93. const config = (await fetchConfig())?.data;
  94. const navMenuRes = await fetchNavMenu();
  95. const navMenu = navMenuRes?.data?.items ?? [];
  96. const configWithMenu = config ? { ...config, navMenu } : config;
  97. const initialMember = await getInitialMember();
  98. return (
  99. <html lang="ko" suppressHydrationWarning>
  100. <head>
  101. {/* 테마/밀도 FOUC 방지 — 첫 페인트 전에 html class/data 속성 확정 (기본 dark/normal, localStorage 영속) */}
  102. <script
  103. dangerouslySetInnerHTML={{
  104. __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';}})();"
  105. }}
  106. />
  107. <link rel="manifest" href="/manifest.json" />
  108. <Script src="https://www.googletagmanager.com/gtag/js?id=G-DY6YFW4CTM" strategy="afterInteractive" />
  109. <Script id="gtag-init" strategy="afterInteractive">
  110. {`
  111. window.dataLayer = window.dataLayer || [];
  112. function gtag(){dataLayer.push(arguments);}
  113. gtag('js', new Date());
  114. gtag('config', 'G-DY6YFW4CTM');
  115. `}
  116. </Script>
  117. </head>
  118. <body className={`${geistSans.variable} ${geistMono.variable} ${archivoBlack.variable} antialiased`}>
  119. <ThemeProvider>
  120. <SignalRProvider accessToken={accessToken} signalRChatUrl={signalRChatUrl} signalRAppUrl={signalRAppUrl}>
  121. <AuthProvider initialAuthenticated={!!initialMember}>
  122. <MemberProvider initialMember={initialMember}>
  123. <ConfigProvider initialConfig={configWithMenu}>
  124. <CartProvider>
  125. {children}
  126. </CartProvider>
  127. </ConfigProvider>
  128. </MemberProvider>
  129. </AuthProvider>
  130. </SignalRProvider>
  131. <Toaster />
  132. </ThemeProvider>
  133. </body>
  134. </html>
  135. );
  136. }