返回 presentation-ai
1 "use client";
2
3 import {
4 closestCenter,
5 DndContext,
6 PointerSensor,
7 useSensor,
8 useSensors,
9 type DragEndEvent,
10 } from "@dnd-kit/core";
11 import {
12 arrayMove,
13 SortableContext,
14 useSortable,
15 verticalListSortingStrategy,
16 } from "@dnd-kit/sortable";
17 import { CSS } from "@dnd-kit/utilities";
18 import { GripVertical, Plus, Trash2 } from "lucide-react";
19
20 import { Button } from "@/components/ui/button";
21 import { Input } from "@/components/ui/input";
22 import { Textarea } from "@/components/ui/textarea";
23 import { cn } from "@/lib/utils";
24 import {
25 type EditableInfographicItem,
26 type InfographicDataMode,
27 type InfographicItemPatch,
28 } from "./types";
29 import { createBlankInfographicItem, normalizeEditableValue } from "./utils";
30
31 interface InfographicItemListProps {
32 items: EditableInfographicItem[];
33 mode: InfographicDataMode;
34 onChange: (items: EditableInfographicItem[]) => void;
35 }
36
37 interface SortableItemRowProps {
38 item: EditableInfographicItem;
39 mode: InfographicDataMode;
40 onInsertAfter: () => void;
41 onRemove: () => void;
42 onUpdate: (patch: InfographicItemPatch) => void;
43 }
44
45 function SortableItemRow({
46 item,
47 mode,
48 onInsertAfter,
49 onRemove,
50 onUpdate,
51 }: SortableItemRowProps) {
52 const {
53 attributes,
54 listeners,
55 setNodeRef,
56 transform,
57 transition,
58 isDragging,
59 } = useSortable({ id: item.editorId });
60
61 const style = {
62 transform: CSS.Transform.toString(transform),
63 transition,
64 };
65 const showValue = mode === "values" || item.value !== undefined;
66 const showId = mode === "nodes";
67
68 return (
69 <div
70 ref={setNodeRef}
71 style={style}
72 className={cn(
73 "group relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-3 rounded-xl border border-border/50 bg-background p-3 transition-colors hover:bg-muted/30",
74 isDragging && "z-20 opacity-80 shadow-xl ring-1 ring-ring",
75 )}
76 >
77 <button
78 type="button"
79 className="mt-1 flex size-7 cursor-grab items-center justify-center rounded-md text-muted-foreground opacity-50 transition-all hover:bg-muted hover:opacity-100 active:cursor-grabbing group-hover:opacity-100"
80 aria-label="Reorder item"
81 {...attributes}
82 {...listeners}
83 >
84 <GripVertical className="size-4" />
85 </button>
86
87 <div className="min-w-0 space-y-3">
88 <div className="grid gap-3 sm:grid-cols-2">
89 <Input
90 value={item.label ?? ""}
91 onChange={(event) => onUpdate({ label: event.target.value })}
92 placeholder={mode === "values" ? "Word or label" : "Title"}
93 className="bg-transparent font-medium shadow-none focus-visible:bg-background"
94 />
95 {showValue ? (
96 <Input
97 value={item.value ?? ""}
98 onChange={(event) =>
99 onUpdate({ value: normalizeEditableValue(event.target.value) })
100 }
101 placeholder="Value"
102 className="bg-transparent shadow-none focus-visible:bg-background"
103 />
104 ) : null}
105 </div>
106
107 {showId ? (
108 <div className="grid gap-2">
109 <Input
110 value={item.id ?? ""}
111 onChange={(event) => onUpdate({ id: event.target.value })}
112 placeholder="Node id"
113 className="bg-transparent shadow-none focus-visible:bg-background font-mono text-sm"
114 />
115 </div>
116 ) : null}
117
118 {mode !== "values" ? (
119 <Textarea
120 value={item.desc ?? ""}
121 onChange={(event) => onUpdate({ desc: event.target.value })}
122 placeholder="Description"
123 className="min-h-18 resize-none bg-transparent shadow-none focus-visible:bg-background leading-relaxed"
124 />
125 ) : null}
126
127 <div className="pt-1 opacity-60 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
128 <Button
129 type="button"
130 variant="ghost"
131 size="sm"
132 className="h-7 gap-1.5 px-2.5 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
133 onClick={onInsertAfter}
134 >
135 <Plus className="size-3.5" />
136 Insert item below
137 </Button>
138 </div>
139 </div>
140
141 <Button
142 type="button"
143 variant="ghost"
144 size="icon"
145 className="mt-1 size-8 text-muted-foreground opacity-50 transition-opacity hover:text-destructive group-hover:opacity-100"
146 onClick={onRemove}
147 aria-label="Remove item"
148 >
149 <Trash2 className="size-4" />
150 </Button>
151 </div>
152 );
153 }
154
155 export function InfographicItemList({
156 items,
157 mode,
158 onChange,
159 }: InfographicItemListProps) {
160 const sensors = useSensors(
161 useSensor(PointerSensor, {
162 activationConstraint: { distance: 6 },
163 }),
164 );
165
166 const handleDragEnd = (event: DragEndEvent) => {
167 const { active, over } = event;
168 if (!over || active.id === over.id) return;
169
170 const oldIndex = items.findIndex((item) => item.editorId === active.id);
171 const newIndex = items.findIndex((item) => item.editorId === over.id);
172 if (oldIndex === -1 || newIndex === -1) return;
173
174 onChange(arrayMove(items, oldIndex, newIndex));
175 };
176
177 const updateItem = (editorId: string, patch: InfographicItemPatch) => {
178 onChange(
179 items.map((item) =>
180 item.editorId === editorId ? { ...item, ...patch } : item,
181 ),
182 );
183 };
184
185 const removeItem = (editorId: string) => {
186 onChange(items.filter((item) => item.editorId !== editorId));
187 };
188
189 const insertItem = (index: number) => {
190 const nextItems = [...items];
191 nextItems.splice(index, 0, createBlankInfographicItem(mode));
192 onChange(nextItems);
193 };
194
195 return (
196 <div className="space-y-4">
197 <div className="flex items-center justify-between gap-3 border-b border-border/50 pb-3">
198 <div>
199 <p className="text-sm font-semibold tracking-tight text-foreground">
200 Items
201 </p>
202 <p className="text-xs text-muted-foreground mt-1">
203 {items.length} item{items.length === 1 ? "" : "s"}
204 </p>
205 </div>
206 <Button
207 type="button"
208 variant="secondary"
209 size="sm"
210 className="gap-2"
211 onClick={() => insertItem(items.length)}
212 >
213 <Plus className="size-3.5" />
214 Add Item
215 </Button>
216 </div>
217
218 <DndContext
219 sensors={sensors}
220 collisionDetection={closestCenter}
221 onDragEnd={handleDragEnd}
222 >
223 <SortableContext
224 items={items.map((item) => item.editorId)}
225 strategy={verticalListSortingStrategy}
226 >
227 <div className="space-y-3">
228 {items.length > 0 ? (
229 <Button
230 type="button"
231 variant="outline"
232 size="sm"
233 className="h-9 w-full border-dashed text-xs text-muted-foreground hover:bg-muted/50 hover:text-foreground"
234 onClick={() => insertItem(0)}
235 >
236 <Plus className="size-3.5 mr-1.5" />
237 Insert item at start
238 </Button>
239 ) : null}
240 {items.map((item, index) => (
241 <SortableItemRow
242 key={item.editorId}
243 item={item}
244 mode={mode}
245 onInsertAfter={() => insertItem(index + 1)}
246 onRemove={() => removeItem(item.editorId)}
247 onUpdate={(patch) => updateItem(item.editorId, patch)}
248 />
249 ))}
250 </div>
251 </SortableContext>
252 </DndContext>
253
254 {items.length === 0 ? (
255 <div className="rounded-xl border border-dashed border-border/60 bg-muted/10 p-8 text-center">
256 <p className="text-sm font-medium text-foreground">No items yet</p>
257 <p className="mt-1 text-xs text-muted-foreground">
258 Add an item to populate the infographic.
259 </p>
260 </div>
261 ) : null}
262 </div>
263 );
264 }
265
265 lines Plain Text