284 lines
8.8 KiB
TypeScript
284 lines
8.8 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";
|
|
|
|
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<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";
|
|
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> {
|
|
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);
|
|
}
|