2026-02-06 10:20:45 +09:00
|
|
|
import React, { useState, useMemo, useCallback } from "react";
|
2026-02-06 09:51:29 +09:00
|
|
|
import { useLayer } from "@/contexts/LayerContext";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
|
|
|
import {
|
|
|
|
|
DropdownMenu,
|
|
|
|
|
DropdownMenuContent,
|
|
|
|
|
DropdownMenuItem,
|
|
|
|
|
DropdownMenuTrigger,
|
|
|
|
|
DropdownMenuSeparator,
|
|
|
|
|
} from "@/components/ui/dropdown-menu";
|
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
2026-02-06 10:20:45 +09:00
|
|
|
import {
|
|
|
|
|
Collapsible,
|
|
|
|
|
CollapsibleContent,
|
|
|
|
|
CollapsibleTrigger,
|
|
|
|
|
} from "@/components/ui/collapsible";
|
2026-02-06 09:51:29 +09:00
|
|
|
import {
|
|
|
|
|
Eye,
|
|
|
|
|
EyeOff,
|
|
|
|
|
Lock,
|
|
|
|
|
Unlock,
|
|
|
|
|
Plus,
|
|
|
|
|
Trash2,
|
|
|
|
|
GripVertical,
|
|
|
|
|
Layers,
|
|
|
|
|
SplitSquareVertical,
|
|
|
|
|
PanelRight,
|
|
|
|
|
ChevronDown,
|
2026-02-06 10:20:45 +09:00
|
|
|
ChevronRight,
|
2026-02-06 09:51:29 +09:00
|
|
|
Settings2,
|
2026-02-06 10:20:45 +09:00
|
|
|
Zap,
|
2026-02-06 09:51:29 +09:00
|
|
|
} from "lucide-react";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
2026-02-06 10:20:45 +09:00
|
|
|
import { LayerType, LayerDefinition, ComponentData, LayerCondition } from "@/types/screen-management";
|
|
|
|
|
import { LayerConditionPanel } from "./LayerConditionPanel";
|
2026-02-06 09:51:29 +09:00
|
|
|
|
|
|
|
|
// 레이어 타입별 아이콘
|
|
|
|
|
const getLayerTypeIcon = (type: LayerType) => {
|
|
|
|
|
switch (type) {
|
|
|
|
|
case "base":
|
|
|
|
|
return <Layers className="h-3 w-3" />;
|
|
|
|
|
case "conditional":
|
|
|
|
|
return <SplitSquareVertical className="h-3 w-3" />;
|
|
|
|
|
case "modal":
|
|
|
|
|
return <Settings2 className="h-3 w-3" />;
|
|
|
|
|
case "drawer":
|
|
|
|
|
return <PanelRight className="h-3 w-3" />;
|
|
|
|
|
default:
|
|
|
|
|
return <Layers className="h-3 w-3" />;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 레이어 타입별 라벨
|
|
|
|
|
function getLayerTypeLabel(type: LayerType): string {
|
|
|
|
|
switch (type) {
|
|
|
|
|
case "base":
|
|
|
|
|
return "기본";
|
|
|
|
|
case "conditional":
|
|
|
|
|
return "조건부";
|
|
|
|
|
case "modal":
|
|
|
|
|
return "모달";
|
|
|
|
|
case "drawer":
|
|
|
|
|
return "드로어";
|
|
|
|
|
default:
|
|
|
|
|
return type;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 레이어 타입별 색상
|
|
|
|
|
function getLayerTypeColor(type: LayerType): string {
|
|
|
|
|
switch (type) {
|
|
|
|
|
case "base":
|
|
|
|
|
return "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300";
|
|
|
|
|
case "conditional":
|
|
|
|
|
return "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300";
|
|
|
|
|
case "modal":
|
|
|
|
|
return "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300";
|
|
|
|
|
case "drawer":
|
|
|
|
|
return "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300";
|
|
|
|
|
default:
|
|
|
|
|
return "bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-300";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface LayerItemProps {
|
|
|
|
|
layer: LayerDefinition;
|
|
|
|
|
isActive: boolean;
|
2026-02-06 10:20:45 +09:00
|
|
|
componentCount: number; // 실제 컴포넌트 수 (layout.components 기반)
|
|
|
|
|
allComponents: ComponentData[]; // 조건 설정에 필요한 전체 컴포넌트
|
2026-02-06 09:51:29 +09:00
|
|
|
onSelect: () => void;
|
|
|
|
|
onToggleVisibility: () => void;
|
|
|
|
|
onToggleLock: () => void;
|
|
|
|
|
onRemove: () => void;
|
|
|
|
|
onUpdateName: (name: string) => void;
|
2026-02-06 10:20:45 +09:00
|
|
|
onUpdateCondition: (condition: LayerCondition | undefined) => void;
|
2026-02-06 09:51:29 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const LayerItem: React.FC<LayerItemProps> = ({
|
|
|
|
|
layer,
|
|
|
|
|
isActive,
|
|
|
|
|
componentCount,
|
2026-02-06 10:20:45 +09:00
|
|
|
allComponents,
|
2026-02-06 09:51:29 +09:00
|
|
|
onSelect,
|
|
|
|
|
onToggleVisibility,
|
|
|
|
|
onToggleLock,
|
|
|
|
|
onRemove,
|
|
|
|
|
onUpdateName,
|
2026-02-06 10:20:45 +09:00
|
|
|
onUpdateCondition,
|
2026-02-06 09:51:29 +09:00
|
|
|
}) => {
|
|
|
|
|
const [isEditing, setIsEditing] = useState(false);
|
2026-02-06 10:20:45 +09:00
|
|
|
const [isConditionOpen, setIsConditionOpen] = useState(false);
|
|
|
|
|
|
|
|
|
|
// 조건부 레이어인지 확인
|
|
|
|
|
const isConditionalLayer = layer.type === "conditional";
|
|
|
|
|
// 조건 설정 여부
|
|
|
|
|
const hasCondition = !!layer.condition;
|
2026-02-06 09:51:29 +09:00
|
|
|
|
|
|
|
|
return (
|
2026-02-06 10:20:45 +09:00
|
|
|
<div className="space-y-0">
|
|
|
|
|
{/* 레이어 메인 영역 */}
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex items-center gap-2 rounded-md border p-2 text-sm transition-all cursor-pointer",
|
|
|
|
|
isActive
|
|
|
|
|
? "border-primary bg-primary/5 shadow-sm"
|
|
|
|
|
: "hover:bg-muted border-transparent",
|
|
|
|
|
!layer.isVisible && "opacity-50",
|
|
|
|
|
isConditionOpen && "rounded-b-none border-b-0",
|
|
|
|
|
)}
|
|
|
|
|
onClick={onSelect}
|
|
|
|
|
>
|
|
|
|
|
{/* 드래그 핸들 */}
|
|
|
|
|
<GripVertical className="text-muted-foreground h-4 w-4 cursor-grab flex-shrink-0" />
|
2026-02-06 09:51:29 +09:00
|
|
|
|
2026-02-06 10:20:45 +09:00
|
|
|
{/* 레이어 정보 */}
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
{/* 레이어 타입 아이콘 */}
|
|
|
|
|
<span className={cn("flex-shrink-0", getLayerTypeColor(layer.type), "p-1 rounded")}>
|
|
|
|
|
{getLayerTypeIcon(layer.type)}
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
{/* 레이어 이름 */}
|
|
|
|
|
{isEditing ? (
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={layer.name}
|
|
|
|
|
onChange={(e) => onUpdateName(e.target.value)}
|
|
|
|
|
onBlur={() => setIsEditing(false)}
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
if (e.key === "Enter") setIsEditing(false);
|
|
|
|
|
}}
|
|
|
|
|
className="flex-1 bg-transparent outline-none border-b border-primary text-sm"
|
|
|
|
|
autoFocus
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<span
|
|
|
|
|
className="flex-1 truncate font-medium"
|
|
|
|
|
onDoubleClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
setIsEditing(true);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{layer.name}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-02-06 09:51:29 +09:00
|
|
|
|
2026-02-06 10:20:45 +09:00
|
|
|
{/* 레이어 메타 정보 */}
|
|
|
|
|
<div className="flex items-center gap-2 mt-0.5">
|
|
|
|
|
<Badge variant="outline" className="text-[10px] px-1 py-0 h-4">
|
|
|
|
|
{getLayerTypeLabel(layer.type)}
|
|
|
|
|
</Badge>
|
|
|
|
|
<span className="text-muted-foreground text-[10px]">
|
|
|
|
|
{componentCount}개 컴포넌트
|
2026-02-06 09:51:29 +09:00
|
|
|
</span>
|
2026-02-06 10:20:45 +09:00
|
|
|
{/* 조건 설정됨 표시 */}
|
|
|
|
|
{hasCondition && (
|
|
|
|
|
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4 gap-0.5">
|
|
|
|
|
<Zap className="h-2.5 w-2.5" />
|
|
|
|
|
조건
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-02-06 09:51:29 +09:00
|
|
|
</div>
|
|
|
|
|
|
2026-02-06 10:20:45 +09:00
|
|
|
{/* 액션 버튼들 */}
|
|
|
|
|
<div className="flex items-center gap-0.5 flex-shrink-0">
|
|
|
|
|
{/* 조건부 레이어일 때 조건 설정 버튼 */}
|
|
|
|
|
{isConditionalLayer && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
className={cn(
|
|
|
|
|
"h-6 w-6",
|
|
|
|
|
hasCondition && "text-amber-600"
|
|
|
|
|
)}
|
|
|
|
|
title="조건 설정"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
setIsConditionOpen(!isConditionOpen);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{isConditionOpen ? (
|
|
|
|
|
<ChevronDown className="h-3.5 w-3.5" />
|
|
|
|
|
) : (
|
|
|
|
|
<ChevronRight className="h-3.5 w-3.5" />
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
2026-02-06 09:51:29 +09:00
|
|
|
)}
|
2026-02-06 10:20:45 +09:00
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
className="h-6 w-6"
|
|
|
|
|
title={layer.isVisible ? "레이어 숨기기" : "레이어 표시"}
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
onToggleVisibility();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{layer.isVisible ? (
|
|
|
|
|
<Eye className="h-3.5 w-3.5" />
|
|
|
|
|
) : (
|
|
|
|
|
<EyeOff className="text-muted-foreground h-3.5 w-3.5" />
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
2026-02-06 09:51:29 +09:00
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
2026-02-06 10:20:45 +09:00
|
|
|
className="h-6 w-6"
|
|
|
|
|
title={layer.isLocked ? "편집 잠금 해제" : "편집 잠금"}
|
2026-02-06 09:51:29 +09:00
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
2026-02-06 10:20:45 +09:00
|
|
|
onToggleLock();
|
2026-02-06 09:51:29 +09:00
|
|
|
}}
|
|
|
|
|
>
|
2026-02-06 10:20:45 +09:00
|
|
|
{layer.isLocked ? (
|
|
|
|
|
<Lock className="text-destructive h-3.5 w-3.5" />
|
|
|
|
|
) : (
|
|
|
|
|
<Unlock className="text-muted-foreground h-3.5 w-3.5" />
|
|
|
|
|
)}
|
2026-02-06 09:51:29 +09:00
|
|
|
</Button>
|
2026-02-06 10:20:45 +09:00
|
|
|
|
|
|
|
|
{layer.type !== "base" && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
className="hover:text-destructive h-6 w-6"
|
|
|
|
|
title="레이어 삭제"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
onRemove();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-02-06 09:51:29 +09:00
|
|
|
</div>
|
2026-02-06 10:20:45 +09:00
|
|
|
|
|
|
|
|
{/* 조건 설정 패널 (조건부 레이어만) */}
|
|
|
|
|
{isConditionalLayer && isConditionOpen && (
|
|
|
|
|
<div className={cn(
|
|
|
|
|
"border border-t-0 rounded-b-md bg-muted/30",
|
|
|
|
|
isActive ? "border-primary" : "border-border"
|
|
|
|
|
)}>
|
|
|
|
|
<LayerConditionPanel
|
|
|
|
|
layer={layer}
|
|
|
|
|
components={allComponents}
|
|
|
|
|
onUpdateCondition={onUpdateCondition}
|
|
|
|
|
onClose={() => setIsConditionOpen(false)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-02-06 09:51:29 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
interface LayerManagerPanelProps {
|
|
|
|
|
components?: ComponentData[]; // layout.components를 전달받음
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({ components = [] }) => {
|
|
|
|
|
const {
|
|
|
|
|
layers,
|
|
|
|
|
activeLayerId,
|
|
|
|
|
setActiveLayerId,
|
|
|
|
|
addLayer,
|
|
|
|
|
removeLayer,
|
|
|
|
|
toggleLayerVisibility,
|
|
|
|
|
toggleLayerLock,
|
|
|
|
|
updateLayer,
|
|
|
|
|
} = useLayer();
|
|
|
|
|
|
2026-02-06 10:20:45 +09:00
|
|
|
// 레이어 조건 업데이트 핸들러
|
|
|
|
|
const handleUpdateCondition = useCallback((layerId: string, condition: LayerCondition | undefined) => {
|
|
|
|
|
updateLayer(layerId, { condition });
|
|
|
|
|
}, [updateLayer]);
|
|
|
|
|
|
2026-02-06 09:51:29 +09:00
|
|
|
// 🆕 각 레이어별 컴포넌트 수 계산 (layout.components 기반)
|
|
|
|
|
const componentCountByLayer = useMemo(() => {
|
|
|
|
|
const counts: Record<string, number> = {};
|
|
|
|
|
|
|
|
|
|
// 모든 레이어를 0으로 초기화
|
|
|
|
|
layers.forEach(layer => {
|
|
|
|
|
counts[layer.id] = 0;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// layout.components에서 layerId별로 카운트
|
|
|
|
|
components.forEach(comp => {
|
|
|
|
|
const layerId = comp.layerId || "default-layer";
|
|
|
|
|
if (counts[layerId] !== undefined) {
|
|
|
|
|
counts[layerId]++;
|
|
|
|
|
} else {
|
|
|
|
|
// layerId가 존재하지 않는 레이어인 경우 default-layer로 카운트
|
|
|
|
|
if (counts["default-layer"] !== undefined) {
|
|
|
|
|
counts["default-layer"]++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return counts;
|
|
|
|
|
}, [components, layers]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="bg-background flex h-full flex-col">
|
|
|
|
|
{/* 헤더 */}
|
|
|
|
|
<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="text-[10px] px-1.5 py-0">
|
|
|
|
|
{layers.length}
|
|
|
|
|
</Badge>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 레이어 추가 드롭다운 */}
|
|
|
|
|
<DropdownMenu>
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
<Button variant="ghost" size="sm" className="h-7 px-2 gap-1">
|
|
|
|
|
<Plus className="h-3.5 w-3.5" />
|
|
|
|
|
<ChevronDown className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
<DropdownMenuContent align="end">
|
|
|
|
|
<DropdownMenuItem onClick={() => addLayer("conditional", "조건부 레이어")}>
|
|
|
|
|
<SplitSquareVertical className="h-4 w-4 mr-2" />
|
|
|
|
|
조건부 레이어
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
|
<DropdownMenuItem onClick={() => addLayer("modal", "모달 레이어")}>
|
|
|
|
|
<Settings2 className="h-4 w-4 mr-2" />
|
|
|
|
|
모달 레이어
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
<DropdownMenuItem onClick={() => addLayer("drawer", "드로어 레이어")}>
|
|
|
|
|
<PanelRight className="h-4 w-4 mr-2" />
|
|
|
|
|
드로어 레이어
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 레이어 목록 */}
|
|
|
|
|
<ScrollArea className="flex-1">
|
|
|
|
|
<div className="space-y-1 p-2">
|
|
|
|
|
{layers.length === 0 ? (
|
|
|
|
|
<div className="text-center text-muted-foreground text-sm py-8">
|
|
|
|
|
레이어가 없습니다.
|
|
|
|
|
<br />
|
|
|
|
|
<span className="text-xs">위의 + 버튼으로 추가하세요.</span>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
layers
|
|
|
|
|
.slice()
|
|
|
|
|
.reverse() // 상위 레이어가 위에 표시
|
|
|
|
|
.map((layer) => (
|
|
|
|
|
<LayerItem
|
|
|
|
|
key={layer.id}
|
|
|
|
|
layer={layer}
|
|
|
|
|
isActive={activeLayerId === layer.id}
|
|
|
|
|
componentCount={componentCountByLayer[layer.id] || 0}
|
2026-02-06 10:20:45 +09:00
|
|
|
allComponents={components}
|
2026-02-06 09:51:29 +09:00
|
|
|
onSelect={() => setActiveLayerId(layer.id)}
|
|
|
|
|
onToggleVisibility={() => toggleLayerVisibility(layer.id)}
|
|
|
|
|
onToggleLock={() => toggleLayerLock(layer.id)}
|
|
|
|
|
onRemove={() => removeLayer(layer.id)}
|
|
|
|
|
onUpdateName={(name) => updateLayer(layer.id, { name })}
|
2026-02-06 10:20:45 +09:00
|
|
|
onUpdateCondition={(condition) => handleUpdateCondition(layer.id, condition)}
|
2026-02-06 09:51:29 +09:00
|
|
|
/>
|
|
|
|
|
))
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</ScrollArea>
|
|
|
|
|
|
|
|
|
|
{/* 도움말 */}
|
|
|
|
|
<div className="border-t px-3 py-2 text-[10px] text-muted-foreground">
|
|
|
|
|
<p>더블클릭: 이름 편집 | 드래그: 순서 변경</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|