- 라우터: 여러 주소 페이지(MountedPage) 길 · 같은 페이지면 셸도 다시 안 그림 · 떠나면 dispose · 늦게 끝난 옛 렌더 버림 - design_page: 보기 약속 DesignPageView(mount 중간 단계 · mountCenter 끝 모습 + shared 한 벌 자리) · host.onShow · onHide · onDispose - 3D 뷰어: setActive(루프 멈춤 · 창 포인터 뗌) · dispose 가 WebGL 문맥까지 놓음 · 보관 지형은 남김 · 코리도 조각을 Viewer_Corridor 로 - 종단도: setActive(창 키 듣기 뗌 · 보일 때 초안 · 판 다시 읽음) - 겹친 알약: 같은 측점이면 누른 알약을 그대로(24-6b) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
206 lines
8.7 KiB
TypeScript
206 lines
8.7 KiB
TypeScript
/* =============================================================================
|
|
* router.ts
|
|
* 해시 기반 SPA 라우터 + 인증 가드
|
|
*
|
|
* - 각 라우트는 (root) => void | Promise<void> 형태의 렌더 함수를 등록.
|
|
* - 페이지 렌더 함수는 지연 로딩(dynamic import)으로 초기 번들 최소화.
|
|
* - PROTECTED_ROUTES는 토큰 없으면 A06_Login으로 리다이렉트.
|
|
* ========================================================================== */
|
|
|
|
import { ROUTES, DEFAULT_ROUTE, PROTECTED_ROUTES, type RoutePath } from "@config/config_frontend";
|
|
|
|
/**
|
|
* 여러 주소를 맡는 페이지 — 같은 페이지 안 주소 이동은 다시 만들지 않고 `show` 만 부른다
|
|
* (B05 · B06 한 페이지 · 계획서 24-3a). 다른 페이지로 떠날 때 `dispose` 로 정리한다.
|
|
*/
|
|
export interface MountedPage {
|
|
covers: (route: RoutePath) => boolean;
|
|
show: (route: RoutePath) => void;
|
|
dispose: () => void;
|
|
}
|
|
|
|
/** 페이지 렌더 함수 시그니처. root(마운트 대상)를 받아 내용을 채운다. */
|
|
export type PageRenderer = (root: HTMLElement) => void | MountedPage | Promise<void | MountedPage>;
|
|
|
|
/** 라우트별 지연 로더 등록 테이블.
|
|
* 페이지 구현이 완료되는 대로 아래에 한 줄씩 추가한다. */
|
|
const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
|
|
[ROUTES.A01_HOME]: async () => (await import("../A01_Home/A01_Home_UI_Page")).renderA01Home,
|
|
[ROUTES.A02_PROG_DETAIL]: async () =>
|
|
(await import("../A02_ProgDetail/A02_ProgDetail_UI_Page")).renderA02ProgDetail,
|
|
[ROUTES.A03_COMP_DETAIL]: async () =>
|
|
(await import("../A03_CompDetail/A03_CompDetail_UI_Page")).renderA03CompDetail,
|
|
[ROUTES.A04_NEWS_HISTORY]: async () =>
|
|
(await import("../A04_NewsHistory/A04_NewsHistory_UI_Page")).renderA04NewsHistory,
|
|
[ROUTES.A05_EDU_DETAIL]: async () =>
|
|
(await import("../A05_EduDetail/A05_EduDetail_UI_Page")).renderA05EduDetail,
|
|
[ROUTES.A06_LOGIN]: async () =>
|
|
(await import("../A06_Login/A06_Login_UI_Auth_Page")).renderA06Login,
|
|
[ROUTES.A07_REGISTER]: async () =>
|
|
(await import("../A07_Register/A07_Register_UI_Auth_Page")).renderA07Register,
|
|
[ROUTES.A08_SUPPORT]: async () =>
|
|
(await import("../A08_Support/A08_Support_UI_Page")).renderA08Support,
|
|
[ROUTES.A09_SECURITY]: async () =>
|
|
(await import("../A09_Security/A09_Security_UI_Page")).renderA09Security,
|
|
// 로그인 후 (B 그룹)
|
|
[ROUTES.B01_ACCOUNT]: async () =>
|
|
(await import("../B01_Dashboard/B01_Dashboard_UI_Page")).renderB01Dashboard,
|
|
[ROUTES.B02_PROJ_REGISTER]: async () =>
|
|
(await import("../B02_ProjRegister/B02_ProjRegister_UI_Page")).renderB02ProjRegister,
|
|
[ROUTES.B03_FILE_INPUT]: async () =>
|
|
(await import("../B03_FileInput/B03_FileInput_UI_Page")).renderB03FileInput,
|
|
[ROUTES.B04_PREPROCESS]: async () =>
|
|
(await import("../B04_PreProcess/B04_PreProcess_UI_Page")).renderB04Surface,
|
|
// B05 · B06 = 한 페이지 두 보기(계획서 24-3a) — 주소만 바뀌면 보기만 바꾼다.
|
|
[ROUTES.B05_PROFILE]: async () => (await import("./design/design_page")).renderDesignPage,
|
|
[ROUTES.B06_SECTION]: async () => (await import("./design/design_page")).renderDesignPage,
|
|
[ROUTES.B07_DESIGN_DETAIL]: async () =>
|
|
(await import("../B07_DesignDetail/B07_DesignDetail_UI_Page")).renderB07DesignDetail,
|
|
// B08_QUANTITY: 수량 본문은 재작성 예정(구 구현은 B08_Quantity/0_old_260726_codex.zip 백업).
|
|
// 지금은 셸 페이지 — 좌측 [확정]으로 stage 5를 닫고 B09 설계도서로 넘어간다.
|
|
[ROUTES.B08_QUANTITY]: async () =>
|
|
(await import("../B08_Quantity/B08_Quantity_UI_Page")).renderB08Quantity,
|
|
[ROUTES.B09_ESTIMATION]: async () =>
|
|
(await import("../B09_Estimation/B09_Estimation_UI_Shell")).renderB09Estimation,
|
|
[ROUTES.B10_PAYMENT]: async () =>
|
|
(await import("../B10_Payment/B10_Payment_UI_Page")).renderB10Payment,
|
|
[ROUTES.B11_STATUS]: async () =>
|
|
(await import("../B11_Status/B11_Status_UI_Page")).renderB11Status,
|
|
[ROUTES.B11_LOADING]: async () =>
|
|
(await import("../B11_Status/B11_Status_UI_Loading")).renderB11Loading,
|
|
[ROUTES.M01_MASTER_DATA]: async () =>
|
|
(await import("../M01_MasterData/M01_MasterData_UI_Page")).renderM01MasterData,
|
|
[ROUTES.M02_MASTER_TEMPLATE]: async () =>
|
|
(await import("../M02_MasterTemplete/M02_MasterTemplete_UI_Page")).renderM02MasterTemplate,
|
|
};
|
|
|
|
/** 페이지가 떠나기 전에 묻는 자리 — false 를 돌려주면 이동을 취소(주소도 되돌림). 이동이 끝나면 비워짐. */
|
|
let leaveGuard: (() => Promise<boolean>) | null = null;
|
|
export const setLeaveGuard = (guard: (() => Promise<boolean>) | null): void => {
|
|
leaveGuard = guard;
|
|
};
|
|
export const takeLeaveGuard = (): (() => Promise<boolean>) | null => leaveGuard;
|
|
|
|
/** 지금 떠 있는 여러 주소 페이지 · 렌더 차례(늦게 끝난 옛 렌더를 버림). */
|
|
let mounted: MountedPage | null = null;
|
|
let renderSeq = 0;
|
|
|
|
/** 같은 페이지 안 주소면 보기만 바꾸고 true — 셸 · 페이지를 다시 그리지 않는다. */
|
|
export function showWithinPage(route: RoutePath): boolean {
|
|
if (!mounted?.covers(route)) return false;
|
|
mounted.show(route);
|
|
return true;
|
|
}
|
|
|
|
/** 로그인 여부 (토큰 존재 확인) */
|
|
export async function isAuthenticated(): Promise<boolean> {
|
|
const { fetchSession } = await import("../A06_Login/A06_Login_Api_Fetch");
|
|
return fetchSession();
|
|
}
|
|
|
|
/** 현재 해시에서 라우트 경로 추출 (#/a01-home → a01-home · `?이름=값` 뒤는 뗌) */
|
|
export function currentRoute(): RoutePath {
|
|
const hash = window.location.hash.replace(/^#\/?/, "").split("?")[0].trim();
|
|
return (hash || DEFAULT_ROUTE) as RoutePath;
|
|
}
|
|
|
|
/** 현재 해시의 `?이름=값` 인자 (#/m02-master-template?layer=personal → layer=personal) */
|
|
export function currentQuery(): URLSearchParams {
|
|
return new URLSearchParams(window.location.hash.split("?")[1] ?? "");
|
|
}
|
|
|
|
/** 지정 라우트로 이동 (해시 변경 → hashchange 이벤트로 렌더 트리거) · `query` = `?이름=값` 인자 */
|
|
export function navigateTo(route: RoutePath, query?: Record<string, string>): void {
|
|
const target = `#/${route}${query ? `?${new URLSearchParams(query)}` : ""}`;
|
|
if (window.location.hash === target) {
|
|
// 동일 해시면 hashchange가 안 뜨므로 수동 트리거
|
|
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
|
} else {
|
|
window.location.hash = target;
|
|
}
|
|
}
|
|
|
|
/** "준비 중" 임시 플레이스홀더 (아직 구현 안 된 페이지용) */
|
|
function renderPlaceholder(root: HTMLElement, route: RoutePath): void {
|
|
root.innerHTML = "";
|
|
const box = document.createElement("div");
|
|
box.className = "app-placeholder";
|
|
box.textContent = `준비 중인 페이지입니다: ${route}`;
|
|
root.append(box);
|
|
}
|
|
|
|
/**
|
|
* 현재 라우트를 outlet에 렌더. 인증 가드 포함.
|
|
* (hashchange 구독은 호출측에서 1회만 등록하도록 분리.)
|
|
*/
|
|
export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
|
|
const route = currentRoute();
|
|
const seq = ++renderSeq;
|
|
// 다른 페이지로 떠남 — 3D · 그리기 루프 · 창 듣기를 정리한다(계획서 24-3a).
|
|
mounted?.dispose();
|
|
mounted = null;
|
|
|
|
if (PROTECTED_ROUTES.includes(route) && !(await isAuthenticated())) {
|
|
navigateTo(ROUTES.A06_LOGIN);
|
|
return;
|
|
}
|
|
|
|
const workflowRoutes: readonly RoutePath[] = [
|
|
ROUTES.B02_PROJ_REGISTER,
|
|
ROUTES.B03_FILE_INPUT,
|
|
ROUTES.B04_PREPROCESS,
|
|
ROUTES.B05_PROFILE,
|
|
ROUTES.B06_SECTION,
|
|
ROUTES.B07_DESIGN_DETAIL,
|
|
ROUTES.B08_QUANTITY,
|
|
ROUTES.B09_ESTIMATION,
|
|
ROUTES.B10_PAYMENT,
|
|
ROUTES.B11_STATUS,
|
|
ROUTES.B11_LOADING,
|
|
];
|
|
|
|
if (workflowRoutes.includes(route)) {
|
|
const { fetchSessionUser } = await import("../A06_Login/A06_Login_Api_Fetch");
|
|
try {
|
|
const user = await fetchSessionUser();
|
|
if (!user) {
|
|
navigateTo(ROUTES.A06_LOGIN);
|
|
return;
|
|
}
|
|
if (!user.company_id && user.role !== "SYSTEM_ADMIN") {
|
|
navigateTo(ROUTES.B01_ACCOUNT);
|
|
return;
|
|
}
|
|
} catch {
|
|
navigateTo(ROUTES.A06_LOGIN);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const loader = routeTable[route];
|
|
if (!loader) {
|
|
renderPlaceholder(outlet, route);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const renderer = await loader();
|
|
if (seq !== renderSeq) return; // 그 사이 다른 주소로 떠났다.
|
|
outlet.innerHTML = "";
|
|
const page = await renderer(outlet);
|
|
if (page && seq !== renderSeq) page.dispose();
|
|
else if (page) mounted = page;
|
|
window.scrollTo(0, 0);
|
|
} catch (err) {
|
|
console.error(`[router] 페이지 로드 실패: ${route}`, err);
|
|
renderPlaceholder(outlet, route);
|
|
}
|
|
}
|
|
|
|
/** 최초 진입 시 해시가 없으면 기본 라우트로 정규화 */
|
|
export function ensureInitialHash(): void {
|
|
if (!window.location.hash) {
|
|
window.location.hash = `#/${DEFAULT_ROUTE}`;
|
|
}
|
|
}
|