- 레이어 저장 로직을 개선하여 conditionConfig의 명시적 전달 여부에 따라 저장 방식을 다르게 처리하도록 변경했습니다. - 조건부 레이어 로드 및 조건 평가 기능을 추가하여 레이어의 가시성을 동적으로 조정할 수 있도록 했습니다. - 컴포넌트 위치 변경 시 모든 애니메이션을 제거하여 사용자 경험을 개선했습니다. - LayerConditionPanel에서 조건 설정 시 기존 displayRegion을 보존하도록 업데이트했습니다. - RealtimePreview 및 ScreenDesigner에서 조건부 레이어의 크기를 적절히 조정하도록 수정했습니다.
339 lines
13 KiB
TypeScript
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>
|
|
);
|
|
};
|