Files
vexplor/frontend/components/screen/LayerManagerPanel.tsx
kjs 7dc0bbb329 feat: 조건부 레이어 관리 및 애니메이션 최적화
- 레이어 저장 로직을 개선하여 conditionConfig의 명시적 전달 여부에 따라 저장 방식을 다르게 처리하도록 변경했습니다.
- 조건부 레이어 로드 및 조건 평가 기능을 추가하여 레이어의 가시성을 동적으로 조정할 수 있도록 했습니다.
- 컴포넌트 위치 변경 시 모든 애니메이션을 제거하여 사용자 경험을 개선했습니다.
- LayerConditionPanel에서 조건 설정 시 기존 displayRegion을 보존하도록 업데이트했습니다.
- RealtimePreview 및 ScreenDesigner에서 조건부 레이어의 크기를 적절히 조정하도록 수정했습니다.
2026-02-09 15:02:53 +09:00

339 lines
13 KiB
TypeScript

import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import {
Plus,
Trash2,
GripVertical,
Layers,
SplitSquareVertical,
ChevronDown,
ChevronRight,
Zap,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { screenApi } from "@/lib/api/screen";
import { convertV2ToLegacy } from "@/lib/utils/layoutV2Converter";
import { toast } from "sonner";
import { LayerConditionPanel } from "./LayerConditionPanel";
import { ComponentData, LayerCondition, DisplayRegion } from "@/types/screen-management";
// DB 레이어 타입
interface DBLayer {
layer_id: number;
layer_name: string;
condition_config: any;
component_count: number;
updated_at: string;
}
interface LayerManagerPanelProps {
screenId: number | null;
activeLayerId: number; // 현재 활성 레이어 ID (DB layer_id)
onLayerChange: (layerId: number) => void; // 레이어 전환
components?: ComponentData[]; // 현재 활성 레이어의 컴포넌트 (폴백용)
}
export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
screenId,
activeLayerId,
onLayerChange,
components = [],
}) => {
const [layers, setLayers] = useState<DBLayer[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [conditionOpenLayerId, setConditionOpenLayerId] = useState<number | null>(null);
// 기본 레이어(layer_id=1)의 컴포넌트 (조건 설정 시 트리거 대상)
const [baseLayerComponents, setBaseLayerComponents] = useState<ComponentData[]>([]);
// 레이어 목록 로드
const loadLayers = useCallback(async () => {
if (!screenId) return;
setIsLoading(true);
try {
const data = await screenApi.getScreenLayers(screenId);
setLayers(data);
} catch (error) {
console.error("레이어 목록 로드 실패:", error);
} finally {
setIsLoading(false);
}
}, [screenId]);
// 기본 레이어 컴포넌트 로드 (조건 설정 패널에서 트리거 컴포넌트 선택용)
const loadBaseLayerComponents = useCallback(async () => {
if (!screenId) return;
try {
const data = await screenApi.getLayerLayout(screenId, 1);
if (data && data.components) {
const legacy = convertV2ToLegacy(data);
if (legacy) {
setBaseLayerComponents(legacy.components as ComponentData[]);
return;
}
}
setBaseLayerComponents([]);
} catch {
// 기본 레이어가 없거나 로드 실패 시 현재 컴포넌트 사용
setBaseLayerComponents(components);
}
}, [screenId, components]);
useEffect(() => {
loadLayers();
}, [loadLayers]);
// 조건 설정 패널이 열릴 때 기본 레이어 컴포넌트 로드
useEffect(() => {
if (conditionOpenLayerId !== null) {
loadBaseLayerComponents();
}
}, [conditionOpenLayerId, loadBaseLayerComponents]);
// 새 레이어 추가
const handleAddLayer = useCallback(async () => {
if (!screenId) return;
// 다음 layer_id 계산
const maxLayerId = layers.length > 0 ? Math.max(...layers.map((l) => l.layer_id)) : 0;
const newLayerId = maxLayerId + 1;
try {
// 빈 레이아웃으로 새 레이어 저장
await screenApi.saveLayoutV2(screenId, {
version: "2.0",
components: [],
layerId: newLayerId,
layerName: `조건부 레이어 ${newLayerId}`,
});
toast.success(`조건부 레이어 ${newLayerId}가 생성되었습니다.`);
await loadLayers();
// 새 레이어로 전환
onLayerChange(newLayerId);
} catch (error) {
console.error("레이어 추가 실패:", error);
toast.error("레이어 추가에 실패했습니다.");
}
}, [screenId, layers, loadLayers, onLayerChange]);
// 레이어 삭제
const handleDeleteLayer = useCallback(async (layerId: number) => {
if (!screenId || layerId === 1) return;
try {
await screenApi.deleteLayer(screenId, layerId);
toast.success("레이어가 삭제되었습니다.");
await loadLayers();
// 기본 레이어로 전환
if (activeLayerId === layerId) {
onLayerChange(1);
}
} catch (error) {
console.error("레이어 삭제 실패:", error);
toast.error("레이어 삭제에 실패했습니다.");
}
}, [screenId, activeLayerId, loadLayers, onLayerChange]);
// 조건 업데이트 (기존 condition_config의 displayRegion 보존)
const handleUpdateCondition = useCallback(async (layerId: number, condition: LayerCondition | undefined) => {
if (!screenId) return;
try {
// 기존 condition_config를 가져와서 displayRegion 보존
const layerData = await screenApi.getLayerLayout(screenId, layerId);
const existingCondition = layerData?.conditionConfig || {};
const displayRegion = existingCondition.displayRegion;
let mergedCondition: any;
if (condition) {
// 조건 설정: 새 조건 + 기존 displayRegion 보존
mergedCondition = { ...condition, ...(displayRegion ? { displayRegion } : {}) };
} else {
// 조건 삭제: displayRegion만 남기거나, 없으면 null
mergedCondition = displayRegion ? { displayRegion } : null;
}
await screenApi.updateLayerCondition(screenId, layerId, mergedCondition);
toast.success("조건이 저장되었습니다.");
await loadLayers();
} catch (error) {
console.error("조건 업데이트 실패:", error);
toast.error("조건 저장에 실패했습니다.");
}
}, [screenId, loadLayers]);
return (
<div className="flex h-full flex-col bg-background">
{/* 헤더 */}
<div className="flex items-center justify-between border-b px-3 py-2">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold"></h3>
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
{layers.length}
</Badge>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 px-2"
onClick={handleAddLayer}
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
{/* 레이어 목록 */}
<ScrollArea className="flex-1">
<div className="space-y-1 p-2">
{isLoading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<span className="text-sm"> ...</span>
</div>
) : layers.length === 0 ? (
<div className="space-y-2 py-4 text-center">
<p className="text-sm text-muted-foreground"> ...</p>
<p className="text-[10px] text-muted-foreground"> .</p>
</div>
) : (
layers
.slice()
.reverse()
.map((layer) => {
const isActive = activeLayerId === layer.layer_id;
const isBase = layer.layer_id === 1;
const hasCondition = !!layer.condition_config;
const isConditionOpen = conditionOpenLayerId === layer.layer_id;
return (
<div key={layer.layer_id} className="space-y-0">
<div
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md border p-2 text-sm transition-all",
isActive
? "border-primary bg-primary/5 shadow-sm"
: "border-transparent hover:bg-muted",
isConditionOpen && "rounded-b-none border-b-0",
)}
onClick={() => onLayerChange(layer.layer_id)}
// 조건부 레이어를 캔버스로 드래그 (영역 배치용)
draggable={!isBase}
onDragStart={(e) => {
if (isBase) return;
e.dataTransfer.setData("application/json", JSON.stringify({
type: "layer-region",
layerId: layer.layer_id,
layerName: layer.layer_name,
}));
e.dataTransfer.effectAllowed = "copy";
}}
>
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className={cn(
"shrink-0 rounded p-1",
isBase
? "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
)}>
{isBase ? <Layers className="h-3 w-3" /> : <SplitSquareVertical className="h-3 w-3" />}
</span>
<span className="flex-1 truncate font-medium">{layer.layer_name}</span>
</div>
<div className="mt-0.5 flex items-center gap-2">
<Badge variant="outline" className="h-4 px-1 py-0 text-[10px]">
{isBase ? "기본" : "조건부"}
</Badge>
<span className="text-[10px] text-muted-foreground">
{layer.component_count}
</span>
{hasCondition && (
<Badge variant="secondary" className="h-4 gap-0.5 px-1 py-0 text-[10px]">
<Zap className="h-2.5 w-2.5" />
</Badge>
)}
</div>
</div>
{/* 액션 버튼 */}
<div className="flex shrink-0 items-center gap-0.5">
{!isBase && (
<Button
variant="ghost"
size="icon"
className={cn("h-6 w-6", hasCondition && "text-amber-600")}
title="조건 설정"
onClick={(e) => {
e.stopPropagation();
setConditionOpenLayerId(isConditionOpen ? null : layer.layer_id);
}}
>
{isConditionOpen ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</Button>
)}
{!isBase && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 hover:text-destructive"
title="레이어 삭제"
onClick={(e) => {
e.stopPropagation();
handleDeleteLayer(layer.layer_id);
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
{/* 조건 설정 패널 */}
{!isBase && isConditionOpen && (
<div className={cn(
"rounded-b-md border border-t-0 bg-muted/30",
isActive ? "border-primary" : "border-border",
)}>
<LayerConditionPanel
layer={{
id: String(layer.layer_id),
name: layer.layer_name,
type: "conditional",
zIndex: layer.layer_id,
isVisible: true,
isLocked: false,
condition: layer.condition_config || undefined,
components: [],
}}
components={baseLayerComponents}
baseLayerComponents={baseLayerComponents}
onUpdateCondition={(condition) => handleUpdateCondition(layer.layer_id, condition)}
onUpdateDisplayRegion={() => {}}
onClose={() => setConditionOpenLayerId(null)}
/>
</div>
)}
</div>
);
})
)}
</div>
</ScrollArea>
{/* 도움말 */}
<div className="border-t px-3 py-2 text-[10px] text-muted-foreground">
<p> | </p>
</div>
</div>
);
};