refactor: Update middleware and enhance component interactions

- Improved the middleware to handle authentication checks more effectively, ensuring that users are redirected appropriately based on their authentication status.
- Updated the InteractiveScreenViewerDynamic and RealtimePreviewDynamic components to utilize a new subscription method for DOM manipulation during drag events, enhancing performance and user experience.
- Refactored the SplitLineComponent to optimize drag handling and state management, ensuring smoother interactions during component adjustments.
- Integrated API client for menu data loading, streamlining token management and error handling.
This commit is contained in:
DDD1542 2026-02-24 11:02:43 +09:00
parent 27853a9447
commit 5afa373b1f
8 changed files with 349 additions and 144 deletions

View File

@ -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<InteractiveScreenViewerPro
: undefined,
};
// 스플릿 조정된 컴포넌트 객체 캐싱 (드래그 끝난 후 최종 렌더링용)
const splitAdjustedComponent = React.useMemo(() => {
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<HTMLDivElement>(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 (
<>
<div id={`interactive-${component.id}`} className="absolute" style={componentStyle}>
{renderInteractiveWidget(
isSplitActive && adjustedW !== origW
? { ...component, size: { ...(component as any).size, width: adjustedW } }
: component
)}
<div ref={elRef} id={`interactive-${component.id}`} className="absolute" style={componentStyle}>
{renderInteractiveWidget(splitAdjustedComponent)}
</div>
{/* 팝업 화면 렌더링 */}

View File

@ -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<RealtimePreviewProps> = ({
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 (
<div
ref={outerDivRef}
@ -629,10 +684,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
style={{ width: "100%", maxWidth: "100%" }}
>
<DynamicComponentRenderer
component={isSplitShrunk
? { ...enhancedComponent, size: { ...(enhancedComponent as any).size, width: splitAdjustedWidth } }
: enhancedComponent
}
component={splitAdjustedComp}
isSelected={isSelected}
isDesignMode={isDesignMode}
isInteractive={!isDesignMode} // 편집 모드가 아닐 때만 인터랙티브

View File

@ -3,15 +3,16 @@
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { MenuItem, MenuState } from "@/types/menu";
import { LAYOUT_CONFIG } from "@/constants/layout";
import { apiClient } from "@/lib/api/client";
/**
*
* - API apiClient를 ( /401 )
* - / (client.ts가 )
*/
export const useMenu = (user: any, authLoading: boolean) => {
const router = useRouter();
// 상태 관리
const [menuState, setMenuState] = useState<MenuState>({
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<string, MenuItem>();
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,
};
};

View File

@ -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";
}
},

View File

@ -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 (
<div className="rounded-md border border-dashed p-6 text-center">
<Package className="text-muted-foreground mx-auto mb-2 h-8 w-8" />
<p className="text-muted-foreground text-sm font-medium">
BOM
</p>
<p className="text-muted-foreground text-xs">
</p>
</div>
);
}
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 (
<div className="rounded-md border border-dashed p-6 text-center">
<Package className="text-muted-foreground mx-auto mb-2 h-8 w-8" />
<p className="text-muted-foreground text-sm font-medium">
BOM
</p>
<p className="text-muted-foreground text-xs">
</p>
<div className="space-y-2">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold"> </h4>
<Button size="sm" className="h-7 text-xs" disabled>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{/* 설정 요약 뱃지 */}
<div className="flex flex-wrap gap-1">
{cfg.mainTableName && (
<span className="rounded bg-orange-100 px-1.5 py-0.5 text-[10px] text-orange-700">
: {cfg.mainTableName}
</span>
)}
{cfg.dataSource?.sourceTable && (
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700">
: {cfg.dataSource.sourceTable}
</span>
)}
{cfg.parentKeyColumn && (
<span className="rounded bg-green-100 px-1.5 py-0.5 text-[10px] text-green-700">
: {cfg.parentKeyColumn}
</span>
)}
{inputColumns.length > 0 && (
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600">
{inputColumns.length}
</span>
)}
{sourceColumns.length > 0 && (
<span className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] text-blue-600">
{sourceColumns.length}
</span>
)}
</div>
{/* 더미 트리 미리보기 */}
<div className="space-y-0.5 rounded-md border p-1.5">
{dummyRows.map((row, i) => (
<div
key={i}
className={cn(
"flex items-center gap-1.5 rounded px-1.5 py-1",
row.depth > 0 && "border-l-2 border-l-primary/20",
i === 0 && "bg-accent/30",
)}
style={{ marginLeft: `${row.depth * 20}px` }}
>
<GripVertical className="text-muted-foreground h-3 w-3 shrink-0 opacity-40" />
{row.depth === 0 ? (
<ChevronDown className="h-3 w-3 shrink-0 opacity-50" />
) : (
<span className="w-3" />
)}
<span className="text-muted-foreground w-4 text-center text-[10px]">
{i + 1}
</span>
<span className="w-16 shrink-0 truncate font-mono text-[10px] font-medium">
{row.code}
</span>
<span className="min-w-[50px] flex-1 truncate text-[10px]">
{row.name}
</span>
{/* 소스 표시 컬럼 미리보기 */}
{sourceColumns.slice(0, 2).map((col: any) => (
<span
key={col.key}
className="w-12 shrink-0 truncate text-center text-[10px] text-blue-500"
>
{col.title}
</span>
))}
{/* 입력 컬럼 미리보기 */}
{inputColumns.slice(0, 2).map((col: any) => (
<div
key={col.key}
className="h-5 w-12 shrink-0 rounded border bg-background text-center text-[10px] leading-5"
>
{col.key === "quantity" || col.title === "수량"
? row.qty
: ""}
</div>
))}
<div className="flex shrink-0 gap-0.5">
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center rounded opacity-40">
<Plus className="h-3 w-3" />
</div>
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center rounded opacity-40">
<X className="h-3 w-3" />
</div>
</div>
</div>
))}
</div>
</div>
);
}

View File

@ -110,7 +110,10 @@ export const SplitLineComponent: React.FC<SplitLineComponentProps> = ({
};
}, [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<SplitLineComponentProps> = ({
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<SplitLineComponentProps> = ({
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<SplitLineComponentProps> = ({
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<SplitLineComponentProps> = ({
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<SplitLineComponentProps> = ({
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) => {

View File

@ -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<CanvasSplitState>): 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;
}

View File

@ -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$).*)",
],
};