Files
Aislo/A00_Common/app_shell.ts
T

465 lines
15 KiB
TypeScript

/* =============================================================================
* 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<void> {
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";
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;
}
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) — 폭 900 이하(위 메뉴가 숨는 폭) · 터치 모드에서 보임.
* 위 메뉴 · 언어 · 테마 · 로그인 단추와 같은 항목 + 터치 모드 스위치. 데스크탑 마우스엔 안 보임.
* -------------------------------------------------------------------------- */
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 setOpen = (open: boolean): void => {
header.classList.toggle("is-drawer-open", open);
burger.setAttribute("aria-expanded", String(open));
};
burger.addEventListener("click", () => setOpen(!header.classList.contains("is-drawer-open")));
scrim.addEventListener("click", () => setOpen(false));
drawer.addEventListener("keydown", (e) => e.key === "Escape" && setOpen(false));
// 메뉴 · 로그인 항목을 누르면 닫힘(언어 · 테마 · 스위치는 그대로 둠)
drawer.addEventListener("click", (e) => {
if ((e.target as Element).closest(".app-drawer__nav, .app-drawer__auth")) setOpen(false);
});
const navList = document.createElement("nav");
navList.className = "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);
drawer.append(navList, prefs, auth, buildTouchModeSwitch());
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<void> {
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 = "";
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 - 64px - 56px);
}
.app-outlet--without-footer {
min-height: calc(100dvh - 64px);
}
.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 이하 · 터치 모드에서만 보임(데스크탑 마우스엔 없음) */
.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;
}
@media (max-width: 900px) {
.app-burger { display: inline-flex; }
}
html[data-touch="on"] .app-burger { display: inline-flex; }
.app-header.is-drawer-open .app-drawer__scrim {
display: block;
position: fixed;
inset: 64px 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: 64px;
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__touch {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding-top: var(--spacing-16);
border-top: 1px solid var(--color-border);
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; }
`;
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);
}