219 lines
8.1 KiB
TypeScript
219 lines
8.1 KiB
TypeScript
|
|
"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">
|
||
|
|
“{query}”에 해당하는 노드를 찾지 못했어요
|
||
|
|
</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>
|
||
|
|
);
|
||
|
|
}
|