@kyro-cms/admin 0.10.19 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kyro-cms/admin",
3
- "version": "0.10.19",
3
+ "version": "0.11.0",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },
@@ -120,7 +120,7 @@
120
120
  "vitest": "^4.1.4"
121
121
  },
122
122
  "peerDependencies": {
123
- "@kyro-cms/core": "^0.10.0",
123
+ "@kyro-cms/core": "^0.11.0",
124
124
  "react": "^19.0.0",
125
125
  "react-dom": "^19.0.0"
126
126
  },
@@ -1,7 +1,22 @@
1
1
  import React from "react";
2
- import { Plus, ChevronDown, ChevronUp, X } from "../ui/icons";
2
+ import { Plus, ChevronDown, ChevronUp, X, GripVertical } from "../ui/icons";
3
+ import {
4
+ DndContext,
5
+ closestCenter,
6
+ PointerSensor,
7
+ useSensor,
8
+ useSensors,
9
+ type DragEndEvent,
10
+ } from "@dnd-kit/core";
11
+ import {
12
+ SortableContext,
13
+ useSortable,
14
+ verticalListSortingStrategy,
15
+ } from "@dnd-kit/sortable";
16
+ import { CSS } from "@dnd-kit/utilities";
3
17
 
4
18
  interface ArrayFieldItem {
19
+ id?: string;
5
20
  [key: string]: unknown;
6
21
  }
7
22
 
@@ -12,6 +27,219 @@ interface ArrayFieldProps {
12
27
  compact?: boolean;
13
28
  }
14
29
 
