/* =====================================================================
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 (
);
}