@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.
@@ -1,4 +1,19 @@
1
1
  import React from "react";
2
+ import { Plus, 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";
2
17
 
3
18
  interface ListFieldProps {
4
19
  items?: string[];
@@ -6,12 +21,79 @@ interface ListFieldProps {
6
21
  compact?: boolean;
7
22
  }
8
23
 
24
+ interface SortableListItemProps {
25
+ id: string;
26
+ text: string;
27
+ onRemove: () => void;
28
+ }
29
+
30
+ function SortableListItem({ id, text, onRemove }: SortableListItemProps) {
31
+ const {
32
+ attributes,
33
+ listeners,
34
+ setNodeRef,
35
+ transform,
36
+ transition,
37
+ isDragging,
38
+ } = useSortable({ id });
39
+
40
+ const style = {
41
+ transform: CSS.Transform.toString(transform),
42
+ transition,
43
+ zIndex: isDragging ? 10 : 1,
44
+ opacity: isDragging ? 0.8 : 1,
45
+ };
46
+
47
+ return (
48
+ <div
49
+ ref={setNodeRef}
50
+ style={style}
51
+ className={`flex items-center gap-2 group/item p-1.5 hover:bg-[var(--kyro-surface-accent)]/50 rounded-md transition-colors ${
52
+ isDragging ? "bg-[var(--kyro-surface-accent)] shadow-sm" : ""
53
+ }`}
54
+ >
55
+ <div
56
+ {...attributes}
57
+ {...listeners}
58
+ className="p-1 cursor-grab active:cursor-grabbing text-[var(--kyro-text-muted)] hover:bg-[var(--kyro-border)] rounded flex-shrink-0"
59
+ onClick={(e) => e.stopPropagation()}
60
+ >
61
+ <GripVertical className="w-3.5 h-3.5" />
62
+ </div>
63
+ <span className="text-sm text-[var(--kyro-text-primary)] flex-1">
64
+ • {text}
65
+ </span>
66
+ <button
67
+ type="button"
68
+ onClick={onRemove}
69
+ className="opacity-0 group-hover/item:opacity-100 p-1 hover:bg-[var(--kyro-danger-bg)] rounded text-[var(--kyro-error)] transition-opacity"
70
+ >
71
+ ×
72
+ </button>
73
+ </div>
74
+ );
75
+ }
76
+
9
77
  export const ListField: React.FC<ListFieldProps> = ({
10
78
  items = [],
11
79
  onChange,
12
80
  compact = false,
13
81
  }) => {
14
82
  const [inputValue, setInputValue] = React.useState("");
83
+ const [mappedItems, setMappedItems] = React.useState<{ id: string; text: string }[]>([]);
84
+
85
+ // Synchronize incoming items
86
+ React.useEffect(() => {
87
+ const mappedTexts = mappedItems.map(item => item.text);
88
+ if (JSON.stringify(mappedTexts) !== JSON.stringify(items)) {
89
+ setMappedItems(
90
+ items.map((text, idx) => ({
91
+ id: `${text}-${idx}-${Math.random().toString(36).substr(2, 4)}`,
92
+ text,
93
+ }))
94
+ );
95
+ }
96
+ }, [items]);
15
97
 
16
98
  const handleAdd = () => {
17
99
  if (inputValue.trim()) {
@@ -31,43 +113,71 @@ export const ListField: React.FC<ListFieldProps> = ({
31
113
  onChange(items.filter((_, i) => i !== index));
32
114
  };
33
115
 
116
+ const sensors = useSensors(
117
+ useSensor(PointerSensor, {
118
+ activationConstraint: {
119
+ distance: 3,
120
+ },
121
+ })
122
+ );
123
+
124
+ const handleDragEnd = (event: DragEndEvent) => {
125
+ const { active, over } = event;
126
+ if (!over || active.id === over.id) return;
127
+
128
+ const oldIndex = mappedItems.findIndex((item) => item.id === active.id);
129
+ const newIndex = mappedItems.findIndex((item) => item.id === over.id);
130
+
131
+ if (oldIndex !== -1 && newIndex !== -1) {
132
+ const newItems = [...items];
133
+ const [movedItem] = newItems.splice(oldIndex, 1);
134
+ newItems.splice(newIndex, 0, movedItem);
135
+ onChange(newItems);
136
+ }
137
+ };
138
+
34
139
  const inputClass = compact
35
140
  ? "w-full px-2.5 py-1.5 border border-[var(--kyro-border)] rounded-md bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
36
141
  : "w-full px-3 py-2.5 border border-[var(--kyro-border)] rounded-lg bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent";
37
142
 
38
143
  return (
39
- <div className={compact ? "space-y-1.5" : "space-y-2"}>
40
- {items.length === 0 ? (
41
- <div className="text-center py-4 text-[var(--kyro-text-muted)] text-sm border border-dashed border-[var(--kyro-border)] rounded-md">
42
- No items. Type below to add.
43
- </div>
44
- ) : (
45
- <div className="space-y-1">
46
- {items.map((item, index) => (
47
- <div key={index} className="flex items-center gap-2 group/item">
48
- <span className="text-sm text-[var(--kyro-text-primary)] flex-1">
49
- {item}
50
- </span>
51
- <button
52
- type="button"
53
- onClick={() => handleRemove(index)}
54
- className="opacity-0 group-hover/item:opacity-100 p-1 hover:bg-[var(--kyro-danger-bg)] rounded text-[var(--kyro-error)] transition-opacity"
55
- >
56
- ×
57
- </button>
144
+ <DndContext
145
+ sensors={sensors}
146
+ collisionDetection={closestCenter}
147
+ onDragEnd={handleDragEnd}
148
+ >
149
+ <div className={compact ? "space-y-1.5" : "space-y-2"}>
150
+ {items.length === 0 ? (
151
+ <div className="text-center py-4 text-[var(--kyro-text-muted)] text-sm border border-dashed border-[var(--kyro-border)] rounded-md">
152
+ No items. Type below to add.
153
+ </div>
154
+ ) : (
155
+ <SortableContext
156
+ items={mappedItems.map((item) => item.id)}
157
+ strategy={verticalListSortingStrategy}
158
+ >
159
+ <div className="space-y-1">
160
+ {mappedItems.map((item, index) => (
161
+ <SortableListItem
162
+ key={item.id}
163
+ id={item.id}
164
+ text={item.text}
165
+ onRemove={() => handleRemove(index)}
166
+ />
167
+ ))}
58
168
  </div>
59
- ))}
60
- </div>
61
- )}
62
- <input
63
- type="text"
64
- value={inputValue}
65
- onChange={(e) => setInputValue(e.target.value)}
66
- onKeyDown={handleKeyDown}
67
- className={inputClass}
68
- placeholder="Type and press Enter to add..."
69
- />
70
- </div>
169
+ </SortableContext>
170
+ )}
171
+ <input
172
+ type="text"
173
+ value={inputValue}
174
+ onChange={(e) => setInputValue(e.target.value)}
175
+ onKeyDown={handleKeyDown}
176
+ className={inputClass}
177
+ placeholder="Type and press Enter to add..."
178
+ />
179
+ </div>
180
+ </DndContext>
71
181
  );
72
182
  };
73
183
 
@@ -1,7 +1,21 @@
1
1
  import { useEffect, useState, useRef, useCallback } from "react";
2
- import { Search, X, ChevronDown, Loader2 } from "../ui/icons";
2
+ import { Search, X, ChevronDown, Loader2, GripVertical } from "../ui/icons";
3
3
  import { apiGet, buildSearchQuery } from "../../lib/api";
4
4
  import { EmptyState } from "../ui/EmptyState";
5
+ import {
6
+ DndContext,
7
+ closestCenter,
8
+ PointerSensor,
9
+ useSensor,
10
+ useSensors,
11
+ type DragEndEvent,
12
+ } from "@dnd-kit/core";
13
+ import {
14
+ SortableContext,
15
+ useSortable,
16
+ horizontalListSortingStrategy,
17
+ } from "@dnd-kit/sortable";
18
+ import { CSS } from "@dnd-kit/utilities";
5
19
 
6
20
  interface RelationshipFieldProps {
7
21
  field: {
@@ -43,6 +57,63 @@ function getLabel(opt: Record<string, unknown>): string {
43
57
  );
44
58
  }
45
59
 
60
+ interface SortableTagProps {
61
+ id: string;
62
+ label: string;
63
+ relation?: string | null;
64
+ onRemove: () => void;
65
+ disabled?: boolean;
66
+ }
67
+
68
+ function SortableTag({ id, label, relation, onRemove, disabled }: SortableTagProps) {
69
+ const {
70
+ attributes,
71
+ listeners,
72
+ setNodeRef,
73
+ transform,
74
+ transition,
75
+ isDragging,
76
+ } = useSortable({ id });
77
+
78
+ const style = {
79
+ transform: CSS.Transform.toString(transform),
80
+ transition,
81
+ zIndex: isDragging ? 10 : 1,
82
+ opacity: isDragging ? 0.8 : 1,
83
+ };
84
+
85
+ return (
86
+ <span
87
+ ref={setNodeRef}
88
+ style={style}
89
+ className={`inline-flex items-center gap-1 px-2 py-1 text-xs rounded-md bg-[var(--kyro-sidebar-active)]/10 text-[var(--kyro-sidebar-active)] border border-transparent ${
90
+ isDragging ? "border-[var(--kyro-primary)] shadow-sm bg-[var(--kyro-sidebar-active)]/20" : ""
91
+ }`}
92
+ >
93
+ {!disabled && (
94
+ <div
95
+ {...attributes}
96
+ {...listeners}
97
+ className="cursor-grab active:cursor-grabbing hover:bg-[var(--kyro-sidebar-active)]/20 rounded p-0.5 text-[var(--kyro-sidebar-active)] opacity-70 flex items-center justify-center"
98
+ >
99
+ <GripVertical className="w-2.5 h-2.5" />
100
+ </div>
101
+ )}
102
+ {relation && <span className="opacity-60 mr-0.5">{relation}:</span>}
103
+ {label}
104
+ {!disabled && (
105
+ <button
106
+ type="button"
107
+ onClick={onRemove}
108
+ className="hover:opacity-70 p-0.5 ml-0.5 flex items-center justify-center"
109
+ >
110
+ <X className="w-3 h-3" />
111
+ </button>
112
+ )}
113
+ </span>
114
+ );
115
+ }
116
+
46
117
  export function RelationshipField({
47
118
  field,
48
119
  value,
@@ -202,6 +273,33 @@ export function RelationshipField({
202
273
  }
203
274
  };
204
275
 
276
+ const sensors = useSensors(
277
+ useSensor(PointerSensor, {
278
+ activationConstraint: {
279
+ distance: 3,
280
+ },
281
+ })
282
+ );
283
+
284
+ const handleDragEnd = (event: DragEndEvent) => {
285
+ const { active, over } = event;
286
+ if (!over || active.id === over.id) return;
287
+
288
+ const items: unknown[] = isMultiple
289
+ ? Array.isArray(value) ? value : []
290
+ : [];
291
+
292
+ const oldIndex = items.findIndex((item) => getValueId(item) === active.id);
293
+ const newIndex = items.findIndex((item) => getValueId(item) === over.id);
294
+
295
+ if (oldIndex !== -1 && newIndex !== -1) {
296
+ const newItems = [...items];
297
+ const [movedItem] = newItems.splice(oldIndex, 1);
298
+ newItems.splice(newIndex, 0, movedItem);
299
+ onChangeRef.current?.(newItems);
300
+ }
301
+ };
302
+
205
303
  const renderSelectedItems = () => {
206
304
  if (!value) return null;
207
305
 
@@ -209,6 +307,45 @@ export function RelationshipField({
209
307
  ? Array.isArray(value) ? value : []
210
308
  : value ? [value] : [];
211
309
 
310
+ const tagIds = items.map((item) => getValueId(item));
311
+
312
+ if (isMultiple && items.length > 1) {
313
+ return (
314
+ <DndContext
315
+ sensors={sensors}
316
+ collisionDetection={closestCenter}
317
+ onDragEnd={handleDragEnd}
318
+ >
319
+ <div className="flex flex-wrap gap-1.5 mt-2">
320
+ <SortableContext
321
+ items={tagIds}
322
+ strategy={horizontalListSortingStrategy}
323
+ >
324
+ {items.map((item) => {
325
+ const rawId = getValueId(item);
326
+ const doc = selectedDocs.find((d) => d.id === rawId);
327
+ const label = doc ? getLabel(doc) : rawId.slice(0, 12);
328
+ const rel = isPolymorphic && doc ? doc.relationTo : null;
329
+ return (
330
+ <SortableTag
331
+ key={rawId}
332
+ id={rawId}
333
+ label={label}
334
+ relation={rel}
335
+ onRemove={() => {
336
+ const filtered = items.filter((value) => getValueId(value) !== rawId);
337
+ onChangeRef.current?.(filtered);
338
+ }}
339
+ disabled={disabled}
340
+ />
341
+ );
342
+ })}
343
+ </SortableContext>
344
+ </div>
345
+ </DndContext>
346
+ );
347
+ }
348
+
212
349
  return (
213
350
  <div className="flex flex-wrap gap-1.5 mt-2">
214
351
  {items.map((item) => {
@@ -230,7 +367,7 @@ export function RelationshipField({
230
367
  const filtered = items.filter((value) => getValueId(value) !== rawId);
231
368
  onChangeRef.current?.(isMultiple ? filtered : (filtered[0] ?? null));
232
369
  }}
233
- className="hover:opacity-70"
370
+ className="hover:opacity-70 flex items-center justify-center"
234
371
  >
235
372
  <X className="w-3 h-3" />
236
373
  </button>
@@ -21,11 +21,11 @@ export function normalizeUploadFields(value: unknown): unknown {
21
21
 
22
22
  // Heuristic: detect a full media object by checking for id + url/filename/mimeType
23
23
  // and a small number of keys (media objects typically have ~6-8 keys).
24
- // Also handle bare {id} objects that result from JSONB column conversion.
25
24
  const keys = Object.keys(obj);
26
25
  const hasId = "id" in obj && (typeof obj.id === "string" || obj.id === null);
27
- const hasMediaField = "url" in obj || "filename" in obj || "mimeType" in obj;
28
- if (hasId && (hasMediaField || keys.length <= 2) && keys.length <= 8) {
26
+ const hasMediaField = "url" in obj && ("filename" in obj || "mimeType" in obj);
27
+
28
+ if (hasId && hasMediaField && keys.length <= 8) {
29
29
  return obj.id;
30
30
  }
31
31