@kyro-cms/admin 0.10.20 → 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/dist/index.cjs +20 -20
- package/dist/index.js +20 -20
- package/package.json +2 -2
- package/src/components/fields/ArrayField.tsx +319 -171
- package/src/components/fields/ArrayLayout.tsx +319 -104
- package/src/components/fields/BlocksField.tsx +33 -20
- package/src/components/fields/ListField.tsx +141 -31
- package/src/components/fields/RelationshipField.tsx +139 -2
- package/src/lib/normalize-upload-fields.ts +3 -3
|
@@ -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
|
-
<
|
|
40
|
-
{
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
</
|
|
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
|
|
28
|
-
|
|
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
|
|