2c0a97f2ba
- components/builder/* 폐기 (12-grid 미완성 빌더 14개 파일)
- components/template-builder/TemplateBuilder.tsx 신규
(자유배치 + 3뷰 + Zustand 스토어 + 드래그/리사이즈/히스토리/격자)
- admin/builder/page.tsx 진입점 전환 (BuilderLayout → TemplateBuilder)
- 타입 정리: FreePosition / TemplateComponent / ViewConfig / Card /
Dashboard / CardConnection 추가, 레거시(GridPosition/TemplateKind/
DEFAULT_COMPONENT_LAYOUTS/CANVAS_KEYWORDS) @deprecated 표기
- v2-* 마이그레이션 1차:
· 완전: v2-table-list (ResizeObserver), v2-table-search-widget (@container)
· 경량: button/input/select/date/text-display/card-display/aggregation-widget
(withContainerQuery HOC)
- 다크 모드 대응: Tailwind dark: variant 21패턴 71곳 치환
- /test-card-responsive PoC 검증 페이지
세션 후반 버그 픽스 (phase1-log §7):
- test-card-responsive (main) 그룹 밖 이동 (AppLayout 탭 시스템 회피)
- useRegistryPalette default_size {width,height}/{w,h} 포맷 정규화
- dark: variant 중복 체인 정리
검증: (A) 반응형 메커니즘, (B) TemplateBuilder UI 통과
(C) 기존 VEX 화면은 마이그레이션 미완 상태라 Phase 2 이후 개별 진행
스펙: notes/gbpark/2026-04-10-card-engine-final-spec.md
로그: notes/gbpark/2026-04-10-card-engine-phase1-log.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
806 lines
29 KiB
TypeScript
806 lines
29 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import {
|
||
useTemplateBuilderStore,
|
||
useCurrentViewBlocks,
|
||
useSelectedBlock,
|
||
canUndo,
|
||
canRedo,
|
||
type BuilderView,
|
||
} from "./store/templateBuilderStore";
|
||
import type { FreePosition, TemplateComponent, Template } from "@/types/invyone-component";
|
||
|
||
const STORAGE_KEY_PREFIX = "invyone-template:";
|
||
const LAST_TEMPLATE_KEY = "invyone-template:__last__";
|
||
|
||
const VIEW_LABELS: Record<BuilderView, string> = {
|
||
list: "목록",
|
||
create: "등록",
|
||
edit: "수정",
|
||
};
|
||
|
||
const MIN_BLOCK_SIZE = 80;
|
||
|
||
interface PaletteItem {
|
||
id: string;
|
||
name: string;
|
||
icon: string;
|
||
category: string;
|
||
defaultSize: { w: number; h: number };
|
||
defaultConfig?: Record<string, any>;
|
||
}
|
||
|
||
const FALLBACK_PALETTE: PaletteItem[] = [
|
||
{ id: "v2-table-list", name: "데이터 테이블", icon: "▦", category: "데이터", defaultSize: { w: 640, h: 360 } },
|
||
{ id: "v2-table-search-widget", name: "검색 필터", icon: "⌕", category: "데이터", defaultSize: { w: 640, h: 80 } },
|
||
{ id: "v2-aggregation-widget", name: "KPI 집계", icon: "Σ", category: "데이터", defaultSize: { w: 320, h: 160 } },
|
||
{ id: "v2-card-display", name: "카드 표시", icon: "▢", category: "데이터", defaultSize: { w: 280, h: 180 } },
|
||
{
|
||
id: "v2-button-primary",
|
||
name: "등록 버튼",
|
||
icon: "+",
|
||
category: "액션",
|
||
defaultSize: { w: 120, h: 36 },
|
||
defaultConfig: { text: "등록", actionType: "add", variant: "primary" },
|
||
},
|
||
{ id: "v2-input", name: "입력", icon: "▭", category: "폼", defaultSize: { w: 260, h: 48 } },
|
||
{ id: "v2-select", name: "드롭다운", icon: "▽", category: "폼", defaultSize: { w: 260, h: 48 } },
|
||
{ id: "v2-date", name: "날짜", icon: "📅", category: "폼", defaultSize: { w: 260, h: 48 } },
|
||
{ id: "v2-text-display", name: "텍스트", icon: "𝐓", category: "표시", defaultSize: { w: 200, h: 40 } },
|
||
];
|
||
|
||
function useRegistryPalette(): PaletteItem[] {
|
||
const [items, setItems] = useState<PaletteItem[] | null>(null);
|
||
useEffect(() => {
|
||
let alive = true;
|
||
(async () => {
|
||
try {
|
||
const mod: any = await import("@/lib/registry/ComponentRegistry");
|
||
const Registry = mod?.ComponentRegistry;
|
||
if (!Registry) return;
|
||
const all = Registry.getAllComponents?.() ?? [];
|
||
if (!alive || all.length === 0) return;
|
||
const mapped: PaletteItem[] = all.map((c: any) => {
|
||
// VEX ComponentDefinition 은 { width, height }, 새 포맷은 { w, h } — 둘 다 지원
|
||
const rawSize = c.default_size ?? {};
|
||
const defaultSize = {
|
||
w: rawSize.w ?? rawSize.width ?? 280,
|
||
h: rawSize.h ?? rawSize.height ?? 180,
|
||
};
|
||
// lucide-react 컴포넌트 객체면 ◼ 로, 짧은 문자열(이모지 등) 만 표시
|
||
const rawIcon = c.icon;
|
||
const icon =
|
||
typeof rawIcon === "string" && rawIcon.length > 0 && rawIcon.length <= 2
|
||
? rawIcon
|
||
: "◼";
|
||
return {
|
||
id: c.id,
|
||
name: c.name || c.id,
|
||
icon,
|
||
category: (c.category as string) || "기타",
|
||
defaultSize,
|
||
defaultConfig: c.default_config,
|
||
};
|
||
});
|
||
setItems(mapped);
|
||
} catch {
|
||
// ComponentRegistry 미초기화. fallback 사용
|
||
}
|
||
})();
|
||
return () => {
|
||
alive = false;
|
||
};
|
||
}, []);
|
||
return items && items.length > 0 ? items : FALLBACK_PALETTE;
|
||
}
|
||
|
||
function snapValue(value: number, step: number, enabled: boolean): number {
|
||
if (!enabled || step <= 0) return value;
|
||
return Math.round(value / step) * step;
|
||
}
|
||
|
||
export interface TemplateBuilderProps {
|
||
templateId?: string;
|
||
onExit?: () => void;
|
||
}
|
||
|
||
type CanvasDragState =
|
||
| { kind: "idle" }
|
||
| {
|
||
kind: "move";
|
||
blockId: string;
|
||
startClientX: number;
|
||
startClientY: number;
|
||
startLeft: number;
|
||
startTop: number;
|
||
}
|
||
| {
|
||
kind: "resize";
|
||
blockId: string;
|
||
startClientX: number;
|
||
startClientY: number;
|
||
startWidth: number;
|
||
startHeight: number;
|
||
};
|
||
|
||
export default function TemplateBuilder({ templateId, onExit }: TemplateBuilderProps) {
|
||
const store = useTemplateBuilderStore();
|
||
const blocks = useCurrentViewBlocks();
|
||
const selectedBlock = useSelectedBlock();
|
||
const paletteItems = useRegistryPalette();
|
||
const canvasRef = useRef<HTMLDivElement>(null);
|
||
const [drag, setDrag] = useState<CanvasDragState>({ kind: "idle" });
|
||
const [pendingCommit, setPendingCommit] = useState(false);
|
||
|
||
useEffect(() => {
|
||
const key = templateId ? STORAGE_KEY_PREFIX + templateId : LAST_TEMPLATE_KEY;
|
||
try {
|
||
const raw = localStorage.getItem(key);
|
||
if (!raw) return;
|
||
const parsed = JSON.parse(raw);
|
||
if (parsed && typeof parsed === "object") {
|
||
store.fromTemplate(parsed as Template);
|
||
}
|
||
} catch {
|
||
// ignore corrupted state
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [templateId]);
|
||
|
||
const handleSave = useCallback(() => {
|
||
const tpl = store.toTemplate();
|
||
const key = templateId
|
||
? STORAGE_KEY_PREFIX + templateId
|
||
: tpl.templateId
|
||
? STORAGE_KEY_PREFIX + tpl.templateId
|
||
: LAST_TEMPLATE_KEY;
|
||
try {
|
||
localStorage.setItem(key, JSON.stringify(tpl));
|
||
localStorage.setItem(LAST_TEMPLATE_KEY, JSON.stringify(tpl));
|
||
store.markClean();
|
||
} catch (err) {
|
||
console.error("template save failed", err);
|
||
}
|
||
}, [templateId, store]);
|
||
|
||
const gridStep = store.gridSettings.gap || 16;
|
||
const snapEnabled = store.gridSettings.snapToGrid;
|
||
|
||
const handlePaletteDragStart = useCallback(
|
||
(e: React.DragEvent<HTMLDivElement>, item: PaletteItem) => {
|
||
e.dataTransfer.effectAllowed = "copy";
|
||
e.dataTransfer.setData(
|
||
"application/x-template-component",
|
||
JSON.stringify({
|
||
componentId: item.id,
|
||
defaultSize: item.defaultSize,
|
||
defaultConfig: item.defaultConfig ?? {},
|
||
}),
|
||
);
|
||
},
|
||
[],
|
||
);
|
||
|
||
const handleCanvasDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||
if (e.dataTransfer.types.includes("application/x-template-component")) {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = "copy";
|
||
}
|
||
}, []);
|
||
|
||
const handleCanvasDrop = useCallback(
|
||
(e: React.DragEvent<HTMLDivElement>) => {
|
||
const payload = e.dataTransfer.getData("application/x-template-component");
|
||
if (!payload) return;
|
||
e.preventDefault();
|
||
try {
|
||
const { componentId, defaultSize, defaultConfig } = JSON.parse(payload);
|
||
const rect = canvasRef.current?.getBoundingClientRect();
|
||
if (!rect) return;
|
||
const left = snapValue(e.clientX - rect.left - defaultSize.w / 2, gridStep, snapEnabled);
|
||
const top = snapValue(e.clientY - rect.top - defaultSize.h / 2, gridStep, snapEnabled);
|
||
const position: FreePosition = {
|
||
left: Math.max(0, left),
|
||
top: Math.max(0, top),
|
||
width: defaultSize.w,
|
||
height: defaultSize.h,
|
||
};
|
||
store.addBlock(componentId, position, defaultConfig ?? {});
|
||
} catch (err) {
|
||
console.error("drop parse failed", err);
|
||
}
|
||
},
|
||
[store, gridStep, snapEnabled],
|
||
);
|
||
|
||
const handleBlockMouseDown = useCallback(
|
||
(e: React.MouseEvent, block: TemplateComponent, mode: "move" | "resize") => {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
store.selectBlock(block.id);
|
||
if (mode === "move") {
|
||
setDrag({
|
||
kind: "move",
|
||
blockId: block.id,
|
||
startClientX: e.clientX,
|
||
startClientY: e.clientY,
|
||
startLeft: block.position.left,
|
||
startTop: block.position.top,
|
||
});
|
||
} else {
|
||
setDrag({
|
||
kind: "resize",
|
||
blockId: block.id,
|
||
startClientX: e.clientX,
|
||
startClientY: e.clientY,
|
||
startWidth: block.position.width,
|
||
startHeight: block.position.height,
|
||
});
|
||
}
|
||
},
|
||
[store],
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (drag.kind === "idle") return;
|
||
const onMove = (e: MouseEvent) => {
|
||
if (drag.kind === "move") {
|
||
const dx = e.clientX - drag.startClientX;
|
||
const dy = e.clientY - drag.startClientY;
|
||
const left = Math.max(0, snapValue(drag.startLeft + dx, gridStep, snapEnabled));
|
||
const top = Math.max(0, snapValue(drag.startTop + dy, gridStep, snapEnabled));
|
||
useTemplateBuilderStore.getState().updateBlockPosition(drag.blockId, { left, top });
|
||
} else if (drag.kind === "resize") {
|
||
const dw = e.clientX - drag.startClientX;
|
||
const dh = e.clientY - drag.startClientY;
|
||
const width = Math.max(MIN_BLOCK_SIZE, snapValue(drag.startWidth + dw, gridStep, snapEnabled));
|
||
const height = Math.max(MIN_BLOCK_SIZE, snapValue(drag.startHeight + dh, gridStep, snapEnabled));
|
||
useTemplateBuilderStore.getState().updateBlockPosition(drag.blockId, { width, height });
|
||
}
|
||
};
|
||
const onUp = () => {
|
||
setDrag({ kind: "idle" });
|
||
setPendingCommit(true);
|
||
};
|
||
window.addEventListener("mousemove", onMove);
|
||
window.addEventListener("mouseup", onUp);
|
||
return () => {
|
||
window.removeEventListener("mousemove", onMove);
|
||
window.removeEventListener("mouseup", onUp);
|
||
};
|
||
}, [drag, gridStep, snapEnabled]);
|
||
|
||
useEffect(() => {
|
||
if (!pendingCommit) return;
|
||
useTemplateBuilderStore.getState().commit();
|
||
setPendingCommit(false);
|
||
}, [pendingCommit]);
|
||
|
||
useEffect(() => {
|
||
const onKey = (e: KeyboardEvent) => {
|
||
const tag = (e.target as HTMLElement)?.tagName;
|
||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
||
if (e.key === "Delete" || e.key === "Backspace") {
|
||
const id = useTemplateBuilderStore.getState().selectedBlockId;
|
||
if (id) {
|
||
e.preventDefault();
|
||
useTemplateBuilderStore.getState().removeBlock(id);
|
||
}
|
||
}
|
||
if (e.ctrlKey && (e.key === "z" || e.key === "Z") && !e.shiftKey) {
|
||
e.preventDefault();
|
||
useTemplateBuilderStore.getState().undo();
|
||
}
|
||
if ((e.ctrlKey && (e.key === "y" || e.key === "Y")) || (e.ctrlKey && e.shiftKey && (e.key === "z" || e.key === "Z"))) {
|
||
e.preventDefault();
|
||
useTemplateBuilderStore.getState().redo();
|
||
}
|
||
if (e.ctrlKey && (e.key === "s" || e.key === "S")) {
|
||
e.preventDefault();
|
||
handleSave();
|
||
}
|
||
};
|
||
window.addEventListener("keydown", onKey);
|
||
return () => window.removeEventListener("keydown", onKey);
|
||
}, [handleSave]);
|
||
|
||
const gridLines = useMemo(() => {
|
||
if (!store.gridSettings.showGrid) return null;
|
||
const step = store.gridSettings.gap || 16;
|
||
const color = store.gridSettings.gridColor || "#e5e7eb";
|
||
const opacity = store.gridSettings.gridOpacity ?? 0.3;
|
||
return (
|
||
<div
|
||
className="pointer-events-none absolute inset-0"
|
||
style={{
|
||
backgroundImage: `linear-gradient(to right, ${color} 1px, transparent 1px), linear-gradient(to bottom, ${color} 1px, transparent 1px)`,
|
||
backgroundSize: `${step}px ${step}px`,
|
||
opacity,
|
||
}}
|
||
/>
|
||
);
|
||
}, [store.gridSettings]);
|
||
|
||
return (
|
||
<div className="flex h-full w-full flex-col bg-slate-50 dark:bg-slate-950 text-slate-800 dark:text-slate-100">
|
||
<Toolbar onSave={handleSave} onExit={onExit} />
|
||
<div className="flex min-h-0 flex-1">
|
||
<PalettePanel items={paletteItems} onDragStart={handlePaletteDragStart} />
|
||
<div className="relative min-w-0 flex-1 overflow-auto">
|
||
<div
|
||
ref={canvasRef}
|
||
className="relative min-h-full"
|
||
style={{ minHeight: 720 }}
|
||
onDragOver={handleCanvasDragOver}
|
||
onDrop={handleCanvasDrop}
|
||
onMouseDown={(e) => {
|
||
if (e.target === e.currentTarget) {
|
||
store.selectBlock(null);
|
||
}
|
||
}}
|
||
>
|
||
{gridLines}
|
||
{blocks.map((block) => (
|
||
<CanvasBlock
|
||
key={block.id}
|
||
block={block}
|
||
isSelected={selectedBlock?.id === block.id}
|
||
onMouseDown={handleBlockMouseDown}
|
||
paletteLabel={paletteItems.find((p) => p.id === block.componentId)?.name ?? block.componentId}
|
||
paletteIcon={paletteItems.find((p) => p.id === block.componentId)?.icon ?? "◼"}
|
||
/>
|
||
))}
|
||
{blocks.length === 0 && <EmptyState view={store.currentView} />}
|
||
</div>
|
||
</div>
|
||
<SidePanel />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Toolbar({ onSave, onExit }: { onSave: () => void; onExit?: () => void }) {
|
||
const state = useTemplateBuilderStore();
|
||
const undoEnabled = canUndo(state);
|
||
const redoEnabled = canRedo(state);
|
||
return (
|
||
<div className="flex items-center gap-2 border-b border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-4 py-2 text-sm">
|
||
{onExit && (
|
||
<button
|
||
type="button"
|
||
onClick={onExit}
|
||
className="rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-2 py-1 text-xs hover:bg-slate-100 dark:hover:bg-slate-700"
|
||
>
|
||
← 목록
|
||
</button>
|
||
)}
|
||
<input
|
||
value={state.templateName}
|
||
onChange={(e) => state.setTemplateMeta({ templateName: e.target.value })}
|
||
placeholder="템플릿 이름"
|
||
className="w-48 rounded border border-slate-200 dark:border-slate-700 px-2 py-1"
|
||
/>
|
||
<input
|
||
value={state.category}
|
||
onChange={(e) => state.setTemplateMeta({ category: e.target.value })}
|
||
placeholder="카테고리 (sales, hr…)"
|
||
className="w-36 rounded border border-slate-200 dark:border-slate-700 px-2 py-1"
|
||
/>
|
||
<div className="ml-2 flex items-center gap-1">
|
||
{(Object.keys(VIEW_LABELS) as BuilderView[]).map((view) => (
|
||
<button
|
||
key={view}
|
||
type="button"
|
||
onClick={() => state.switchView(view)}
|
||
className={`rounded px-3 py-1 text-xs transition-colors ${
|
||
state.currentView === view
|
||
? "bg-indigo-600 text-white"
|
||
: "border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||
}`}
|
||
>
|
||
{VIEW_LABELS[view]}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="ml-auto flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={state.undo}
|
||
disabled={!undoEnabled}
|
||
className="rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-2 py-1 text-xs disabled:opacity-40"
|
||
>
|
||
↶ 실행취소
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={state.redo}
|
||
disabled={!redoEnabled}
|
||
className="rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-2 py-1 text-xs disabled:opacity-40"
|
||
>
|
||
↷ 다시실행
|
||
</button>
|
||
<label className="flex items-center gap-1 text-xs text-slate-600 dark:text-slate-300">
|
||
<input
|
||
type="checkbox"
|
||
checked={state.gridSettings.showGrid}
|
||
onChange={(e) => state.setGridSettings({ showGrid: e.target.checked })}
|
||
/>
|
||
격자
|
||
</label>
|
||
<label className="flex items-center gap-1 text-xs text-slate-600 dark:text-slate-300">
|
||
<input
|
||
type="checkbox"
|
||
checked={state.gridSettings.snapToGrid}
|
||
onChange={(e) => state.setGridSettings({ snapToGrid: e.target.checked })}
|
||
/>
|
||
스냅
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={onSave}
|
||
className="rounded bg-indigo-600 px-3 py-1 text-xs font-medium text-white hover:bg-indigo-700"
|
||
>
|
||
{state.isDirty ? "저장 *" : "저장"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PalettePanel({
|
||
items,
|
||
onDragStart,
|
||
}: {
|
||
items: PaletteItem[];
|
||
onDragStart: (e: React.DragEvent<HTMLDivElement>, item: PaletteItem) => void;
|
||
}) {
|
||
const [query, setQuery] = useState("");
|
||
const filtered = useMemo(() => {
|
||
const q = query.trim().toLowerCase();
|
||
const list = q
|
||
? items.filter(
|
||
(i) => i.name.toLowerCase().includes(q) || i.id.toLowerCase().includes(q),
|
||
)
|
||
: items;
|
||
const groups = new Map<string, PaletteItem[]>();
|
||
list.forEach((i) => {
|
||
const arr = groups.get(i.category) ?? [];
|
||
arr.push(i);
|
||
groups.set(i.category, arr);
|
||
});
|
||
return Array.from(groups.entries()).sort((a, b) => a[0].localeCompare(b[0]));
|
||
}, [items, query]);
|
||
|
||
return (
|
||
<aside className="flex w-60 flex-col border-r border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900">
|
||
<div className="border-b border-slate-200 dark:border-slate-700 px-3 py-2 text-xs font-semibold text-slate-500 dark:text-slate-400">
|
||
컴포넌트 팔레트
|
||
</div>
|
||
<div className="border-b border-slate-200 dark:border-slate-700 p-2">
|
||
<input
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
placeholder="검색"
|
||
className="w-full rounded border border-slate-200 dark:border-slate-700 px-2 py-1 text-xs"
|
||
/>
|
||
</div>
|
||
<div className="flex-1 space-y-3 overflow-y-auto p-2">
|
||
{filtered.map(([cat, list]) => (
|
||
<div key={cat}>
|
||
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-slate-400 dark:text-slate-500">
|
||
{cat}
|
||
</div>
|
||
<div className="space-y-1">
|
||
{list.map((item) => (
|
||
<div
|
||
key={item.id}
|
||
draggable
|
||
onDragStart={(e) => onDragStart(e, item)}
|
||
className="flex cursor-grab items-center gap-2 rounded border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-950 px-2 py-1.5 text-xs hover:border-indigo-300 dark:hover:border-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-950/40 active:cursor-grabbing"
|
||
title={item.id}
|
||
>
|
||
<span className="text-sm">{item.icon}</span>
|
||
<span className="truncate">{item.name}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
function CanvasBlock({
|
||
block,
|
||
isSelected,
|
||
onMouseDown,
|
||
paletteLabel,
|
||
paletteIcon,
|
||
}: {
|
||
block: TemplateComponent;
|
||
isSelected: boolean;
|
||
onMouseDown: (e: React.MouseEvent, block: TemplateComponent, mode: "move" | "resize") => void;
|
||
paletteLabel: string;
|
||
paletteIcon: string;
|
||
}) {
|
||
return (
|
||
<div
|
||
className={`absolute flex flex-col overflow-hidden rounded-md border bg-white dark:bg-slate-900 shadow-sm transition-shadow ${
|
||
isSelected ? "border-indigo-500 shadow-md ring-2 ring-indigo-200 dark:ring-indigo-800" : "border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600"
|
||
}`}
|
||
style={{
|
||
left: block.position.left,
|
||
top: block.position.top,
|
||
width: block.position.width,
|
||
height: block.position.height,
|
||
containerType: "inline-size",
|
||
}}
|
||
onMouseDown={(e) => onMouseDown(e, block, "move")}
|
||
>
|
||
<div className="flex items-center gap-1 border-b border-slate-200 dark:border-slate-700 bg-slate-100 dark:bg-slate-800 px-2 py-1 text-[11px] font-medium text-slate-600 dark:text-slate-300">
|
||
<span>{paletteIcon}</span>
|
||
<span className="truncate">{paletteLabel}</span>
|
||
<span className="ml-auto text-[9px] text-slate-400 dark:text-slate-500">
|
||
{Math.round(block.position.width)}×{Math.round(block.position.height)}
|
||
</span>
|
||
</div>
|
||
<div className="flex flex-1 items-center justify-center p-3 text-[11px] text-slate-400 dark:text-slate-500">
|
||
<span className="truncate">{block.componentId}</span>
|
||
</div>
|
||
<div
|
||
className="absolute bottom-0 right-0 h-3 w-3 cursor-nwse-resize bg-indigo-500/60"
|
||
onMouseDown={(e) => onMouseDown(e, block, "resize")}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EmptyState({ view }: { view: BuilderView }) {
|
||
return (
|
||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||
<div className="text-center text-slate-400 dark:text-slate-500">
|
||
<div className="text-4xl">📋</div>
|
||
<div className="mt-2 text-sm font-medium">{VIEW_LABELS[view]} 뷰가 비어있습니다</div>
|
||
<div className="mt-1 text-xs">좌측 팔레트에서 컴포넌트를 드래그하여 배치하세요</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SidePanel() {
|
||
const state = useTemplateBuilderStore();
|
||
const selected = useSelectedBlock();
|
||
const [tab, setTab] = useState<"props" | "grid" | "meta">("props");
|
||
|
||
return (
|
||
<aside className="flex w-72 flex-col border-l border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900">
|
||
<div className="flex border-b border-slate-200 dark:border-slate-700">
|
||
{(["props", "grid", "meta"] as const).map((t) => (
|
||
<button
|
||
key={t}
|
||
type="button"
|
||
onClick={() => setTab(t)}
|
||
className={`flex-1 px-3 py-2 text-xs font-medium ${
|
||
tab === t ? "border-b-2 border-indigo-600 text-indigo-600" : "text-slate-500 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800/60"
|
||
}`}
|
||
>
|
||
{t === "props" ? "속성" : t === "grid" ? "격자" : "메타"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="flex-1 overflow-y-auto p-3">
|
||
{tab === "props" ? (
|
||
selected ? (
|
||
<BlockProperties block={selected} />
|
||
) : (
|
||
<div className="py-8 text-center text-xs text-slate-400 dark:text-slate-500">컴포넌트를 선택하세요</div>
|
||
)
|
||
) : tab === "grid" ? (
|
||
<GridSettings />
|
||
) : (
|
||
<MetaForm />
|
||
)}
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
function BlockProperties({ block }: { block: TemplateComponent }) {
|
||
const updateBlockConfig = useTemplateBuilderStore((s) => s.updateBlockConfig);
|
||
const updateBlockPosition = useTemplateBuilderStore((s) => s.updateBlockPosition);
|
||
const removeBlock = useTemplateBuilderStore((s) => s.removeBlock);
|
||
const [configText, setConfigText] = useState(() => JSON.stringify(block.config ?? {}, null, 2));
|
||
const [configError, setConfigError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
setConfigText(JSON.stringify(block.config ?? {}, null, 2));
|
||
setConfigError(null);
|
||
}, [block.id, block.config]);
|
||
|
||
const commitConfig = () => {
|
||
try {
|
||
const parsed = JSON.parse(configText);
|
||
if (typeof parsed !== "object" || parsed === null) {
|
||
setConfigError("객체여야 합니다");
|
||
return;
|
||
}
|
||
updateBlockConfig(block.id, parsed);
|
||
setConfigError(null);
|
||
} catch (err) {
|
||
setConfigError(err instanceof Error ? err.message : "JSON 파싱 실패");
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-3 text-xs">
|
||
<div>
|
||
<div className="text-[10px] uppercase text-slate-400 dark:text-slate-500">component</div>
|
||
<div className="font-mono text-slate-700 dark:text-slate-200">{block.componentId}</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<LabeledNumber
|
||
label="left"
|
||
value={block.position.left}
|
||
onChange={(v) => updateBlockPosition(block.id, { left: v })}
|
||
/>
|
||
<LabeledNumber
|
||
label="top"
|
||
value={block.position.top}
|
||
onChange={(v) => updateBlockPosition(block.id, { top: v })}
|
||
/>
|
||
<LabeledNumber
|
||
label="width"
|
||
value={block.position.width}
|
||
onChange={(v) => updateBlockPosition(block.id, { width: Math.max(MIN_BLOCK_SIZE, v) })}
|
||
/>
|
||
<LabeledNumber
|
||
label="height"
|
||
value={block.position.height}
|
||
onChange={(v) => updateBlockPosition(block.id, { height: Math.max(MIN_BLOCK_SIZE, v) })}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<div className="mb-1 text-[10px] uppercase text-slate-400 dark:text-slate-500">config (JSON)</div>
|
||
<textarea
|
||
value={configText}
|
||
onChange={(e) => setConfigText(e.target.value)}
|
||
onBlur={commitConfig}
|
||
rows={8}
|
||
className="w-full rounded border border-slate-200 dark:border-slate-700 p-2 font-mono text-[11px]"
|
||
/>
|
||
{configError && <div className="mt-1 text-[10px] text-rose-500 dark:text-rose-400">{configError}</div>}
|
||
</div>
|
||
{block.viewTrigger && (
|
||
<div className="rounded bg-amber-50 dark:bg-amber-950/40 p-2 text-[11px] text-amber-700 dark:text-amber-300">
|
||
이 컴포넌트는 <b>{block.viewTrigger.targetView}</b> 뷰를 엽니다 ({block.viewTrigger.action})
|
||
</div>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={() => removeBlock(block.id)}
|
||
className="w-full rounded border border-rose-200 dark:border-rose-800 py-1.5 text-[11px] text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-950/40"
|
||
>
|
||
컴포넌트 삭제
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LabeledNumber({
|
||
label,
|
||
value,
|
||
onChange,
|
||
}: {
|
||
label: string;
|
||
value: number;
|
||
onChange: (v: number) => void;
|
||
}) {
|
||
return (
|
||
<label className="block">
|
||
<span className="block text-[10px] uppercase text-slate-400 dark:text-slate-500">{label}</span>
|
||
<input
|
||
type="number"
|
||
value={Math.round(value)}
|
||
onChange={(e) => onChange(Number(e.target.value) || 0)}
|
||
className="w-full rounded border border-slate-200 dark:border-slate-700 px-2 py-1 text-xs"
|
||
/>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
function GridSettings() {
|
||
const grid = useTemplateBuilderStore((s) => s.gridSettings);
|
||
const setGrid = useTemplateBuilderStore((s) => s.setGridSettings);
|
||
const reset = useTemplateBuilderStore((s) => s.resetGrid);
|
||
return (
|
||
<div className="space-y-3 text-xs">
|
||
<label className="flex items-center justify-between">
|
||
<span>격자 표시</span>
|
||
<input
|
||
type="checkbox"
|
||
checked={grid.showGrid}
|
||
onChange={(e) => setGrid({ showGrid: e.target.checked })}
|
||
/>
|
||
</label>
|
||
<label className="flex items-center justify-between">
|
||
<span>스냅</span>
|
||
<input
|
||
type="checkbox"
|
||
checked={grid.snapToGrid}
|
||
onChange={(e) => setGrid({ snapToGrid: e.target.checked })}
|
||
/>
|
||
</label>
|
||
<LabeledNumber label="간격 (px)" value={grid.gap} onChange={(v) => setGrid({ gap: Math.max(4, v) })} />
|
||
<LabeledNumber
|
||
label="패딩 (px)"
|
||
value={grid.padding}
|
||
onChange={(v) => setGrid({ padding: Math.max(0, v) })}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={reset}
|
||
className="w-full rounded border border-slate-200 dark:border-slate-700 py-1.5 text-[11px] text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800/60"
|
||
>
|
||
기본값으로
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MetaForm() {
|
||
const state = useTemplateBuilderStore();
|
||
return (
|
||
<div className="space-y-3 text-xs">
|
||
<label className="block">
|
||
<span className="block text-[10px] uppercase text-slate-400 dark:text-slate-500">아이콘</span>
|
||
<input
|
||
value={state.icon}
|
||
onChange={(e) => state.setTemplateMeta({ icon: e.target.value })}
|
||
className="w-full rounded border border-slate-200 dark:border-slate-700 px-2 py-1"
|
||
/>
|
||
</label>
|
||
<label className="block">
|
||
<span className="block text-[10px] uppercase text-slate-400 dark:text-slate-500">배지</span>
|
||
<input
|
||
value={state.badge}
|
||
onChange={(e) => state.setTemplateMeta({ badge: e.target.value })}
|
||
placeholder="ERP · 영업"
|
||
className="w-full rounded border border-slate-200 dark:border-slate-700 px-2 py-1"
|
||
/>
|
||
</label>
|
||
<label className="block">
|
||
<span className="block text-[10px] uppercase text-slate-400 dark:text-slate-500">설명</span>
|
||
<textarea
|
||
value={state.description}
|
||
onChange={(e) => state.setTemplateMeta({ description: e.target.value })}
|
||
rows={3}
|
||
className="w-full rounded border border-slate-200 dark:border-slate-700 px-2 py-1"
|
||
/>
|
||
</label>
|
||
<label className="block">
|
||
<span className="block text-[10px] uppercase text-slate-400 dark:text-slate-500">기본 테이블</span>
|
||
<input
|
||
value={state.primaryTable ?? ""}
|
||
onChange={(e) => state.setTemplateMeta({ primaryTable: e.target.value || null })}
|
||
placeholder="ORDER_MASTER"
|
||
className="w-full rounded border border-slate-200 dark:border-slate-700 px-2 py-1 font-mono"
|
||
/>
|
||
</label>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<LabeledNumber
|
||
label="기본 너비"
|
||
value={state.defaultSize.w}
|
||
onChange={(v) => state.setTemplateMeta({ defaultSize: { ...state.defaultSize, w: v } })}
|
||
/>
|
||
<LabeledNumber
|
||
label="기본 높이"
|
||
value={state.defaultSize.h}
|
||
onChange={(v) => state.setTemplateMeta({ defaultSize: { ...state.defaultSize, h: v } })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|