diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 4f76778e..e2143e8e 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -24,6 +24,7 @@ import { subscribe as canvasSplitSubscribe, getSnapshot as canvasSplitGetSnapshot, getServerSnapshot as canvasSplitGetServerSnapshot, + subscribeDom as canvasSplitSubscribeDom, } from "@/lib/registry/components/v2-split-line/canvasSplitStore"; // 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록 @@ -1181,14 +1182,66 @@ export const InteractiveScreenViewerDynamic: React.FC { + if (isSplitActive && adjustedW !== origW) { + return { ...component, size: { ...(component as any).size, width: Math.round(adjustedW) } }; + } + return component; + }, [component, isSplitActive, adjustedW, origW]); + + // 드래그 중 DOM 직접 조작 (React 리렌더 없이 매 프레임 업데이트) + const elRef = React.useRef(null); + React.useEffect(() => { + const compType = (component as any).componentType || ""; + if (type === "component" && compType === "v2-split-line") return; + + const unsubscribe = canvasSplitSubscribeDom((snap) => { + if (!snap.isDragging || !snap.active || !snap.scopeId) return; + if (myScopeIdRef.current !== snap.scopeId) return; + const el = elRef.current; + if (!el) return; + + const origX = position?.x || 0; + const oW = size?.width || 200; + const { initialDividerX, currentDividerX, canvasWidth } = snap; + const delta = currentDividerX - initialDividerX; + if (Math.abs(delta) < 1) return; + + if (canvasSplitSideRef.current === null) { + canvasSplitSideRef.current = (origX + oW / 2) < initialDividerX ? "left" : "right"; + } + + const GAP = 4; + let nx: number, nw: number; + if (canvasSplitSideRef.current === "left") { + const scale = initialDividerX > 0 ? Math.max(20, currentDividerX - GAP) / initialDividerX : 1; + nx = origX * scale; + nw = oW * scale; + if (nx + nw > currentDividerX - GAP) nw = currentDividerX - GAP - nx; + } else { + const irw = canvasWidth - initialDividerX; + const crw = Math.max(20, canvasWidth - currentDividerX - GAP); + const scale = irw > 0 ? crw / irw : 1; + nx = currentDividerX + GAP + (origX - initialDividerX) * scale; + nw = oW * scale; + if (nx < currentDividerX + GAP) nx = currentDividerX + GAP; + if (nx + nw > canvasWidth) nw = canvasWidth - nx; + } + nx = Math.max(0, nx); + nw = Math.max(20, nw); + + el.style.left = `${nx}px`; + el.style.width = `${Math.round(nw)}px`; + el.style.overflow = nw < oW ? "hidden" : ""; + }); + return unsubscribe; + }, [component.id, position?.x, size?.width, type]); + return ( <> -
- {renderInteractiveWidget( - isSplitActive && adjustedW !== origW - ? { ...component, size: { ...(component as any).size, width: adjustedW } } - : component - )} +
+ {renderInteractiveWidget(splitAdjustedComponent)}
{/* 팝업 화면 렌더링 */} diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index d4c91d93..b95506d9 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -21,6 +21,7 @@ import { subscribe as canvasSplitSubscribe, getSnapshot as canvasSplitGetSnapshot, getServerSnapshot as canvasSplitGetServerSnapshot, + subscribeDom as canvasSplitSubscribeDom, } from "@/lib/registry/components/v2-split-line/canvasSplitStore"; import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext"; @@ -603,6 +604,60 @@ const RealtimePreviewDynamicComponent: React.FC = ({ onDragEnd?.(); }; + const splitAdjustedComp = React.useMemo(() => { + if (isSplitShrunk && splitAdjustedWidth !== null) { + return { ...enhancedComponent, size: { ...(enhancedComponent as any).size, width: Math.round(splitAdjustedWidth) } }; + } + return enhancedComponent; + }, [enhancedComponent, isSplitShrunk, splitAdjustedWidth]); + + // 드래그 중 DOM 직접 조작 (React 리렌더 없이 매 프레임 업데이트) + React.useEffect(() => { + const isSplitLine = type === "component" && componentType === "v2-split-line"; + if (isSplitLine) return; + + const unsubscribe = canvasSplitSubscribeDom((snap) => { + if (!snap.isDragging || !snap.active || !snap.scopeId) return; + if (myScopeIdRef.current !== snap.scopeId) return; + const el = outerDivRef.current; + if (!el) return; + + const origX = position.x; + const oW = size?.width || 100; + const { initialDividerX, currentDividerX, canvasWidth } = snap; + const delta = currentDividerX - initialDividerX; + if (Math.abs(delta) < 1) return; + + if (canvasSplitSideRef.current === null) { + canvasSplitSideRef.current = (origX + oW / 2) < initialDividerX ? "left" : "right"; + } + + const GAP = 4; + let nx: number, nw: number; + if (canvasSplitSideRef.current === "left") { + const scale = initialDividerX > 0 ? Math.max(20, currentDividerX - GAP) / initialDividerX : 1; + nx = origX * scale; + nw = oW * scale; + if (nx + nw > currentDividerX - GAP) nw = currentDividerX - GAP - nx; + } else { + const irw = canvasWidth - initialDividerX; + const crw = Math.max(20, canvasWidth - currentDividerX - GAP); + const scale = irw > 0 ? crw / irw : 1; + nx = currentDividerX + GAP + (origX - initialDividerX) * scale; + nw = oW * scale; + if (nx < currentDividerX + GAP) nx = currentDividerX + GAP; + if (nx + nw > canvasWidth) nw = canvasWidth - nx; + } + nx = Math.max(0, nx); + nw = Math.max(20, nw); + + el.style.left = `${nx}px`; + el.style.width = `${Math.round(nw)}px`; + el.style.overflow = nw < oW ? "hidden" : ""; + }); + return unsubscribe; + }, [id, position.x, size?.width, type, componentType]); + return (
= ({ style={{ width: "100%", maxWidth: "100%" }} > { const router = useRouter(); - // 상태 관리 const [menuState, setMenuState] = useState({ menuList: [], expandedMenus: new Set(), @@ -36,103 +37,58 @@ export const useMenu = (user: any, authLoading: boolean) => { * 메뉴 트리 구조 생성 */ const buildMenuTree = useCallback((menuItems: MenuItem[]): MenuItem[] => { - console.log("빌드 메뉴 트리 - 원본 메뉴 아이템들:", menuItems); - const menuMap = new Map(); const rootMenus: MenuItem[] = []; - // 모든 메뉴를 맵에 저장 (ID를 문자열로 변환) menuItems.forEach((menu) => { const objId = String(menu.OBJID); const parentId = String(menu.PARENT_OBJ_ID); - console.log(`메뉴 처리: ${menu.MENU_NAME_KOR}, OBJID: ${objId}, PARENT_OBJ_ID: ${parentId}`); menuMap.set(objId, { ...menu, OBJID: objId, PARENT_OBJ_ID: parentId, children: [] }); }); - console.log("메뉴 맵 생성 완료, 총 메뉴 수:", menuMap.size); - - // 부모-자식 관계 설정 menuItems.forEach((menu) => { const objId = String(menu.OBJID); const parentId = String(menu.PARENT_OBJ_ID); const menuItem = menuMap.get(objId)!; - // PARENT_OBJ_ID가 특정 값이 아닌 경우 (루트가 아닌 경우) if (parentId !== "-395553955") { const parent = menuMap.get(parentId); if (parent) { parent.children = parent.children || []; parent.children.push(menuItem); - console.log(`자식 메뉴 추가: ${menu.MENU_NAME_KOR} -> ${parent.MENU_NAME_KOR}`); - } else { - console.log(`부모 메뉴를 찾을 수 없음: ${menu.MENU_NAME_KOR}, 부모 ID: ${parentId}`); } } else { rootMenus.push(menuItem); - console.log(`루트 메뉴 추가: ${menu.MENU_NAME_KOR}`); } }); - console.log("루트 메뉴 개수:", rootMenus.length); - console.log( - "최종 루트 메뉴들:", - rootMenus.map((m) => m.MENU_NAME_KOR), - ); - return rootMenus.sort((a, b) => (a.SEQ || 0) - (b.SEQ || 0)); }, []); /** * 메뉴 데이터 로드 + * - apiClient 사용으로 토큰/401 자동 처리 + * - 실패 시 빈 메뉴 유지 (로그인 리다이렉트는 client.ts 인터셉터가 담당) */ const loadMenuData = useCallback(async () => { try { - // JWT 토큰 가져오기 - const token = localStorage.getItem("authToken"); + const response = await apiClient.get("/admin/user-menus"); - if (!token) { - console.error("JWT 토큰이 없습니다."); - router.push("/login"); - return; + if (response.data?.success && response.data?.data) { + const convertedMenuData = convertToUpperCaseKeys(response.data.data || []); + setMenuState((prev: MenuState) => ({ + ...prev, + menuList: buildMenuTree(convertedMenuData), + isLoading: false, + })); + } else { + setMenuState((prev: MenuState) => ({ ...prev, isLoading: false })); } - - // 메뉴 목록 조회 - const menuResponse = await fetch(`${LAYOUT_CONFIG.API_BASE_URL}${LAYOUT_CONFIG.ENDPOINTS.USER_MENUS}`, { - method: "GET", - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/json", - "Content-Type": "application/json", - }, - }); - - if (menuResponse.ok) { - const menuResult = await menuResponse.json(); - console.log("메뉴 응답 데이터:", menuResult); - - if (menuResult.success && menuResult.data) { - console.log("메뉴 데이터 배열:", menuResult.data); - const convertedMenuData = convertToUpperCaseKeys(menuResult.data || []); - console.log("변환된 메뉴 데이터:", convertedMenuData); - - setMenuState((prev: MenuState) => ({ - ...prev, - menuList: buildMenuTree(convertedMenuData), - isLoading: false, - })); - } - } else if (menuResponse.status === 401) { - // 인증 실패 시 토큰 제거 및 로그인 페이지로 리다이렉트 - localStorage.removeItem("authToken"); - router.push("/login"); - } - } catch (error) { - console.error("메뉴 데이터 로드 실패:", error); - localStorage.removeItem("authToken"); - router.push("/login"); + } catch { + // API 실패 시 빈 메뉴로 유지 (401은 client.ts 인터셉터가 리다이렉트 처리) setMenuState((prev: MenuState) => ({ ...prev, isLoading: false })); } - }, [router, convertToUpperCaseKeys, buildMenuTree]); + }, [convertToUpperCaseKeys, buildMenuTree]); /** * 메뉴 토글 @@ -160,13 +116,11 @@ export const useMenu = (user: any, authLoading: boolean) => { if (menu.children && menu.children.length > 0) { toggleMenu(String(menu.OBJID)); } else { - // 메뉴 이름 저장 (엑셀 다운로드 파일명에 사용) const menuName = menu.MENU_NAME_KOR || menu.menuNameKor || menu.TRANSLATED_NAME || "메뉴"; if (typeof window !== "undefined") { localStorage.setItem("currentMenuName", menuName); } - // 먼저 할당된 화면이 있는지 확인 (URL 유무와 관계없이) try { const menuObjid = menu.OBJID || menu.objid; if (menuObjid) { @@ -174,9 +128,7 @@ export const useMenu = (user: any, authLoading: boolean) => { const assignedScreens = await menuScreenApi.getScreensByMenu(parseInt(menuObjid.toString())); if (assignedScreens.length > 0) { - // 할당된 화면이 있으면 첫 번째 화면으로 이동 const firstScreen = assignedScreens[0]; - // menuObjid를 쿼리 파라미터로 전달 router.push(`/screens/${firstScreen.screenId}?menuObjid=${menuObjid}`); return; } @@ -185,11 +137,9 @@ export const useMenu = (user: any, authLoading: boolean) => { console.warn("할당된 화면 조회 실패:", error); } - // 할당된 화면이 없고 URL이 있으면 기존 URL로 이동 if (menu.MENU_URL) { router.push(menu.MENU_URL); } else { - // URL도 없고 할당된 화면도 없으면 경고 메시지 console.warn("메뉴에 URL이나 할당된 화면이 없습니다:", menu); const { toast } = await import("sonner"); toast.warning("이 메뉴에는 연결된 페이지나 화면이 없습니다."); @@ -199,7 +149,6 @@ export const useMenu = (user: any, authLoading: boolean) => { [toggleMenu, router], ); - // 사용자 정보가 있고 로딩이 완료되면 메뉴 데이터 로드 useEffect(() => { if (user && !authLoading) { loadMenuData(); @@ -212,6 +161,6 @@ export const useMenu = (user: any, authLoading: boolean) => { isMenuLoading: menuState.isLoading, handleMenuClick, toggleMenu, - refreshMenus: loadMenuData, // 메뉴 새로고침 함수 추가 + refreshMenus: loadMenuData, }; }; diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index 01a069ce..7abe856c 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -73,12 +73,14 @@ const TokenManager = { setToken: (token: string): void => { if (typeof window !== "undefined") { localStorage.setItem("authToken", token); + document.cookie = `authToken=${token}; path=/; max-age=86400; SameSite=Lax`; } }, removeToken: (): void => { if (typeof window !== "undefined") { localStorage.removeItem("authToken"); + document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax"; } }, diff --git a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx index 8a34a42e..8191e68b 100644 --- a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx @@ -641,18 +641,139 @@ export function BomItemEditorComponent({ }); }; - // ─── 디자인 모드 ─── + // ─── 디자인 모드 미리보기 ─── if (isDesignMode) { + const cfg = component?.componentConfig || {}; + const hasConfig = + cfg.mainTableName || cfg.dataSource?.sourceTable || (cfg.columns && cfg.columns.length > 0); + + const sourceColumns = (cfg.columns || []).filter((c: any) => c.isSourceDisplay); + const inputColumns = (cfg.columns || []).filter((c: any) => !c.isSourceDisplay); + + if (!hasConfig) { + return ( +
+ +

+ BOM 하위 품목 편집기 +

+

+ 트리 구조로 하위 품목을 관리합니다 +

+
+ ); + } + + const dummyRows = [ + { depth: 0, code: "ASM-001", name: "본체 조립", type: "조립", qty: "1" }, + { depth: 1, code: "PRT-010", name: "프레임", type: "구매", qty: "2" }, + { depth: 1, code: "PRT-011", name: "커버", type: "구매", qty: "1" }, + { depth: 0, code: "ASM-002", name: "전장 조립", type: "조립", qty: "1" }, + { depth: 1, code: "PRT-020", name: "PCB 보드", type: "구매", qty: "3" }, + ]; + return ( -
- -

- BOM 하위 품목 편집기 -

-

- 트리 구조로 하위 품목을 관리합니다 -

+
+ {/* 헤더 */} +
+

하위 품목 구성

+ +
+ + {/* 설정 요약 뱃지 */} +
+ {cfg.mainTableName && ( + + 저장: {cfg.mainTableName} + + )} + {cfg.dataSource?.sourceTable && ( + + 소스: {cfg.dataSource.sourceTable} + + )} + {cfg.parentKeyColumn && ( + + 트리: {cfg.parentKeyColumn} + + )} + {inputColumns.length > 0 && ( + + 입력 {inputColumns.length}개 + + )} + {sourceColumns.length > 0 && ( + + 표시 {sourceColumns.length}개 + + )} +
+ + {/* 더미 트리 미리보기 */} +
+ {dummyRows.map((row, i) => ( +
0 && "border-l-2 border-l-primary/20", + i === 0 && "bg-accent/30", + )} + style={{ marginLeft: `${row.depth * 20}px` }} + > + + {row.depth === 0 ? ( + + ) : ( + + )} + + {i + 1} + + + {row.code} + + + {row.name} + + + {/* 소스 표시 컬럼 미리보기 */} + {sourceColumns.slice(0, 2).map((col: any) => ( + + {col.title} + + ))} + + {/* 입력 컬럼 미리보기 */} + {inputColumns.slice(0, 2).map((col: any) => ( +
+ {col.key === "quantity" || col.title === "수량" + ? row.qty + : ""} +
+ ))} + +
+
+ +
+
+ +
+
+
+ ))} +
); } diff --git a/frontend/lib/registry/components/v2-split-line/SplitLineComponent.tsx b/frontend/lib/registry/components/v2-split-line/SplitLineComponent.tsx index a0110195..b27f9d9f 100644 --- a/frontend/lib/registry/components/v2-split-line/SplitLineComponent.tsx +++ b/frontend/lib/registry/components/v2-split-line/SplitLineComponent.tsx @@ -110,7 +110,10 @@ export const SplitLineComponent: React.FC = ({ }; }, [isDesignMode, component.id, component.position?.x, detectCanvasWidth]); - // 드래그 핸들러 (requestAnimationFrame으로 스로틀링 → 렉 방지) + // 드래그 중 최종 오프셋 (DOM 직접 조작용) + const latestOffsetRef = useRef(dragOffset); + latestOffsetRef.current = dragOffset; + const rafIdRef = useRef(0); const handleMouseDown = useCallback( (e: React.MouseEvent) => { @@ -120,7 +123,7 @@ export const SplitLineComponent: React.FC = ({ const posX = component.position?.x || 0; const startX = e.clientX; - const startOffset = dragOffset; + const startOffset = latestOffsetRef.current; const scaleFactor = getScaleFactor(); const cw = detectCanvasWidth(); const MIN_POS = Math.max(50, cw * 0.15); @@ -130,7 +133,6 @@ export const SplitLineComponent: React.FC = ({ setCanvasSplit({ isDragging: true }); const handleMouseMove = (moveEvent: MouseEvent) => { - // rAF로 스로틀링: 프레임당 1회만 업데이트 cancelAnimationFrame(rafIdRef.current); rafIdRef.current = requestAnimationFrame(() => { const rawDelta = moveEvent.clientX - startX; @@ -141,7 +143,13 @@ export const SplitLineComponent: React.FC = ({ if (newDividerX < MIN_POS) newOffset = MIN_POS - posX; if (newDividerX > MAX_POS) newOffset = MAX_POS - posX; - setDragOffset(newOffset); + latestOffsetRef.current = newOffset; + + // 스플릿선 자체는 DOM 직접 조작 (React 리렌더 없음) + if (containerRef.current) { + containerRef.current.style.transform = `translateX(${newOffset}px)`; + } + // 스토어 업데이트 → DOM 리스너만 호출 (React 리렌더 없음) setCanvasSplit({ currentDividerX: posX + newOffset }); }); }; @@ -153,6 +161,8 @@ export const SplitLineComponent: React.FC = ({ document.body.style.userSelect = ""; document.body.style.cursor = ""; + // 최종 오프셋을 React 상태에 동기화 (1회만 리렌더) + setDragOffset(latestOffsetRef.current); setIsDragging(false); setCanvasSplit({ isDragging: false }); }; @@ -162,7 +172,7 @@ export const SplitLineComponent: React.FC = ({ document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); }, - [resizable, isDesignMode, dragOffset, component.position?.x, getScaleFactor, detectCanvasWidth], + [resizable, isDesignMode, component.position?.x, getScaleFactor, detectCanvasWidth], ); const handleClick = (e: React.MouseEvent) => { diff --git a/frontend/lib/registry/components/v2-split-line/canvasSplitStore.ts b/frontend/lib/registry/components/v2-split-line/canvasSplitStore.ts index 365d3fc3..85cdbdbd 100644 --- a/frontend/lib/registry/components/v2-split-line/canvasSplitStore.ts +++ b/frontend/lib/registry/components/v2-split-line/canvasSplitStore.ts @@ -1,27 +1,21 @@ /** * 캔버스 분할선 글로벌 스토어 * - * React Context를 우회하여 useSyncExternalStore로 직접 상태를 공유. - * SplitLineComponent가 드래그 시 이 스토어를 업데이트하고, - * RealtimePreviewDynamic이 구독하여 컴포넌트 위치를 조정. + * 성능 최적화: 이중 리스너 구조 + * - React 리스너 (subscribe): 구조적 변경만 알림 (active, isDragging 시작/종료) + * - DOM 리스너 (subscribeDom): 드래그 중 위치 변경 알림 (React 우회, 직접 DOM 조작) */ export interface CanvasSplitState { - /** 스플릿선의 초기 X 위치 (캔버스 기준 px) */ initialDividerX: number; - /** 스플릿선의 현재 X 위치 (드래그 중 변경) */ currentDividerX: number; - /** 캔버스 전체 너비 (px) */ canvasWidth: number; - /** 드래그 진행 중 여부 */ isDragging: boolean; - /** 활성 여부 (스플릿선이 등록되었는지) */ active: boolean; - /** 스코프 ID (같은 data-screen-runtime 컨테이너의 컴포넌트만 영향) */ scopeId: string; } -let state: CanvasSplitState = { +const initialState: CanvasSplitState = { initialDividerX: 0, currentDividerX: 0, canvasWidth: 0, @@ -30,44 +24,71 @@ let state: CanvasSplitState = { scopeId: "", }; -const listeners = new Set<() => void>(); +let state: CanvasSplitState = { ...initialState }; + +// React 리렌더링을 트리거하는 리스너 (구조적 변경 전용) +const reactListeners = new Set<() => void>(); + +// DOM 직접 조작용 리스너 (드래그 중 위치 업데이트, React 우회) +const domListeners = new Set<(state: CanvasSplitState) => void>(); + +// React용 스냅샷 (드래그 중 위치 변경에는 갱신 안 함) +let reactSnapshot: CanvasSplitState = { ...initialState }; export function setCanvasSplit(updates: Partial): void { state = { ...state, ...updates }; - listeners.forEach((fn) => fn()); + + // 드래그 중 위치만 변경 → DOM 리스너만 호출 (React 리렌더 없음) + const isPositionOnlyDuringDrag = + state.isDragging && + Object.keys(updates).length === 1 && + "currentDividerX" in updates; + + if (isPositionOnlyDuringDrag) { + domListeners.forEach((fn) => fn(state)); + return; + } + + // 구조적 변경 → React 리스너 + DOM 리스너 모두 호출 + reactSnapshot = { ...state }; + reactListeners.forEach((fn) => fn()); + domListeners.forEach((fn) => fn(state)); } export function resetCanvasSplit(): void { - state = { - initialDividerX: 0, - currentDividerX: 0, - canvasWidth: 0, - isDragging: false, - active: false, - scopeId: "", - }; - listeners.forEach((fn) => fn()); + state = { ...initialState }; + reactSnapshot = { ...initialState }; + reactListeners.forEach((fn) => fn()); + domListeners.forEach((fn) => fn(state)); } +// React용: useSyncExternalStore에 연결 export function subscribe(callback: () => void): () => void { - listeners.add(callback); + reactListeners.add(callback); return () => { - listeners.delete(callback); + reactListeners.delete(callback); }; } export function getSnapshot(): CanvasSplitState { - return state; + return reactSnapshot; } -// SSR 호환 export function getServerSnapshot(): CanvasSplitState { - return { - initialDividerX: 0, - currentDividerX: 0, - canvasWidth: 0, - isDragging: false, - active: false, - scopeId: "", + return { ...initialState }; +} + +// DOM 직접 조작용: 드래그 중 매 프레임 위치 업데이트 수신 +export function subscribeDom( + callback: (state: CanvasSplitState) => void, +): () => void { + domListeners.add(callback); + return () => { + domListeners.delete(callback); }; } + +// 현재 상태 직접 참조 (DOM 리스너 콜백 외부에서 최신 상태 필요 시) +export function getCurrentState(): CanvasSplitState { + return state; +} diff --git a/frontend/middleware.ts b/frontend/middleware.ts index eb42b4c2..d603adc7 100644 --- a/frontend/middleware.ts +++ b/frontend/middleware.ts @@ -4,36 +4,41 @@ import type { NextRequest } from "next/server"; /** * Next.js 미들웨어 * 페이지 렌더링 전에 실행되어 인증 상태를 확인하고 리다이렉트 처리 + * + * 주의: 미들웨어는 쿠키만 접근 가능 (localStorage 접근 불가) + * 따라서 토큰이 localStorage에만 있고 쿠키에 없는 경우는 + * 클라이언트 사이드의 AuthGuard에서 처리 */ export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; - // 인증 토큰 확인 - const token = request.cookies.get("authToken")?.value || request.headers.get("authorization")?.replace("Bearer ", ""); + const token = request.cookies.get("authToken")?.value; - // /login 페이지 접근 시 + // /login 페이지 접근 시 - 토큰이 있으면 메인으로 if (pathname === "/login") { - // 토큰이 있으면 메인 페이지로 리다이렉트 if (token) { const url = request.nextUrl.clone(); url.pathname = "/main"; return NextResponse.redirect(url); } - // 토큰이 없으면 로그인 페이지 표시 return NextResponse.next(); } - // 인증이 필요한 페이지들 - const protectedPaths = ["/main", "/admin", "/dashboard", "/settings"]; - const isProtectedPath = protectedPaths.some((path) => pathname.startsWith(path)); + // 보호 경로 목록 - 쿠키에 토큰이 없으면 로그인으로 + // 단, 쿠키 없이 localStorage에만 토큰이 있을 수 있으므로 + // 클라이언트에서 한 번 더 확인 후 리다이렉트 (AuthGuard) + const strictProtectedPaths = ["/admin"]; - if (isProtectedPath && !token) { - // 인증되지 않은 사용자는 로그인 페이지로 리다이렉트 + const isStrictProtected = strictProtectedPaths.some((path) => pathname.startsWith(path)); + + if (isStrictProtected && !token) { const url = request.nextUrl.clone(); url.pathname = "/login"; return NextResponse.redirect(url); } + // /main, /screens, /dashboard 등은 쿠키 없어도 통과 허용 + // (localStorage 토큰이 있을 수 있으므로 클라이언트 AuthGuard에 위임) return NextResponse.next(); } @@ -42,14 +47,6 @@ export function middleware(request: NextRequest) { */ export const config = { matcher: [ - /* - * 다음 경로를 제외한 모든 요청에 대해 실행: - * - api (API routes) - * - _next/static (static files) - * - _next/image (image optimization files) - * - favicon.ico (favicon file) - * - public 폴더의 파일들 - */ "/((?!api|_next/static|_next/image|favicon.ico|.*\\.png$|.*\\.jpg$|.*\\.jpeg$|.*\\.svg$).*)", ], };