/* ===================================================================== App — 라우터. AuthProvider 로 감싸고 역할 가드(RequireAuth) 적용. 라우트 (task 명세): /login -> Login /learn -> LearnerHome (learner) /learn/session/:sessionId -> Session /learn/session/:sessionId/review -> SessionReview /teach -> Professor (teacher → data-role=instructor) /teach/analysis -> Professor (teacher learner analysis) /teach/supervision -> SupervisionResearch (teacher/admin) /teach/personas -> PersonaStudio (teacher/admin) /teach/session/:sessionId/review -> SessionReview (teacher read-only) /admin -> Admin (admin) /settings -> Settings / -> Navigate(역할 홈, 미인증이면 /login) 보호 라우트는 AuthContext 기반 간단 가드. 과설계 금지. ===================================================================== */ import { Component, Suspense, lazy, useEffect, useLayoutEffect, type ErrorInfo, type ReactNode, } from "react"; import { BrowserRouter, Navigate, Route, Routes, useLocation, } from "react-router-dom"; import { AuthProvider, canAccessRole, initialPathForUser, useAuth, roleHomePath, type AuthUser, type Role, } from "./lib/auth"; import { runtimeAssetLabel } from "./lib/runtimeDiagnostics"; import { clearChunkRecoveryMarker } from "./lib/chunkRecovery"; const Login = lazy(() => import("./pages/Login")); const Onboarding = lazy(() => import("./pages/Onboarding")); const PendingApproval = lazy(() => import("./pages/PendingApproval")); const LearnerHome = lazy(() => import("./pages/LearnerHome")); const AvatarExpressionLab = lazy(() => import("./pages/AvatarExpressionLab")); const AvatarPreview = lazy(() => import("./pages/AvatarPreview")); const Session = lazy(() => import("./pages/Session")); const SessionReview = lazy(() => import("./pages/SessionReview")); const Professor = lazy(() => import("./pages/Professor")); const SupervisionResearch = lazy(() => import("./pages/SupervisionResearch")); const PersonaStudio = lazy(() => import("./pages/PersonaStudio")); const Admin = lazy(() => import("./pages/Admin")); const AdminAi = lazy(() => import("./pages/AdminAi")); const AdminContinuousImprovement = lazy( () => import("./pages/AdminContinuousImprovement"), ); const Settings = lazy(() => import("./pages/Settings")); /** 부트스트랩 로딩 동안 깜빡임 최소화용 중립 화면. */ function BootScreen() { return (
불러오는 중…
); } function AuthRestoreGate({ children }: { children: ReactNode }) { const { loading, restoreState, retryRestore } = useAuth(); if (loading && restoreState === "loading") return ; if (loading && restoreState === "retrying") { return (

서버 재연결

로그인 권한을 다시 확인하고 있습니다.

서버가 재구동된 직후일 수 있습니다. 저장된 세션을 버리지 않고 잠시 다시 연결합니다.

); } if (restoreState === "failed") { return (

서버 연결 진단

관리자 권한이 사라진 것이 아닙니다.

서버와의 연결을 복구하지 못했습니다. 서버가 정상화된 뒤 아래 버튼으로 같은 로그인 세션을 다시 확인할 수 있습니다.

); } return <>{children}; } /** * RequireAuth — 미인증이면 /login 으로. (선택) roles 로 역할 제한. * 권한 불일치 시 자신의 역할 홈으로 보냄(빈 화면/에러 대신). */ function RequireAuth({ children, roles, }: { children: ReactNode; roles?: Role[]; }) { const { user, loading } = useAuth(); const location = useLocation(); if (loading) return ; if (!user) { return ; } if (roles && !roles.some((role) => canAccessRole(user, role))) { return ; } return <>{children}; } function isAdminWorkspacePath(path: string) { return path === "/admin" || path.startsWith("/admin/"); } function approvedHomePath(user: AuthUser) { return initialPathForUser(user); } function OnboardingGate({ children }: { children: ReactNode }) { const { user, loading } = useAuth(); const location = useLocation(); const path = location.pathname; if (loading) return ; if (path === "/pending") return <>{children}; if (user && user.onboardingCompletedAt == null && path !== "/onboarding") { if (isAdminWorkspacePath(path) && canAccessRole(user, "admin")) { return <>{children}; } return ; } if ( user && user.onboardingCompletedAt != null && (path === "/login" || path === "/onboarding") ) { return ; } return <>{children}; } function PendingApprovalGate({ children }: { children: ReactNode }) { const { user, loading } = useAuth(); const location = useLocation(); const path = location.pathname; if (loading) return ; if (user && user.accountStatus !== "approved" && path !== "/pending") { return ; } if (user && user.accountStatus === "approved" && path === "/pending") { return ; } return <>{children}; } /** 루트(/) — 인증되면 역할 홈, 아니면 /login. */ function RootRedirect() { const { user, loading } = useAuth(); if (loading) return ; if (user && user.accountStatus !== "approved") { return ; } if (user && user.onboardingCompletedAt == null) { return ; } return ; } function resetDocumentScroll() { window.scrollTo({ top: 0, left: 0, behavior: "auto" }); document.documentElement.scrollTop = 0; document.body.scrollTop = 0; } function ScrollToTopOnPathChange() { const { pathname } = useLocation(); useLayoutEffect(() => { const previousRestoration = window.history.scrollRestoration; window.history.scrollRestoration = "manual"; const handlePageShow = () => resetDocumentScroll(); window.addEventListener("pageshow", handlePageShow); return () => { window.removeEventListener("pageshow", handlePageShow); window.history.scrollRestoration = previousRestoration; }; }, []); useLayoutEffect(() => { resetDocumentScroll(); const frame = window.requestAnimationFrame(resetDocumentScroll); return () => window.cancelAnimationFrame(frame); }, [pathname]); return null; } function RouteErrorFallback({ error, errorInfo, }: { error: Error; errorInfo: ErrorInfo | null; }) { const reloadLatestVersion = () => { clearChunkRecoveryMarker(window.location.pathname); window.location.reload(); }; return (
화면 진단

화면을 표시하지 못했습니다

React 라우트 렌더 중 예외가 발생했습니다. 빈 화면 대신 원인 정보를 표시합니다.

{[ [ "path", typeof window === "undefined" ? "unknown" : window.location.pathname, ], ["error", error.message || error.name], ["asset", runtimeAssetLabel()], [ "componentStack", errorInfo?.componentStack?.trim() || "not captured", ], ].map(([key, value]) => (
{key}
{value}
))}
홈으로 이동
); } class RouteErrorBoundary extends Component< { resetKey: string; children: ReactNode }, { error: Error | null; errorInfo: ErrorInfo | null } > { state = { error: null, errorInfo: null }; static getDerivedStateFromError(error: Error) { return { error, errorInfo: null }; } componentDidCatch(error: Error, errorInfo: ErrorInfo) { this.setState({ errorInfo }); console.error("[route-render-error]", error, errorInfo.componentStack); } componentDidUpdate(prevProps: { resetKey: string; children: ReactNode }) { if (prevProps.resetKey !== this.props.resetKey && this.state.error) { this.setState({ error: null, errorInfo: null }); } } render() { if (this.state.error) { return ( ); } return this.props.children; } } function AppRoutesWithBoundary() { const { pathname } = useLocation(); return ( }> ); } function ChunkRecoveryReady({ pathname }: { pathname: string }) { useEffect(() => { clearChunkRecoveryMarker(pathname); }, [pathname]); return null; } function AppRoutes() { return ( } /> } /> {/* dev: 인증 없는 아바타 컴포지션 튜닝 페이지 (실서비스 아님) */} } /> } /> {/* 학습자 */} } /> } /> } /> } /> } /> } /> {/* 교수자 */} } /> } /> } /> } /> } /> {/* 관리자 */} } /> } /> } /> } /> } /> } /> {/* 설정 — 3역할 공통 */} } /> } /> {/* 미정의 경로 → 루트로 */} } /> ); } export default function App() { return ( ); }