ERP-node/frontend/components/dataflow/node-editor/CommandPalette.tsx

219 lines
8.1 KiB
TypeScript
Raw Normal View History

"use client";
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
import { Search, X } from "lucide-react";
import { NODE_PALETTE, NODE_CATEGORIES } from "./sidebar/nodePaletteConfig";
import type { NodePaletteItem } from "@/types/node-editor";
const TOSS_CATEGORY_LABELS: Record<string, string> = {
source: "데이터를 가져와요",
transform: "데이터를 가공해요",
action: "데이터를 저장해요",
external: "외부로 연결해요",
utility: "도구",
};
const TOSS_NODE_DESCRIPTIONS: Record<string, string> = {
tableSource: "내부 데이터베이스에서 데이터를 읽어와요",
externalDBSource: "외부 데이터베이스에 연결해서 데이터를 가져와요",
restAPISource: "REST API를 호출해서 데이터를 받아와요",
condition: "조건에 따라 데이터 흐름을 나눠요",
dataTransform: "데이터를 원하는 형태로 바꿔요",
aggregate: "합계, 평균 등 집계 연산을 수행해요",
formulaTransform: "수식을 이용해서 새로운 값을 계산해요",
insertAction: "데이터를 테이블에 새로 추가해요",
updateAction: "기존 데이터를 수정해요",
deleteAction: "데이터를 삭제해요",
upsertAction: "있으면 수정하고, 없으면 새로 추가해요",
emailAction: "이메일을 자동으로 보내요",
scriptAction: "외부 스크립트를 실행해요",
httpRequestAction: "HTTP 요청을 보내요",
procedureCallAction: "DB 프로시저를 호출해요",
comment: "메모를 남겨요",
};
interface CommandPaletteProps {
isOpen: boolean;
onClose: () => void;
onSelectNode: (nodeType: string) => void;
}
export function CommandPalette({ isOpen, onClose, onSelectNode }: CommandPaletteProps) {
const [query, setQuery] = useState("");
const [focusIndex, setFocusIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const filteredItems = useMemo(() => {
if (!query.trim()) return NODE_PALETTE;
const q = query.toLowerCase();
return NODE_PALETTE.filter(
(item) =>
item.label.toLowerCase().includes(q) ||
item.description.toLowerCase().includes(q) ||
item.type.toLowerCase().includes(q) ||
(TOSS_NODE_DESCRIPTIONS[item.type] || "").toLowerCase().includes(q),
);
}, [query]);
const groupedItems = useMemo(() => {
const groups: { category: string; label: string; items: NodePaletteItem[] }[] = [];
for (const cat of NODE_CATEGORIES) {
const items = filteredItems.filter((i) => i.category === cat.id);
if (items.length > 0) {
groups.push({
category: cat.id,
label: TOSS_CATEGORY_LABELS[cat.id] || cat.label,
items,
});
}
}
return groups;
}, [filteredItems]);
const flatItems = useMemo(() => groupedItems.flatMap((g) => g.items), [groupedItems]);
useEffect(() => {
if (isOpen) {
setQuery("");
setFocusIndex(0);
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [isOpen]);
useEffect(() => {
setFocusIndex(0);
}, [query]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
} else if (e.key === "ArrowDown") {
e.preventDefault();
setFocusIndex((i) => Math.min(i + 1, flatItems.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setFocusIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter" && flatItems[focusIndex]) {
onSelectNode(flatItems[focusIndex].type);
onClose();
}
},
[flatItems, focusIndex, onClose, onSelectNode],
);
useEffect(() => {
const focused = listRef.current?.querySelector('[data-focused="true"]');
focused?.scrollIntoView({ block: "nearest" });
}, [focusIndex]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh]">
{/* backdrop */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
{/* palette */}
<div className="relative w-full max-w-[520px] overflow-hidden rounded-xl border border-zinc-700 bg-zinc-900 shadow-2xl shadow-black/50">
{/* 검색 */}
<div className="flex items-center gap-3 border-b border-zinc-800 px-4 py-3">
<Search className="h-4 w-4 flex-shrink-0 text-zinc-500" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="어떤 노드를 추가할까요?"
className="flex-1 bg-transparent text-sm text-zinc-200 outline-none placeholder:text-zinc-600"
/>
<button
onClick={onClose}
className="flex h-6 w-6 items-center justify-center rounded text-zinc-500 transition-colors hover:text-zinc-300"
>
<X className="h-4 w-4" />
</button>
</div>
{/* 목록 */}
<div ref={listRef} className="max-h-[360px] overflow-y-auto p-2">
{groupedItems.length === 0 ? (
<div className="py-8 text-center text-sm text-zinc-500">
&ldquo;{query}&rdquo;
</div>
) : (
groupedItems.map((group) => {
let groupStartIdx = 0;
for (const g of groupedItems) {
if (g.category === group.category) break;
groupStartIdx += g.items.length;
}
return (
<div key={group.category} className="mb-1">
<div className="px-2 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
{group.label}
</div>
{group.items.map((item, idx) => {
const globalIdx = groupStartIdx + idx;
const isFocused = globalIdx === focusIndex;
return (
<button
key={item.type}
data-focused={isFocused}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors ${
isFocused ? "bg-violet-500/15 text-zinc-100" : "text-zinc-300 hover:bg-zinc-800"
}`}
onClick={() => {
onSelectNode(item.type);
onClose();
}}
onMouseEnter={() => setFocusIndex(globalIdx)}
>
<div
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{ backgroundColor: item.color }}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{item.label}</div>
<div className="truncate text-[11px] text-zinc-500">
{TOSS_NODE_DESCRIPTIONS[item.type] || item.description}
</div>
</div>
</button>
);
})}
</div>
);
})
)}
</div>
{/* 하단 힌트 */}
<div className="flex items-center gap-4 border-t border-zinc-800 px-4 py-2 text-[11px] text-zinc-600">
<span>
<kbd className="rounded border border-zinc-700 bg-zinc-800 px-1 py-0.5 font-mono text-[10px]">
Enter
</kbd>{" "}
</span>
<span>
<kbd className="rounded border border-zinc-700 bg-zinc-800 px-1 py-0.5 font-mono text-[10px]">
Esc
</kbd>{" "}
</span>
<span>
<kbd className="rounded border border-zinc-700 bg-zinc-800 px-1 py-0.5 font-mono text-[10px]">
</kbd>{" "}
</span>
</div>
</div>
</div>
);
}