Files
invyone/frontend/components/template-builder/TemplateBuilder.tsx
T
gbpark 2c0a97f2ba Phase 1: INVYONE 카드 엔진 토대 정리
- 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>
2026-04-11 03:08:06 +09:00

806 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}