179 lines
6.2 KiB
TypeScript
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>
|
|
);
|
|
}
|