File name
Commit message
Commit date
File name
Commit message
Commit date
import type { Metadata } from "next";
import { Geist_Mono } from "next/font/google";
import localFont from "next/font/local";
import { ViewTransition } from "react";
import { FeedbackHost } from "@/app/_components/feedback-host";
import { FeedbackProvider } from "@/app/_components/feedback-provider";
import { SplashGate } from "@/app/_components/splash-gate";
import { SPLASH_SHOWN_STORAGE_KEY } from "@/lib/constants/splash";
import {
THEME_ATTRIBUTE_NAME,
THEME_STORAGE_KEY,
} from "@/lib/constants/theme";
import "./globals.css";
// 첫 페인트 전 테마 적용 — <head>는 <body>보다 먼저 파싱되므로 <html>만 조작 가능하다
// (preventing-flash-before-hydration.md의 Themes 패턴). localStorage에 명시 선택("light"
// /"dark")이 저장돼 있으면 첫 페인트 전에 <html data-theme>을 부여해 FOUC를 막는다. 저장값이
// 없거나("system") 손상된 값이면 속성을 부여하지 않는다 — globals.css의
// `prefers-color-scheme` 미디어 쿼리가 그대로 OS 설정을 따르는 fail-safe 경로다.
//
// THEME_STORAGE_KEY/THEME_ATTRIBUTE_NAME은 반드시 'use client' 파일이 아닌
// lib/constants/theme에서 import한다 — 'use client' 모듈의 일반 값(문자열) export를
// Server Component가 직접 import하면 클라이언트 레퍼런스 프록시 특성상 undefined로 평가된다
// (컴포넌트 export만 정상적으로 프록시된다. splash 상수와 동일한 이유로 분리됨).
const THEME_BOOTSTRAP_SCRIPT = `(function(){try{var t=localStorage.getItem(${JSON.stringify(
THEME_STORAGE_KEY
)});if(t==="light"||t==="dark")document.documentElement.setAttribute(${JSON.stringify(
THEME_ATTRIBUTE_NAME
)},t)}catch(e){}})()`;
// 스플래시 재표시 방지 — <head>는 <body>보다 먼저 파싱되므로 body 하위 요소는 아직 존재하지
// 않는다(preventing-flash-before-hydration.md의 Themes 패턴과 동일하게 <html>만 조작 가능).
// 이번 세션에 이미 표시했다면 첫 페인트 전에 <html>에 data attribute를 부여하고, 아래 스타일
// 규칙이 SplashGate 루트를 즉시 숨긴다 — useEffect보다 먼저 실행되어 깜빡임이 없다.
//
// SPLASH_SHOWN_STORAGE_KEY는 반드시 'use client' 파일이 아닌 lib/constants/splash에서
// import한다 — splash-gate.tsx('use client')의 일반 값(문자열) export를 Server Component가
// 직접 import하면 클라이언트 레퍼런스 프록시 특성상 undefined로 평가된다(컴포넌트 export만
// 정상적으로 프록시된다).
const SPLASH_HIDE_STYLE = `html[data-splash-shown] [data-splash-gate]{display:none}`;
const SPLASH_GATE_SCRIPT = `(function(){try{if(sessionStorage.getItem(${JSON.stringify(
SPLASH_SHOWN_STORAGE_KEY
)}))document.documentElement.setAttribute("data-splash-shown","")}catch(e){}})()`;
// 어드민 전체 기본 서체(edupay-app 일체화, design 전달 사양) — Geist는 대체되어 제거했다.
// globals.css `--font-sans`가 이 변수를 우선 참조하고, 로드 전에도 system-ui로 안전하게
// 폴백하도록 이미 구성돼 있다.
const wantedSans = localFont({
src: [
{
path: "../public/fonts/wanted-sans-regular.ttf",
weight: "400",
style: "normal",
},
{
path: "../public/fonts/wanted-sans-medium.ttf",
weight: "500",
style: "normal",
},
{
path: "../public/fonts/wanted-sans-semibold.ttf",
weight: "600",
style: "normal",
},
{
path: "../public/fonts/wanted-sans-bold.ttf",
weight: "700",
style: "normal",
},
],
variable: "--font-wanted-sans",
display: "swap",
});
// 코드/숫자 표기용 고정폭 서체 — globals.css `--font-mono`가 여전히 이 변수를 직접 참조하므로
// (폴백 체인 없음) 유지한다.
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
// 관리자 사이트 전체(로그인 포함)의 검색엔진 색인을 차단한다 — 하위 라우트는 별도 설정이
// 없는 한 이 값을 그대로 상속한다. robots.txt(app/robots.ts), next.config.ts의
// X-Robots-Tag 헤더와 함께 SEO 차단 3층 구조를 이룬다.
export const metadata: Metadata = {
title: "에듀페이 관리자",
robots: {
index: false,
follow: false,
nocache: true,
googleBot: {
index: false,
follow: false,
noimageindex: true,
"max-image-preview": "none",
"max-snippet": 0,
"max-video-preview": 0,
},
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="ko"
suppressHydrationWarning
className={`${wantedSans.variable} ${geistMono.variable} h-full antialiased`}
>
<head>
<script dangerouslySetInnerHTML={{ __html: THEME_BOOTSTRAP_SCRIPT }} />
<style dangerouslySetInnerHTML={{ __html: SPLASH_HIDE_STYLE }} />
<script dangerouslySetInnerHTML={{ __html: SPLASH_GATE_SCRIPT }} />
</head>
<body className="min-h-full flex flex-col">
<SplashGate />
<FeedbackProvider>
{/* 라우트 그룹 간 이동을 포함한 모든 전환에 크로스페이드를 적용한다. 클래스명(layout-transition)에
대한 `::view-transition-old/new` 규칙은 design 레인이 app/globals.css에 제공한다. */}
<ViewTransition default="layout-transition">{children}</ViewTransition>
{/* 피드백 오버레이는 전환 크로스페이드 대상이 아니므로 ViewTransition 바깥에 둔다. */}
<FeedbackHost />
</FeedbackProvider>
</body>
</html>
);
}