import { useEffect, useLayoutEffect, useRef, type ReactNode } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { Topbar } from "./Topbar"; import { Sidebar } from "./Sidebar"; import { canAccessRole, designRoleOf, useAuth, type Role } from "../../lib/auth"; export interface AppShellProps { children: ReactNode; /** 페이지 전용 셸 표면을 위한 추가 클래스 */ className?: string; /** 역할 컨텍스트 라벨 오버라이드 (없으면 user.role 라벨) */ contextLabel?: string; /** 좌측 네비 숨김 (세션 화면처럼 집중 모드) */ hideNav?: boolean; /** 메인 패딩·최대폭 제거 (풀-블리드 레이아웃) */ bleed?: boolean; /** 운영형 대시보드/저작 화면용 넓은 작업폭 */ wide?: boolean; /** 톱바까지 제거하는 실제 전체화면 작업 공간 */ hideTopbar?: boolean; /** 현재 화면 컨텍스트에 맞춘 네비/크롬 역할 */ navRole?: Role; } /** * AppShell — 3역할 공통 셸. 톱바 + (역할 네비) + 메인. * body[data-role] 은 AuthProvider 가 관리(여기선 셸 골격만). * 미인증 시에도 안전하게 렌더(네비 없이) — 가드는 라우터(RequireAuth)가 담당. */ export function AppShell({ children, className, contextLabel, hideNav, bleed, wide, hideTopbar, navRole }: AppShellProps) { const { user } = useAuth(); const { pathname } = useLocation(); const mainRef = useRef(null); const showNav = !hideNav && !!user; const shellRole = navRole ?? user?.role; const mainClassName = [ "vg-main", bleed ? "vg-main--bleed" : "", wide ? "vg-main--wide" : "", ] .filter(Boolean) .join(" "); useEffect(() => { const body = document.body; if (shellRole) body.setAttribute("data-role", designRoleOf(shellRole)); return () => { if (user) body.setAttribute("data-role", designRoleOf(user.role)); else body.removeAttribute("data-role"); }; }, [shellRole, user]); useLayoutEffect(() => { const resetMainScroll = () => { const main = mainRef.current; if (!main) return; main.scrollTop = 0; main.scrollLeft = 0; }; resetMainScroll(); const frame = window.requestAnimationFrame(resetMainScroll); return () => window.cancelAnimationFrame(frame); }, [pathname]); useEffect(() => { const handlePageShow = () => { const main = mainRef.current; if (!main) return; main.scrollTop = 0; main.scrollLeft = 0; window.requestAnimationFrame(() => { if (!mainRef.current) return; mainRef.current.scrollTop = 0; mainRef.current.scrollLeft = 0; }); }; window.addEventListener("pageshow", handlePageShow); return () => window.removeEventListener("pageshow", handlePageShow); }, []); const navigate = useNavigate(); // ── 전역 단축키 핸들러 ── useEffect(() => { const handleGlobalKeyDown = (e: KeyboardEvent) => { if (e.repeat) return; const target = e.target instanceof HTMLElement ? e.target : null; const isInput = target?.closest("input, textarea, select, [contenteditable='true']"); const isAlt = e.altKey && !e.ctrlKey && !e.metaKey; const isCtrl = (e.ctrlKey || e.metaKey) && !e.altKey; // Ctrl+W 또는 Escape: 활성 모달 닫기 if ((isCtrl && e.key.toLowerCase() === "w") || e.key === "Escape") { const openModalCloseBtn = document.querySelector( "[role='dialog'] button:has(svg), [role='dialog'] .sx-panel-close-btn, .vg-dialog__close, [role='dialog'] button[aria-label*='닫기']" ); if (openModalCloseBtn) { e.preventDefault(); openModalCloseBtn.click(); return; } } // Alt+H: 홈으로 이동 if (isAlt && e.key.toLowerCase() === "h") { e.preventDefault(); const homePath = user?.role === "admin" ? "/admin" : user?.role === "teacher" ? "/professor" : "/learn"; navigate(homePath); return; } // Alt+N 또는 Ctrl+N 또는 Ctrl+T (입력창 밖): 새 회기 시작 / 페르소나 선택 if ((isAlt && e.key.toLowerCase() === "n") || (!isInput && isCtrl && (e.key.toLowerCase() === "n" || e.key.toLowerCase() === "t"))) { e.preventDefault(); navigate("/learn"); return; } // Alt+K: 학습 기록으로 이동 if (isAlt && e.key.toLowerCase() === "k") { e.preventDefault(); navigate("/learn/history"); return; } // Alt+S: 설정 화면으로 이동 if (isAlt && e.key.toLowerCase() === "s") { e.preventDefault(); navigate("/settings"); return; } // Alt+T: 다크/라이트 테마 전환 if (isAlt && e.key.toLowerCase() === "t") { e.preventDefault(); const themeBtn = document.querySelector(".vg-topbar__theme-btn, [data-testid='theme-toggle']"); if (themeBtn) { themeBtn.click(); } else { const currentTheme = document.documentElement.getAttribute("data-theme") || "light"; const nextTheme = currentTheme === "dark" ? "light" : "dark"; document.documentElement.setAttribute("data-theme", nextTheme); localStorage.setItem("vignette_theme", nextTheme); } return; } }; window.addEventListener("keydown", handleGlobalKeyDown); return () => window.removeEventListener("keydown", handleGlobalKeyDown); }, [navigate, user]); return (
{hideTopbar ? null : }
{showNav ? ( ) : null}
{children}
); }