/* ============================================================================= * app_shell.ts * 공통 앱 셸 — 헤더(로고/네비/언어·테마/로그인) + 콘텐츠 아울렛 + 푸터 * * - 모든 문구는 ui_template_locale.ts 참조 (하드코딩 금지). * - 색상/간격은 theme.css 변수만 사용. * - 셸 자체 스타일은 injectShellStyles()로 1회 주입. * ========================================================================== */ import { ui_locales, currentLanguageIndex, setLanguage } from "@ui/ui_template_locale"; import { createButton } from "@ui/ui_template_elements"; import { THEME_STORAGE_KEY, LANG_STORAGE_KEY, ROUTES } from "@config/config_frontend"; import { navigateTo, isAuthenticated } from "./router"; const BRAND_ICON_URL = new URL("../resources/prog_icon.jpg", import.meta.url).href; /** locale 헬퍼: 현재 언어 인덱스로 문구 반환 */ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } /* ----------------------------------------------------------------------------- * 테마 / 언어 초기화 (localStorage 복원) * -------------------------------------------------------------------------- */ export function initThemeAndLang(): void { const savedTheme = localStorage.getItem(THEME_STORAGE_KEY); if (savedTheme === "dark" || savedTheme === "light") { document.documentElement.setAttribute("data-theme", savedTheme); } const savedLang = localStorage.getItem(LANG_STORAGE_KEY); if (savedLang === "ko" || savedLang === "en") { setLanguage(savedLang); } } function onApp_Theme_Toggle_Click(): void { const current = document.documentElement.getAttribute("data-theme") ?? "light"; const next = current === "light" ? "dark" : "light"; document.documentElement.setAttribute("data-theme", next); localStorage.setItem(THEME_STORAGE_KEY, next); } function onApp_Lang_Toggle_Click(): void { const next = currentLanguageIndex === 0 ? "en" : "ko"; setLanguage(next); localStorage.setItem(LANG_STORAGE_KEY, next); // 언어 변경 후 현재 페이지 다시 렌더 (hashchange 강제 트리거) window.dispatchEvent(new HashChangeEvent("hashchange")); } async function onApp_Logout_Click(): Promise { const { logout } = await import("../A06_Login/A06_Login_Api_Fetch"); try { await logout(); } catch { // 세션이 이미 만료된 경우 등은 무시하고 홈으로 이동 } navigateTo(ROUTES.A01_HOME); } /* ----------------------------------------------------------------------------- * 헤더 구성 * -------------------------------------------------------------------------- */ function buildHeader(): HTMLElement { const header = document.createElement("header"); header.className = "app-header"; // 로고 (클릭 시 홈) const brand = document.createElement("button"); brand.className = "app-brand"; brand.type = "button"; const brandIcon = document.createElement("img"); brandIcon.className = "app-brand__icon"; brandIcon.src = BRAND_ICON_URL; brandIcon.alt = ""; const brandName = document.createElement("span"); brandName.className = "app-brand__name"; brandName.textContent = L("App_BrandName"); brand.append(brandIcon, brandName); brand.addEventListener("click", () => navigateTo(ROUTES.A01_HOME)); // 네비게이션 링크 const nav = document.createElement("nav"); nav.className = "app-nav"; const navItems: [keyof typeof ui_locales, string][] = [ ["App_Nav_Program", ROUTES.A02_PROG_DETAIL], ["App_Nav_Company", ROUTES.A03_COMP_DETAIL], ["App_Nav_News", ROUTES.A04_NEWS_HISTORY], ["App_Nav_Education", ROUTES.A05_EDU_DETAIL], ["App_Nav_Support", ROUTES.A08_SUPPORT], ]; for (const [labelKey, route] of navItems) { const link = createButton({ label: L(labelKey), variant: "pill", onClick: () => navigateTo(route as never), }); nav.append(link); } // 우측 액션 (언어/테마/로그인) const actions = document.createElement("div"); actions.className = "app-actions"; const langBtn = createButton({ label: currentLanguageIndex === 0 ? "EN" : "한", variant: "pill", onClick: onApp_Lang_Toggle_Click, }); const themeBtn = createButton({ label: document.documentElement.getAttribute("data-theme") === "dark" ? "☀" : "☾", variant: "pill", onClick: onApp_Theme_Toggle_Click, }); actions.append(langBtn, themeBtn); // 인증 버튼은 서버 세션 확인(비동기) 후 채운다. const authSlot = document.createElement("div"); authSlot.className = "app-actions__auth"; actions.append(authSlot); void fillAuthActions(authSlot); header.append(brand, nav, actions); return header; } /** 서버 세션 상태에 따라 로그인/로그아웃 버튼을 채운다. */ async function fillAuthActions(slot: HTMLElement): Promise { try { const loggedIn = await isAuthenticated(); slot.innerHTML = ""; if (loggedIn) { const { fetchSessionUser } = await import("../A06_Login/A06_Login_Api_Fetch"); const user = await fetchSessionUser(); slot.append( createButton({ label: user?.name?.trim() || L("Nav_MyAccount"), variant: "ghost", onClick: () => navigateTo(ROUTES.B01_ACCOUNT), }), createButton({ label: L("Nav_Logout"), variant: "pill", onClick: onApp_Logout_Click, }), ); } else { slot.append( createButton({ label: L("Nav_Login"), variant: "ghost", onClick: () => navigateTo(ROUTES.A06_LOGIN), }), createButton({ label: L("Nav_Register"), variant: "filled", onClick: () => navigateTo(ROUTES.A07_REGISTER), }), ); } } catch (error) { console.error("[app_shell] fillAuthActions error:", error); slot.innerHTML = ""; slot.append( createButton({ label: L("Nav_Login"), variant: "ghost", onClick: () => navigateTo(ROUTES.A06_LOGIN), }), createButton({ label: L("Nav_Register"), variant: "filled", onClick: () => navigateTo(ROUTES.A07_REGISTER), }), ); } } function buildFooter(): HTMLElement { const footer = document.createElement("footer"); footer.className = "app-footer"; footer.textContent = L("App_Footer_Copyright"); return footer; } /* ----------------------------------------------------------------------------- * 셸 렌더 — #app 안에 헤더 + outlet + 푸터를 구성하고 outlet 반환 * -------------------------------------------------------------------------- */ export function renderShell(appRoot: HTMLElement): HTMLElement { appRoot.innerHTML = ""; const header = buildHeader(); const outlet = document.createElement("main"); outlet.className = "app-outlet"; outlet.id = "app-outlet"; const footer = buildFooter(); appRoot.append(header, outlet, footer); return outlet; } /* ----------------------------------------------------------------------------- * 셸 스타일 (theme.css 변수만 사용) * -------------------------------------------------------------------------- */ const SHELL_STYLE_ID = "app-shell-style"; const SHELL_CSS = ` .app-header { display: flex; align-items: center; gap: var(--spacing-24); height: 64px; padding: 0 var(--spacing-32); background-color: var(--color-surface-raised); border-bottom: 1px solid var(--color-border); position: sticky; top: 0; z-index: var(--z-header); } .app-brand { display: inline-flex; align-items: center; gap: var(--spacing-8); font-family: var(--font-display); font-size: var(--text-subheading); font-weight: var(--font-weight-medium); color: var(--color-deep-iris); background: none; border: none; cursor: pointer; padding: 0; } .app-brand__icon { width: 32px; height: 32px; object-fit: cover; border-radius: var(--radius-icons); } .app-brand__name { line-height: 1; } .app-nav { display: flex; gap: var(--spacing-4); flex: 1; } .app-actions { display: flex; align-items: center; gap: var(--spacing-8); } .app-outlet { min-height: calc(100vh - 64px - 56px); } .app-footer { height: 56px; display: flex; align-items: center; justify-content: center; padding: 0 var(--spacing-32); background-color: var(--color-surface); border-top: 1px solid var(--color-border); color: var(--color-text-muted); font-size: var(--text-caption); } .app-placeholder { display: flex; align-items: center; justify-content: center; min-height: 40vh; color: var(--color-text-muted); font-size: var(--text-body); } @media (max-width: 900px) { .app-nav { display: none; } } `; export function injectShellStyles(): void { if (document.getElementById(SHELL_STYLE_ID)) return; const style = document.createElement("style"); style.id = SHELL_STYLE_ID; style.textContent = SHELL_CSS; document.head.append(style); }