Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
534 lines
18 KiB
TypeScript
534 lines
18 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 = 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<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 = "";
|
|
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);
|
|
}
|