/* ============================================================================= * 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"; import { readTouchMode, setTouchMode, type TouchMode } from "./device_context"; const BRAND_ICON_URL = new URL("../resources/app_branding/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 = buildBrand(); // 네비게이션 링크 const nav = document.createElement("nav"); nav.className = "app-nav"; nav.append(...buildNavButtons()); // 우측 액션 (언어/테마/로그인) 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, ...buildDrawer(header)); return header; } /** 로고 (클릭 시 홈) — 위 줄 · ☰ 서랍 첫 묶음이 같이 씀 */ function buildBrand(): HTMLButtonElement { 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)); return brand; } /* ☰ 서랍 진행 단계 묶음(6-2) — 프로젝트 화면이 진행 판에 그린 단계 목록 **그 요소**를 받아 둠. * 서랍을 열 때 서랍으로 옮기고 닫을 때 제자리(진행 판)로 — 그리기 · 누름 코드는 한 벌. */ let drawerSteps: { node: HTMLElement; home: HTMLElement } | null = null; export function setDrawerSteps(node: HTMLElement | null): void { drawerSteps = node?.parentElement ? { node, home: node.parentElement } : null; } const NAV_ITEMS: [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], ]; function buildNavButtons(): HTMLElement[] { return NAV_ITEMS.map(([labelKey, route]) => createButton({ label: L(labelKey), variant: "pill", onClick: () => navigateTo(route as never), }), ); } /* ----------------------------------------------------------------------------- * ☰ 서랍(PLAN_기기대응 2-2 · 7-1) — 공개 쪽은 폭 900 이하 · 터치 모드에서 · 로그인 뒤 쪽(B · M)은 늘 보임. * 위 메뉴 · 언어 · 테마 · 로그인 단추와 같은 항목 + 터치 모드 스위치. * -------------------------------------------------------------------------- */ function buildDrawer(header: HTMLElement): HTMLElement[] { const burger = document.createElement("button"); burger.type = "button"; burger.className = "app-burger"; burger.textContent = "☰"; burger.setAttribute("aria-label", L("App_Menu")); burger.setAttribute("aria-expanded", "false"); const drawer = document.createElement("div"); drawer.className = "app-drawer"; drawer.setAttribute("role", "dialog"); drawer.setAttribute("aria-label", L("App_Menu")); const scrim = document.createElement("div"); scrim.className = "app-drawer__scrim"; const steps = document.createElement("div"); steps.className = "app-drawer__group app-drawer__steps"; const setOpen = (open: boolean): void => { header.classList.toggle("is-drawer-open", open); burger.setAttribute("aria-expanded", String(open)); if (!drawerSteps) return; if (open) steps.append(drawerSteps.node); else if (steps.contains(drawerSteps.node)) drawerSteps.home.append(drawerSteps.node); }; burger.addEventListener("click", () => setOpen(!header.classList.contains("is-drawer-open"))); scrim.addEventListener("click", () => setOpen(false)); header.addEventListener("keydown", (e) => e.key === "Escape" && setOpen(false)); // ☰ 에 포커스가 있어도 닫힘 // 로고 · 진행 단계 · 메뉴 · 로그인 항목을 누르면 닫힘(언어 · 테마 · 스위치는 그대로 둠) drawer.addEventListener("click", (e) => { const hit = (e.target as Element).closest("button"); if (hit?.closest(".app-drawer__brand, .app-drawer__steps, .app-drawer__nav, .app-drawer__auth")) setOpen(false); }); // 묶음 차례 = 로고 · 홈 ─ 진행 단계(프로젝트 안) ─ 메뉴 ─ 언어 · 테마 · 로그인 · 터치 모드 const brandRow = document.createElement("div"); brandRow.className = "app-drawer__group app-drawer__brand"; brandRow.append(buildBrand()); const navList = document.createElement("nav"); navList.className = "app-drawer__group app-drawer__nav"; navList.append(...buildNavButtons()); const prefs = document.createElement("div"); prefs.className = "app-drawer__row"; prefs.append( createButton({ label: currentLanguageIndex === 0 ? "EN" : "한", variant: "pill", onClick: onApp_Lang_Toggle_Click, }), createButton({ label: document.documentElement.getAttribute("data-theme") === "dark" ? "☀" : "☾", variant: "pill", onClick: onApp_Theme_Toggle_Click, }), ); const auth = document.createElement("div"); auth.className = "app-drawer__row app-drawer__auth"; void fillAuthActions(auth); const settings = document.createElement("div"); settings.className = "app-drawer__group"; settings.append(prefs, auth, buildTouchModeSwitch()); drawer.append(brandRow, steps, navList, settings); return [burger, scrim, drawer]; } function buildTouchModeSwitch(): HTMLElement { const box = document.createElement("div"); box.className = "app-drawer__touch"; const label = document.createElement("span"); label.textContent = L("App_TouchMode"); const group = document.createElement("div"); group.className = "app-drawer__row"; group.setAttribute("role", "radiogroup"); group.setAttribute("aria-label", L("App_TouchMode")); const modes: [TouchMode, keyof typeof ui_locales][] = [ ["auto", "App_TouchMode_Auto"], ["on", "App_TouchMode_On"], ["off", "App_TouchMode_Off"], ]; const buttons = modes.map(([mode, key]) => { const b = createButton({ label: L(key), variant: "pill", onClick: () => { setTouchMode(mode); mark(); }, }); b.setAttribute("role", "radio"); b.dataset.touchMode = mode; return b; }); const mark = (): void => { const now = readTouchMode(); for (const b of buttons) b.setAttribute("aria-checked", String(b.dataset.touchMode === now)); }; mark(); group.append(...buttons); box.append(label, group); return box; } /** 서버 세션 상태에 따라 로그인/로그아웃 버튼을 채운다. */ 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, showFooter = true): HTMLElement { appRoot.innerHTML = ""; drawerSteps = null; // 새 쪽이 진행 판을 세우면 다시 맡김 const header = buildHeader(); const outlet = document.createElement("main"); outlet.className = "app-outlet"; outlet.id = "app-outlet"; appRoot.append(header, outlet); if (showFooter) { appRoot.append(buildFooter()); } else { outlet.classList.add("app-outlet--without-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); } /* 사용자명·로그아웃(또는 로그인·회원가입) 버튼이 서로 붙어 보이던 것을 띄운다 (2026-09-06 사용자 지적) — 이 칸은 나중에 채워지므로 자체 간격이 필요하다. */ .app-actions__auth { display: flex; align-items: center; gap: var(--spacing-8); } .app-outlet { min-height: calc(100dvh - var(--app-header-h) - 56px); } .app-outlet--without-footer { min-height: calc(100dvh - var(--app-header-h)); } .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; } .app-actions { margin-left: auto; } } /* ☰ 서랍 — 공개 쪽은 폭 900 이하 · 터치 모드에서만 · 로그인 뒤 쪽(B · M)은 늘 */ .app-burger, .app-drawer, .app-drawer__scrim { display: none; } .app-burger { align-items: center; justify-content: center; min-width: 44px; min-height: 44px; border: 1px solid var(--color-border); border-radius: var(--radius-buttons); background: var(--color-surface); color: var(--color-text); font-size: 20px; cursor: pointer; } html:is([data-burger="on"], .is-bm-route) .app-burger { display: inline-flex; } .app-header.is-drawer-open { z-index: var(--z-overlay); } .app-header.is-drawer-open .app-drawer__scrim { display: block; position: fixed; inset: var(--app-header-h) 0 0 0; background: rgba(38, 17, 74, 0.35); } .app-header.is-drawer-open .app-drawer { display: flex; flex-direction: column; gap: var(--spacing-16); position: fixed; top: var(--app-header-h); right: 0; bottom: 0; width: min(320px, 100vw); padding: var(--spacing-16); overflow-y: auto; background: var(--color-surface-raised); border-left: 1px solid var(--color-border); box-shadow: var(--shadow-lg); } .app-drawer__nav { display: flex; flex-direction: column; gap: var(--spacing-4); } .app-drawer__nav > * { justify-content: flex-start; min-height: 44px; } .app-drawer__row { display: flex; flex-wrap: wrap; gap: var(--spacing-8); } .app-drawer__group { display: flex; flex-direction: column; gap: var(--spacing-8); } .app-drawer__group + .app-drawer__group { padding-top: var(--spacing-16); border-top: 1px solid var(--color-border); } .app-drawer__steps:empty { display: none; } .app-drawer__touch { display: flex; flex-direction: column; gap: var(--spacing-8); color: var(--color-text-secondary); font-size: var(--text-caption); } .app-drawer__touch [aria-checked="true"] { background: var(--color-mist-violet); color: var(--color-accent); } /* 휴대폰 폭 — 오른쪽 단추(언어 · 테마 · 로그인)는 서랍 안으로 */ html[data-size="compact"] .app-header { padding: 0 var(--spacing-16); gap: var(--spacing-8); } html[data-size="compact"] .app-actions { display: none; } html[data-size="compact"] .app-burger { margin-left: auto; } /* 로그인 뒤 쪽(B · M) — 폭 · 입력과 상관없이 위 줄을 걷고 ☰ 만 화면 왼쪽 위 모서리에 띄움(7-1) 본문이 화면 높이를 다 씀 · 서랍은 왼쪽에서 열림 · 위 줄 항목은 모두 서랍에 있음 */ html.is-bm-route .app-header { height: 0; padding: 0; border: 0; z-index: calc(var(--z-dropdown) + 1); } html.is-bm-route .app-header > :is(.app-brand, .app-nav, .app-actions) { display: none; } /* ☰ 폭 36(7-4) · 높이는 손가락 44 · 마우스 36 */ html.is-bm-route .app-burger { min-width: var(--app-burger-w); min-height: var(--app-burger-w); position: fixed; top: var(--spacing-8); left: var(--spacing-8); z-index: 1; margin: 0; padding: 0; box-shadow: var(--shadow-lg); } html.is-bm-route[data-touch="on"] .app-burger { min-height: 44px; } html.is-bm-route .app-header.is-drawer-open .app-drawer { left: 0; right: auto; border-left: 0; border-right: 1px solid var(--color-border); } html.is-bm-route .app-drawer__brand { min-height: 44px; padding-left: calc(var(--app-burger-inset) - var(--spacing-16)); } html.is-bm-route .app-drawer__brand .app-brand { align-self: flex-start; } `; 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); }