Files
Aislo/A00_Common/app_shell.ts
T

244 lines
7.6 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";
/** 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";
brand.textContent = L("App_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<void> {
const loggedIn = await isAuthenticated();
slot.innerHTML = "";
if (loggedIn) {
slot.append(
createButton({
label: 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),
}),
);
}
}
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 {
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-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);
}