vignette/apps/web/src/components/shell/AppShell.tsx

179 lines
6.2 KiB
TypeScript

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<HTMLElement | null>(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<HTMLButtonElement>(
"[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<HTMLButtonElement>(".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 (
<div
className={[
"vg-shell",
"vg-shell--botanical",
hideTopbar ? "vg-shell--fullscreen" : "",
className ?? "",
]
.filter(Boolean)
.join(" ")}
>
{hideTopbar ? null : <Topbar contextLabel={contextLabel} />}
<div className={"vg-shell__body" + (showNav ? "" : " vg-shell__body--bare")}>
{showNav ? (
<Sidebar
role={shellRole ?? user.role}
showAdminEntry={canAccessRole(user, "admin")}
/>
) : null}
<main ref={mainRef} className={mainClassName}>
<div className="vg-main__inner">{children}</div>
</main>
</div>
</div>
);
}