30
+ interface SortableArrayFieldItemProps {
31
+ id: string;
32
+ index: number;
33
+ isOpen: boolean;
34
+ setOpenIndex: (index: number | null) => void;
35
+ item: Record<string, unknown>;
36
+ labelField: string;
37
+ itemKeys: string[];
38
+ inputClass: string;
39
+ handleItemChange: (index: number, field: string, value: string) => void;
40
+ handleRemove: (index: number) => void;
41
+ compact: boolean;
42
+ }
43
+
44
+ function SortableArrayFieldItem({
45
+ id,
46
+ index,
47
+ isOpen,
48
+ setOpenIndex,
49
+ item,
50
+ labelField,
51
+ itemKeys,
52
+ inputClass,
53
+ handleItemChange,
54
+ handleRemove,
55
+ compact,
56
+ }: SortableArrayFieldItemProps) {
57
+ const {
58
+ attributes,
59
+ listeners,
60
+ setNodeRef,
61
+ transform,
62
+ transition,
63
+ isDragging,
64
+ } = useSortable({ id });
65
+
66
+ const style = {
67
+ transform: CSS.Transform.toString(transform),
68
+ transition,
69
+ zIndex: isDragging ? 10 : 1,
70
+ opacity: isDragging ? 0.8 : 1,
71
+ };
72
+
73
+ const itemLabel =
74
+ String(item[labelField] || item.title || item.name || `Item ${index + 1}`);
75
+
76
+ if (compact) {
77
+ return (
78
+ <div
79
+ ref={setNodeRef}
80
+ style={style}
81
+ className={`border border-[var(--kyro-border)] rounded-lg overflow-hidden group bg-[var(--kyro-surface)] ${
82
+ isDragging ? "border-[var(--kyro-primary)] shadow-md" : ""
83
+ }`}
84
+ >
85
+ <div className="w-full flex items-center justify-between p-2.5 bg-[var(--kyro-surface-accent)] hover:bg-[var(--kyro-sidebar-active)]/10 transition-colors">
86
+ <div className="flex items-center gap-2 flex-1 min-w-0">
87
+ <div
88
+ {...attributes}
89
+ {...listeners}
90
+ className="p-0.5 cursor-grab active:cursor-grabbing text-[var(--kyro-text-muted)] hover:bg-[var(--kyro-border)] rounded flex-shrink-0"
91
+ onClick={(e) => e.stopPropagation()}
92
+ >
93
+ <GripVertical className="w-3.5 h-3.5" />
94
+ </div>
95
+ <span
96
+ onClick={() => setOpenIndex(isOpen ? null : index)}
97
+ className="text-sm font-medium text-[var(--kyro-text-primary)] truncate cursor-pointer flex-1 py-1 text-left"
98
+ >
99
+ {itemLabel}
100
+ </span>
101
+ </div>
102
+ <div className="flex items-center gap-1">
103
+ <button
104
+ type="button"
105
+ onClick={(e) => {
106
+ e.stopPropagation();
107
+ handleRemove(index);
108
+ }}
109
+ className="opacity-0 group-hover:opacity-100 p-1 hover:bg-[var(--kyro-danger-bg)] rounded text-[var(--kyro-error)] transition-opacity"
110
+ title="Remove"
111
+ >
112
+ <X className="w-3.5 h-3.5" />
113
+ </button>
114
+ <button
115
+ type="button"
116
+ onClick={() => setOpenIndex(isOpen ? null : index)}
117
+ className="p-1 hover:bg-[var(--kyro-surface-accent)] rounded"
118
+ >
119
+ {isOpen ? (
120
+ <ChevronUp className="w-4 h-4 text-[var(--kyro-text-muted)]" />
121
+ ) : (
122
+ <ChevronDown className="w-4 h-4 text-[var(--kyro-text-muted)]" />
123
+ )}
124
+ </button>
125
+ </div>
126
+ </div>
127
+ {isOpen && (
128
+ <div className="p-2.5 bg-[var(--kyro-surface)] space-y-2">
129
+ {itemKeys.length > 0 ? (
130
+ itemKeys.map((key) => (
131
+ <input
132
+ key={key}
133
+ type="text"
134
+ value={String(item[key] || "")}
135
+ onChange={(e) =>
136
+ handleItemChange(index, key, e.target.value)
137
+ }
138
+ onClick={(e) => e.stopPropagation()}
139
+ className={inputClass}
140
+ placeholder={key}
141
+ />
142
+ ))
143
+ ) : (
144
+ <input
145
+ type="text"
146
+ value={String(item.value || "")}
147
+ onChange={(e) =>
148
+ handleItemChange(index, "value", e.target.value)
149
+ }
150
+ onClick={(e) => e.stopPropagation()}
151
+ className={inputClass}
152
+ placeholder="Value..."
153
+ />
154
+ )}
155
+ </div>
156
+ )}
157
+ </div>
158
+ );
159
+ }
160
+
161
+ return (
162
+ <div
163
+ ref={setNodeRef}
164
+ style={style}
165
+ className={`border border-[var(--kyro-border)] rounded-lg overflow-hidden group bg-[var(--kyro-surface)] ${
166
+ isDragging ? "border-[var(--kyro-primary)] shadow-md" : ""
167
+ }`}
168
+ >
169
+ <div className="w-full flex items-center justify-between p-3 bg-[var(--kyro-surface-accent)] hover:bg-[var(--kyro-sidebar-active)]/10 transition-colors">
170
+ <div className="flex items-center gap-2 flex-1 min-w-0">
171
+ <div
172
+ {...attributes}
173
+ {...listeners}
174
+ className="p-1 cursor-grab active:cursor-grabbing text-[var(--kyro-text-muted)] hover:bg-[var(--kyro-border)] rounded flex-shrink-0"
175
+ onClick={(e) => e.stopPropagation()}
176
+ >
177
+ <GripVertical className="w-4 h-4" />
178
+ </div>
179
+ <span
180
+ onClick={() => setOpenIndex(isOpen ? null : index)}
181
+ className="text-sm font-medium text-[var(--kyro-text-primary)] truncate cursor-pointer flex-1 py-1 text-left"
182
+ >
183
+ {itemLabel}
184
+ </span>
185
+ </div>
186
+ <div className="flex items-center gap-1">
187
+ <button
188
+ type="button"
189
+ onClick={(e) => {
190
+ e.stopPropagation();
191
+ handleRemove(index);
192
+ }}
193
+ className="opacity-0 group-hover:opacity-100 p-1.5 hover:bg-[var(--kyro-danger-bg)] rounded text-[var(--kyro-error)] transition-opacity"
194
+ title="Remove"
195
+ >
196
+ <X className="w-4 h-4" />
197
+ </button>
198
+ <button
199
+ type="button"
200
+ onClick={() => setOpenIndex(isOpen ? null : index)}
201
+ className="p-1 hover:bg-[var(--kyro-surface-accent)] rounded"
202
+ >
203
+ {isOpen ? (
204
+ <ChevronUp className="w-4 h-4 text-[var(--kyro-text-muted)]" />
205
+ ) : (
206
+ <ChevronDown className="w-4 h-4 text-[var(--kyro-text-muted)]" />
207
+ )}
208
+ </button>
209
+ </div>
210
+ </div>
211
+ {isOpen && (
212
+ <div className="p-3 bg-[var(--kyro-surface)] space-y-2">
213
+ {itemKeys.length > 0 ? (
214
+ itemKeys.map((key) => (
215
+ <input
216
+ key={key}
217
+ type="text"
218
+ value={String(item[key] || "")}
219
+ onChange={(e) =>
220
+ handleItemChange(index, key, e.target.value)
221
+ }
222
+ className={inputClass}
223
+ placeholder={key}
224
+ />
225
+ ))
226
+ ) : (
227
+ <input
228
+ type="text"
229
+ value={String(item.value || "")}
230
+ onChange={(e) =>
231
+ handleItemChange(index, "value", e.target.value)
232
+ }
233
+ className={inputClass}
234
+ placeholder="Value..."
235
+ />
236
+ )}
237
+ </div>
238
+ )}
239
+ </div>
240
+ );
241
+ }
242
+
15
243
  export const ArrayField: React.FC<ArrayFieldProps> = ({
16
244
  items = [],
17
245
  labelField = "title",
@@ -20,6 +248,32 @@ export const ArrayField: React.FC<ArrayFieldProps> = ({
20
248
  }) => {
21
249
  const [openIndex, setOpenIndex] = React.useState<number | null>(0);
22
250
 
251
+ // Sync stable IDs and heal bad data
252
+ React.useEffect(() => {
253
+ let needsUpdate = false;
254
+ const updated = items
255
+ .filter((item) => {
256
+ if (typeof item !== "object" || item === null) {
257
+ needsUpdate = true;
258
+ return false;
259
+ }
260
+ return true;
261
+ })
262
+ .map((item) => {
263
+ if (!item.id && !item._key) {
264
+ needsUpdate = true;
265
+ return {
266
+ ...item,
267
+ id: Math.random().toString(36).substr(2, 9),
268
+ };
269
+ }
270
+ return item;
271
+ });
272
+ if (needsUpdate) {
273
+ onChange(updated);
274
+ }
275
+ }, [items, onChange]);
276
+
23
277
  const handleItemChange = (index: number, field: string, value: string) => {
24
278
  const newItems = [...items];
25
279
  newItems[index] = { ...newItems[index], [field]: value };
@@ -35,7 +289,9 @@ export const ArrayField: React.FC<ArrayFieldProps> = ({
35
289
  };
36
290
 
37
291
  const handleAdd = () => {
292
+ const newId = Math.random().toString(36).substr(2, 9);
38
293
  const newItem: ArrayFieldItem = {
294
+ id: newId,
39
295
  [labelField]: `Item ${items.length + 1}`,
40
296
  };
41
297
  onChange([...items, newItem]);
@@ -51,88 +307,71 @@ export const ArrayField: React.FC<ArrayFieldProps> = ({
51
307
  ? Object.keys(items[0]).filter((k) => k !== "id" && k !== "_key")
52
308
  : [];
53
309
 
54
- if (compact) {
55
- return (
56
- <div className="space-y-1.5">
310
+ const sensors = useSensors(
311
+ useSensor(PointerSensor, {
312
+ activationConstraint: {
313
+ distance: 3,
314
+ },
315
+ })
316
+ );
317
+
318
+ const handleDragEnd = (event: DragEndEvent) => {
319
+ const { active, over } = event;
320
+ if (!over || active.id === over.id) return;
321
+
322
+ const oldIndex = items.findIndex((item) => (item.id || item._key) === active.id);
323
+ const newIndex = items.findIndex((item) => (item.id || item._key) === over.id);
324
+
325
+ if (oldIndex !== -1 && newIndex !== -1) {
326
+ const newItems = [...items];
327
+ const [movedItem] = newItems.splice(oldIndex, 1);
328
+ newItems.splice(newIndex, 0, movedItem);
329
+ onChange(newItems);
330
+ }
331
+ };
332
+
333
+ const itemIds = React.useMemo(() => {
334
+ return items.map((item) => item?.id || item?._key || "");
335
+ }, [items]);
336
+
337
+ return (
338
+ <DndContext
339
+ sensors={sensors}
340
+ collisionDetection={closestCenter}
341
+ onDragEnd={handleDragEnd}
342
+ >
343
+ <div className={compact ? "space-y-1.5" : "space-y-2"}>
57
344
  {items.length === 0 ? (
58
- <div className="text-center py-4 text-[var(--kyro-text-muted)] text-sm border border-dashed border-[var(--kyro-border)] rounded-md">
345
+ <div className={compact
346
+ ? "text-center py-4 text-[var(--kyro-text-muted)] text-sm border border-dashed border-[var(--kyro-border)] rounded-md"
347
+ : "text-center py-4 text-[var(--kyro-text-muted)] text-sm border border-dashed border-[var(--kyro-border)] rounded-lg"
348
+ }>
59
349
  No items. Click "Add Item" to create one.
60
350
  </div>
61
351
  ) : (
62
- <div className="space-y-1">
63
- {items.map((item, index) => {
64
- const isOpen = openIndex === index;
65
- const itemLabel =
66
- item[labelField] ||
67
- item.title ||
68
- item.name ||
69
- `Item ${index + 1}`;
70
- return (
71
- <div
72
- key={index}
73
- className="border border-[var(--kyro-border)] rounded-lg overflow-hidden group"
74
- >
75
- <button
76
- type="button"
77
- onClick={() => setOpenIndex(isOpen ? null : index)}
78
- className="w-full flex items-center justify-between p-2.5 bg-[var(--kyro-surface-accent)] hover:bg-[var(--kyro-sidebar-active)]/10 transition-colors"
79
- >
80
- <span className="text-sm font-medium text-[var(--kyro-text-primary)] truncate">
81
- {itemLabel}
82
- </span>
83
- <div className="flex items-center gap-1">
84
- <button
85
- type="button"
86
- onClick={(e) => {
87
- e.stopPropagation();
88
- handleRemove(index);
89
- }}
90
- className="opacity-0 group-hover:opacity-100 p-1 hover:bg-[var(--kyro-danger-bg)] rounded text-[var(--kyro-error)] transition-opacity"
91
- title="Remove"
92
- >
93
- <X className="w-3.5 h-3.5" />
94
- </button>
95
- {isOpen ? (
96
- <ChevronUp className="w-4 h-4 text-[var(--kyro-text-muted)]" />
97
- ) : (
98
- <ChevronDown className="w-4 h-4 text-[var(--kyro-text-muted)]" />
99
- )}
100
- </div>
101
- </button>
102
- {isOpen && (
103
- <div className="p-2.5 bg-[var(--kyro-surface)] space-y-2">
104
- {itemKeys.length > 0 ? (
105
- itemKeys.map((key) => (
106
- <input
107
- key={key}
108
- type="text"
109
- value={item[key] || ""}
110
- onChange={(e) =>
111
- handleItemChange(index, key, e.target.value)
112
- }
113
- onClick={(e) => e.stopPropagation()}
114
- className={inputClass}
115
- placeholder={key}
116
- />
117
- ))
118
- ) : (
119
- <input
120
- type="text"
121
- value={item.value || ""}
122
- onChange={(e) =>
123
- handleItemChange(index, "value", e.target.value)
124
- }
125
- onClick={(e) => e.stopPropagation()}
126
- className={inputClass}
127
- placeholder="Value..."
128
- />
129
- )}
130
- </div>
131
- )}
132
- </div>
133
- );
134
- })}
135
- </div>
352
+ <SortableContext
353
+ items={itemIds}
354
+ strategy={verticalListSortingStrategy}
355
+ >
356
+ <div className={compact ? "space-y-1" : "space-y-2"}>
357
+ {items.map((item, index) => (
358
+ <SortableArrayFieldItem
359
+ key={item.id || item._key || index}
360
+ id={item.id || item._key || `idx-${index}`}
361
+ index={index}
362
+ isOpen={openIndex === index}
363
+ setOpenIndex={setOpenIndex}
364
+ item={item as Record<string, unknown>}
365
+ labelField={labelField}
366
+ itemKeys={itemKeys}
367
+ inputClass={inputClass}
368
+ handleItemChange={handleItemChange}
369
+ handleRemove={handleRemove}
370
+ compact={compact}
371
+ />
372
+ ))}
373
+ </div>
374
+ </SortableContext>
136
375
  )}
137
376
  <button
138
377
  type="button"
@@ -143,98 +382,7 @@ export const ArrayField: React.FC<ArrayFieldProps> = ({
143
382
  Add Item
144
383
  </button>
145
384
  </div>
146
- );
147
- }
148
-
149
- return (
150
- <div className="space-y-2">
151
- {items.length === 0 ? (
152
- <div className="text-center py-4 text-[var(--kyro-text-muted)] text-sm border border-dashed border-[var(--kyro-border)] rounded-lg">
153
- No items. Click "Add Item" to create one.
154
- </div>
155
- ) : (
156
- <div className="space-y-2">
157
- {items.map((item, index) => {
158
- const isOpen = openIndex === index;
159
- const itemLabel =
160
- item[labelField] ||
161
- item.title ||
162
- item.name ||
163
- `Item ${index + 1}`;
164
- return (
165
- <div
166
- key={index}
167
- className="border border-[var(--kyro-border)] rounded-lg overflow-hidden group"
168
- >
169
- <button
170
- type="button"
171
- onClick={() => setOpenIndex(isOpen ? null : index)}
172
- className="w-full flex items-center justify-between p-3 bg-[var(--kyro-surface-accent)] hover:bg-[var(--kyro-sidebar-active)]/10 transition-colors"
173
- >
174
- <span className="text-sm font-medium text-[var(--kyro-text-primary)] truncate">
175
- {itemLabel}
176
- </span>
177
- <div className="flex items-center gap-1">
178
- <button
179
- type="button"
180
- onClick={(e) => {
181
- e.stopPropagation();
182
- handleRemove(index);
183
- }}
184
- className="opacity-0 group-hover:opacity-100 p-1.5 hover:bg-[var(--kyro-danger-bg)] rounded text-[var(--kyro-error)] transition-opacity"
185
- title="Remove"
186
- >
187
- <X className="w-4 h-4" />
188
- </button>
189
- {isOpen ? (
190
- <ChevronUp className="w-4 h-4 text-[var(--kyro-text-muted)]" />
191
- ) : (
192
- <ChevronDown className="w-4 h-4 text-[var(--kyro-text-muted)]" />
193
- )}
194
- </div>
195
- </button>
196
- {isOpen && (
197
- <div className="p-3 bg-[var(--kyro-surface)] space-y-2">
198
- {itemKeys.length > 0 ? (
199
- itemKeys.map((key) => (
200
- <input
201
- key={key}
202
- type="text"
203
- value={item[key] || ""}
204
- onChange={(e) =>
205
- handleItemChange(index, key, e.target.value)
206
- }
207
- className={inputClass}
208
- placeholder={key}
209
- />
210
- ))
211
- ) : (
212
- <input
213
- type="text"
214
- value={item.value || ""}
215
- onChange={(e) =>
216
- handleItemChange(index, "value", e.target.value)
217
- }
218
- className={inputClass}
219
- placeholder="Value..."
220
- />
221
- )}
222
- </div>
223
- )}
224
- </div>
225
- );
226
- })}
227
- </div>
228
- )}
229
- <button
230
- type="button"
231
- onClick={handleAdd}
232
- className="flex items-center justify-center gap-1.5 w-full px-3 py-2 text-xs font-medium rounded-lg border border-dashed border-[var(--kyro-border)] bg-[var(--kyro-surface-accent)] text-[var(--kyro-text-secondary)] hover:border-[var(--kyro-sidebar-active)] hover:text-[var(--kyro-text-primary)] transition-colors"
233
- >
234
- <Plus className="w-3.5 h-3.5" />
235
- Add Item
236
- </button>
237
- </div>
385
+ </DndContext>
238
386
  );
239
387
  };
240
388