@kyro-cms/admin 0.9.0 → 0.9.2
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 +11715 -11292
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +67 -65
- package/dist/index.css.map +1 -1
- package/dist/index.d.cts +564 -0
- package/dist/index.d.ts +11 -10
- package/dist/index.js +11326 -10912
- package/dist/index.js.map +1 -1
- package/package.json +16 -12
- package/src/components/ActionBar.tsx +25 -161
- package/src/components/Admin.tsx +2 -4
- package/src/components/ApiKeysManager.tsx +5 -5
- package/src/components/AuditLogsPage.tsx +2 -13
- package/src/components/AutoForm.tsx +572 -461
- package/src/components/BrandingHub.tsx +7 -4
- package/src/components/CreateView.tsx +2 -0
- package/src/components/DetailView.tsx +52 -65
- package/src/components/DeveloperCenter.tsx +8 -6
- package/src/components/FieldRenderer.tsx +94 -19
- package/src/components/ListView.tsx +57 -216
- package/src/components/MediaGallery.tsx +334 -367
- package/src/components/PluginsManager.tsx +197 -70
- package/src/components/RestPlayground.tsx +59 -52
- package/src/components/SessionsManager.tsx +1 -1
- package/src/components/SettingsPage.tsx +22 -0
- package/src/components/Sidebar.astro +13 -41
- package/src/components/UserManagement.tsx +153 -15
- package/src/components/UserMenu.tsx +30 -4
- package/src/components/VersionHistoryPanel.tsx +112 -119
- package/src/components/WebhookManager.tsx +6 -4
- package/src/components/blocks/ArrayBlock.tsx +6 -23
- package/src/components/blocks/BlockEditModal.tsx +82 -309
- package/src/components/blocks/CardBlock.tsx +35 -0
- package/src/components/blocks/ChildBlocksTree.tsx +57 -31
- package/src/components/blocks/GenericBlock.tsx +44 -0
- package/src/components/blocks/HeadingSubheadingBlock.tsx +32 -0
- package/src/components/blocks/HeroBlock.tsx +5 -14
- package/src/components/blocks/RichTextBlock.tsx +5 -5
- package/src/components/blocks/index.ts +5 -3
- package/src/components/fields/AccordionField.tsx +2 -2
- package/src/components/fields/ArrayField.tsx +1 -1
- package/src/components/fields/ArrayLayout.tsx +120 -29
- package/src/components/fields/BlocksField.tsx +433 -55
- package/src/components/fields/CardField.tsx +73 -0
- package/src/components/fields/CheckboxField.tsx +7 -3
- package/src/components/fields/DateField.tsx +4 -1
- package/src/components/fields/GroupLayout.tsx +2 -2
- package/src/components/fields/HeadingSubheadingField.tsx +43 -0
- package/src/components/fields/ListField.tsx +2 -2
- package/src/components/fields/NumberField.tsx +4 -1
- package/src/components/fields/RelationshipBlockField.tsx +2 -3
- package/src/components/fields/RelationshipField.tsx +155 -90
- package/src/components/fields/RichTextField.tsx +781 -0
- package/src/components/fields/SecretField.tsx +102 -0
- package/src/components/fields/SelectField.tsx +19 -6
- package/src/components/fields/TabsLayout.tsx +19 -9
- package/src/components/fields/TextField.tsx +4 -1
- package/src/components/fields/UploadField.tsx +122 -56
- package/src/components/fields/extensions/blockComponents.tsx +103 -174
- package/src/components/fields/extensions/blocksStore.ts +8 -1
- package/src/components/fields/index.ts +4 -2
- package/src/components/fix_imports.cjs +23 -0
- package/src/components/fix_imports2.cjs +19 -0
- package/src/components/replace_svgs.cjs +63 -0
- package/src/components/ui/Dropdown.tsx +7 -2
- package/src/components/ui/Modal.tsx +24 -27
- package/src/components/ui/PageHeader.tsx +5 -5
- package/src/components/ui/PromptModal.tsx +2 -10
- package/src/components/ui/SlidePanel.tsx +10 -13
- package/src/components/ui/SplitButton.tsx +107 -0
- package/src/components/ui/Toaster.tsx +0 -1
- package/src/components/ui/icons.tsx +110 -109
- package/src/components/users/UserDetail.tsx +79 -16
- package/src/components/users/UsersList.tsx +8 -85
- package/src/hooks/useAutoFormState.ts +187 -196
- package/src/hooks/useQueue.ts +60 -0
- package/src/integration.ts +148 -46
- package/src/kyro-cms.d.ts +7 -2
- package/src/layouts/AdminLayout.astro +22 -2
- package/src/layouts/AuthLayout.astro +67 -7
- package/src/lib/autoform-store.ts +90 -53
- package/src/lib/change-source.ts +9 -0
- package/src/lib/config.ts +104 -8
- package/src/lib/globals.ts +48 -11
- package/src/lib/normalize-upload-fields.ts +41 -0
- package/src/lib/paths.ts +2 -2
- package/src/lib/resolve-field-value.ts +110 -0
- package/src/lib/shim/use-sync-external-store-with-selector.js +30 -0
- package/src/lib/shim/use-sync-external-store.js +1 -0
- package/src/lib/stores/index.ts +1 -0
- package/src/lib/useResourceManager.ts +4 -4
- package/src/lib/vite-shim-plugin.ts +100 -0
- package/src/pages/[collection]/[id].astro +1 -1
- package/src/pages/auth/register.astro +5 -2
- package/src/pages/preview/[collection]/[id].astro +4 -4
- package/src/pages/settings/[slug].astro +2 -2
- package/src/styles/main.css +60 -54
- package/README.md +0 -46
- package/dist/EditorClient-Q23UXR37.cjs +0 -468
- package/dist/EditorClient-Q23UXR37.cjs.map +0 -1
- package/dist/EditorClient-T5PASFNR.js +0 -466
- package/dist/EditorClient-T5PASFNR.js.map +0 -1
- package/dist/chunk-3BGDYKTD.cjs +0 -348
- package/dist/chunk-3BGDYKTD.cjs.map +0 -1
- package/dist/chunk-EEFXLQVT.js +0 -3
- package/dist/chunk-EEFXLQVT.js.map +0 -1
- package/src/components/blocks/ButtonBlock.tsx +0 -64
- package/src/components/blocks/ColumnsBlock.tsx +0 -55
- package/src/components/blocks/DividerBlock.tsx +0 -43
- package/src/components/blocks/LinkBlock.tsx +0 -65
- package/src/components/blocks/VStackBlock.tsx +0 -29
- package/src/components/fields/EditorClient.tsx +0 -535
- package/src/components/fields/PortableTextField.tsx +0 -155
- package/src/components/fields/PortableTextRenderer.tsx +0 -68
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
|
2
2
|
import { useStore } from "zustand";
|
|
3
|
-
import { BlocksContext, createBlocksStore, createNewBlock, type BlocksStoreApi } from "./extensions/blocksStore";
|
|
3
|
+
import { BlocksContext, createBlocksStore, createNewBlock, type BlocksStoreApi, useBlockActions } from "./extensions/blocksStore";
|
|
4
4
|
import { BlockDrawer, DraggableBlockType } from "../ui/BlockDrawer";
|
|
5
|
-
import { Plus, Box } from "../ui/icons";
|
|
5
|
+
import { Plus, Box, X, Copy, ChevronDown } from "../ui/icons";
|
|
6
6
|
import {
|
|
7
7
|
BLOCK_COMPONENTS,
|
|
8
8
|
getBlockComponent,
|
|
9
|
-
blockCategories,
|
|
10
9
|
blockIcons,
|
|
10
|
+
getBlockLabel,
|
|
11
|
+
blockTheme,
|
|
11
12
|
} from "./extensions/blockComponents";
|
|
13
|
+
import { GenericBlock } from "../blocks/GenericBlock";
|
|
14
|
+
import { BlockEditModal } from "../blocks/BlockEditModal";
|
|
12
15
|
import {
|
|
16
|
+
|
|
13
17
|
DndContext,
|
|
14
18
|
closestCenter,
|
|
15
19
|
PointerSensor,
|
|
@@ -40,13 +44,38 @@ interface BlocksFieldProps {
|
|
|
40
44
|
|
|
41
45
|
import { GripVertical } from "../ui/icons";
|
|
42
46
|
|
|
47
|
+
function getBlockPreviewSnippet(
|
|
48
|
+
data: Record<string, any>,
|
|
49
|
+
blockSchema?: Record<string, any>,
|
|
50
|
+
): string {
|
|
51
|
+
if (blockSchema?.fields) {
|
|
52
|
+
for (const field of blockSchema.fields) {
|
|
53
|
+
if (field.type === "text" || field.type === "textarea") {
|
|
54
|
+
const val = data[field.name];
|
|
55
|
+
if (val && typeof val === "string") return val;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return data.heading || data.title || data.text || data.name || data.label || data.sectionTitle || "";
|
|
60
|
+
}
|
|
61
|
+
|
|
43
62
|
// Sortable block wrapper for drag-and-drop
|
|
44
63
|
const SortableBlockComponent = ({
|
|
45
64
|
block,
|
|
46
65
|
index,
|
|
66
|
+
blockSchema,
|
|
67
|
+
editingBlockId,
|
|
68
|
+
setEditingBlockId,
|
|
69
|
+
onDuplicate,
|
|
70
|
+
compact,
|
|
47
71
|
}: {
|
|
48
72
|
block: Record<string, unknown>;
|
|
49
73
|
index: number;
|
|
74
|
+
blockSchema?: Record<string, any>;
|
|
75
|
+
editingBlockId: string | null;
|
|
76
|
+
setEditingBlockId: (id: string | null) => void;
|
|
77
|
+
onDuplicate: (id: string) => void;
|
|
78
|
+
compact?: boolean;
|
|
50
79
|
}) => {
|
|
51
80
|
const {
|
|
52
81
|
attributes,
|
|
@@ -57,6 +86,10 @@ const SortableBlockComponent = ({
|
|
|
57
86
|
isDragging,
|
|
58
87
|
} = useSortable({ id: block.id });
|
|
59
88
|
|
|
89
|
+
const { removeBlock } = useBlockActions();
|
|
90
|
+
const isEditing = editingBlockId === block.id;
|
|
91
|
+
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
92
|
+
|
|
60
93
|
const style = {
|
|
61
94
|
transform: CSS.Transform.toString(transform),
|
|
62
95
|
transition,
|
|
@@ -64,10 +97,101 @@ const SortableBlockComponent = ({
|
|
|
64
97
|
opacity: isDragging ? 0.8 : 1,
|
|
65
98
|
};
|
|
66
99
|
|
|
67
|
-
const
|
|
100
|
+
const itemLabel = getBlockLabel(block.type as string);
|
|
101
|
+
const data = (block.data || {}) as Record<string, any>;
|
|
102
|
+
const previewSnippet = getBlockPreviewSnippet(data, blockSchema);
|
|
103
|
+
|
|
104
|
+
if (compact) {
|
|
105
|
+
return (
|
|
106
|
+
<div ref={setNodeRef} style={style} className="relative group">
|
|
107
|
+
<div
|
|
108
|
+
onClick={() => setEditingBlockId(block.id as string)}
|
|
109
|
+
className={`flex items-center gap-1 pl-5 pr-1.5 py-1 bg-[var(--kyro-bg-secondary)] rounded-md border transition-colors cursor-pointer text-xs whitespace-nowrap ${
|
|
110
|
+
isEditing
|
|
111
|
+
? `${(blockTheme[block.type as string] || blockTheme.default).border} bg-[var(--kyro-primary)]/5`
|
|
112
|
+
: "border-[var(--kyro-border)] hover:border-[var(--kyro-primary)]/50 hover:bg-[var(--kyro-primary)]/5"
|
|
113
|
+
}`}
|
|
114
|
+
>
|
|
115
|
+
<div
|
|
116
|
+
className="absolute left-0.5 top-1/2 -translate-y-1/2 p-0.5 cursor-grab active:cursor-grabbing text-[var(--kyro-text-muted)] opacity-0 group-hover:opacity-100 transition-opacity hover:bg-[var(--kyro-surface-accent)] rounded"
|
|
117
|
+
{...attributes}
|
|
118
|
+
{...listeners}
|
|
119
|
+
onClick={(e) => e.stopPropagation()}
|
|
120
|
+
>
|
|
121
|
+
<GripVertical className="w-2.5 h-2.5" />
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
{blockIcons[block.type as string] && (
|
|
125
|
+
<span className="text-[var(--kyro-text-secondary)] flex-shrink-0">
|
|
126
|
+
{blockIcons[block.type as string]}
|
|
127
|
+
</span>
|
|
128
|
+
)}
|
|
129
|
+
|
|
130
|
+
<span className="font-medium text-[var(--kyro-text-secondary)] truncate max-w-[120px]">
|
|
131
|
+
{itemLabel}
|
|
132
|
+
</span>
|
|
133
|
+
|
|
134
|
+
{showDeleteConfirm ? (
|
|
135
|
+
<div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
onClick={() => {
|
|
139
|
+
removeBlock(block.id as string);
|
|
140
|
+
setShowDeleteConfirm(false);
|
|
141
|
+
}}
|
|
142
|
+
className="px-1.5 py-0.5 text-[9px] bg-red-500 hover:bg-red-600 text-white rounded font-semibold transition-colors"
|
|
143
|
+
>
|
|
144
|
+
Remove
|
|
145
|
+
</button>
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
onClick={() => setShowDeleteConfirm(false)}
|
|
149
|
+
className="px-1.5 py-0.5 text-[9px] bg-[var(--kyro-surface-accent)] hover:bg-[var(--kyro-border)] text-[var(--kyro-text-secondary)] rounded font-semibold transition-colors"
|
|
150
|
+
>
|
|
151
|
+
Cancel
|
|
152
|
+
</button>
|
|
153
|
+
</div>
|
|
154
|
+
) : (
|
|
155
|
+
<div className="flex items-center gap-0.5">
|
|
156
|
+
<button
|
|
157
|
+
type="button"
|
|
158
|
+
onClick={(e) => {
|
|
159
|
+
e.stopPropagation();
|
|
160
|
+
onDuplicate(block.id as string);
|
|
161
|
+
}}
|
|
162
|
+
className="p-0.5 hover:bg-[var(--kyro-surface-accent)] rounded text-[var(--kyro-text-secondary)] transition-colors"
|
|
163
|
+
title="Duplicate"
|
|
164
|
+
>
|
|
165
|
+
<Copy className="w-3 h-3" />
|
|
166
|
+
</button>
|
|
167
|
+
<button
|
|
168
|
+
type="button"
|
|
169
|
+
onClick={(e) => {
|
|
170
|
+
e.stopPropagation();
|
|
171
|
+
setShowDeleteConfirm(true);
|
|
172
|
+
}}
|
|
173
|
+
className="p-0.5 hover:bg-red-50 hover:text-red-500 rounded text-[var(--kyro-text-muted)] transition-colors"
|
|
174
|
+
title="Remove"
|
|
175
|
+
>
|
|
176
|
+
<X className="w-3 h-3" />
|
|
177
|
+
</button>
|
|
178
|
+
</div>
|
|
179
|
+
)}
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
{isEditing && (
|
|
183
|
+
<BlockEditModal
|
|
184
|
+
block={block}
|
|
185
|
+
blockSchema={blockSchema}
|
|
186
|
+
onClose={() => setEditingBlockId(null)}
|
|
187
|
+
/>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
68
192
|
|
|
69
193
|
return (
|
|
70
|
-
<div ref={setNodeRef} style={style} className="relative group pl-8">
|
|
194
|
+
<div ref={setNodeRef} style={style} className="relative group pl-8 mb-2">
|
|
71
195
|
<div
|
|
72
196
|
className="absolute left-0 top-1/2 -translate-y-1/2 p-1.5 cursor-grab active:cursor-grabbing text-[var(--kyro-text-muted)] opacity-0 group-hover:opacity-100 transition-opacity hover:bg-[var(--kyro-surface-accent)] rounded"
|
|
73
197
|
{...attributes}
|
|
@@ -75,12 +199,100 @@ const SortableBlockComponent = ({
|
|
|
75
199
|
>
|
|
76
200
|
<GripVertical className="w-4 h-4" />
|
|
77
201
|
</div>
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
202
|
+
|
|
203
|
+
<div
|
|
204
|
+
onClick={() => setEditingBlockId(block.id as string)}
|
|
205
|
+
className={`flex items-center gap-3 p-3 bg-[var(--kyro-bg-secondary)] rounded-lg border transition-colors cursor-pointer ${
|
|
206
|
+
isEditing
|
|
207
|
+
? `${(blockTheme[block.type as string] || blockTheme.default).border} bg-[var(--kyro-primary)]/5`
|
|
208
|
+
: "border-[var(--kyro-border)] hover:border-[var(--kyro-primary)]/50 hover:bg-[var(--kyro-primary)]/5"
|
|
209
|
+
}`}
|
|
210
|
+
>
|
|
211
|
+
{blockIcons[block.type as string] && (
|
|
212
|
+
<span className="text-[var(--kyro-text-secondary)]">
|
|
213
|
+
{blockIcons[block.type as string]}
|
|
214
|
+
</span>
|
|
215
|
+
)}
|
|
216
|
+
|
|
217
|
+
<div className="flex-1 min-w-0">
|
|
218
|
+
<div className="text-xs font-semibold text-[var(--kyro-text-secondary)] truncate">
|
|
219
|
+
{itemLabel}
|
|
220
|
+
{previewSnippet && typeof previewSnippet === "string" && (
|
|
221
|
+
<span className="text-[var(--kyro-text-muted)] font-normal ml-1.5">
|
|
222
|
+
- {previewSnippet.length > 40 ? `${previewSnippet.slice(0, 40)}...` : previewSnippet}
|
|
223
|
+
</span>
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
{blockSchema?.admin?.description && (
|
|
227
|
+
<div className="text-[10px] text-[var(--kyro-text-muted)] mt-0.5 truncate opacity-80">
|
|
228
|
+
{blockSchema.admin.description}
|
|
229
|
+
</div>
|
|
230
|
+
)}
|
|
83
231
|
</div>
|
|
232
|
+
|
|
233
|
+
{block.children && Array.isArray(block.children) && block.children.length > 0 && (
|
|
234
|
+
<span className="text-[10px] bg-[var(--kyro-surface-accent)] px-2 py-0.5 rounded text-[var(--kyro-text-muted)] font-medium">
|
|
235
|
+
{block.children.length} nested
|
|
236
|
+
</span>
|
|
237
|
+
)}
|
|
238
|
+
|
|
239
|
+
{showDeleteConfirm ? (
|
|
240
|
+
<div
|
|
241
|
+
className="flex items-center gap-1.5"
|
|
242
|
+
onClick={(e) => e.stopPropagation()}
|
|
243
|
+
>
|
|
244
|
+
<button
|
|
245
|
+
type="button"
|
|
246
|
+
onClick={() => {
|
|
247
|
+
removeBlock(block.id as string);
|
|
248
|
+
setShowDeleteConfirm(false);
|
|
249
|
+
}}
|
|
250
|
+
className="px-2.5 py-1 text-[10px] bg-red-500 hover:bg-red-600 text-white rounded font-semibold transition-colors"
|
|
251
|
+
>
|
|
252
|
+
Remove
|
|
253
|
+
</button>
|
|
254
|
+
<button
|
|
255
|
+
type="button"
|
|
256
|
+
onClick={() => setShowDeleteConfirm(false)}
|
|
257
|
+
className="px-2.5 py-1 text-[10px] bg-[var(--kyro-surface-accent)] hover:bg-[var(--kyro-border)] text-[var(--kyro-text-secondary)] rounded font-semibold transition-colors"
|
|
258
|
+
>
|
|
259
|
+
Cancel
|
|
260
|
+
</button>
|
|
261
|
+
</div>
|
|
262
|
+
) : (
|
|
263
|
+
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
264
|
+
<button
|
|
265
|
+
type="button"
|
|
266
|
+
onClick={(e) => {
|
|
267
|
+
e.stopPropagation();
|
|
268
|
+
onDuplicate(block.id as string);
|
|
269
|
+
}}
|
|
270
|
+
className="p-1 hover:bg-[var(--kyro-surface-accent)] rounded text-[var(--kyro-text-secondary)] transition-colors"
|
|
271
|
+
title="Duplicate Block"
|
|
272
|
+
>
|
|
273
|
+
<Copy className="w-3.5 h-3.5" />
|
|
274
|
+
</button>
|
|
275
|
+
<button
|
|
276
|
+
type="button"
|
|
277
|
+
onClick={(e) => {
|
|
278
|
+
e.stopPropagation();
|
|
279
|
+
setShowDeleteConfirm(true);
|
|
280
|
+
}}
|
|
281
|
+
className="p-1 hover:bg-red-50 hover:text-red-500 rounded text-[var(--kyro-text-muted)] transition-colors"
|
|
282
|
+
title="Remove Block"
|
|
283
|
+
>
|
|
284
|
+
<X className="w-3.5 h-3.5" />
|
|
285
|
+
</button>
|
|
286
|
+
</div>
|
|
287
|
+
)}
|
|
288
|
+
</div>
|
|
289
|
+
|
|
290
|
+
{isEditing && (
|
|
291
|
+
<BlockEditModal
|
|
292
|
+
block={block}
|
|
293
|
+
blockSchema={blockSchema}
|
|
294
|
+
onClose={() => setEditingBlockId(null)}
|
|
295
|
+
/>
|
|
84
296
|
)}
|
|
85
297
|
</div>
|
|
86
298
|
);
|
|
@@ -99,14 +311,72 @@ export const BlocksField: React.FC<BlocksFieldProps> = ({
|
|
|
99
311
|
justSaved,
|
|
100
312
|
}) => {
|
|
101
313
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
|
314
|
+
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
|
315
|
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
316
|
+
const pickerMode = (field as any).admin?.pickerMode || "drawer";
|
|
317
|
+
const allowedBlocks = (field.blocks as any[]) || [];
|
|
318
|
+
|
|
319
|
+
const groupedBlocks = allowedBlocks.reduce((acc, block) => {
|
|
320
|
+
const groupName = block.admin?.group || "Custom Blocks";
|
|
321
|
+
if (!acc[groupName]) {
|
|
322
|
+
acc[groupName] = [];
|
|
323
|
+
}
|
|
324
|
+
acc[groupName].push(block);
|
|
325
|
+
return acc;
|
|
326
|
+
}, {} as Record<string, any[]>);
|
|
327
|
+
|
|
328
|
+
// Define a preferred ordering of core categories if possible
|
|
329
|
+
const categoryOrder = [
|
|
330
|
+
"Structural Sections",
|
|
331
|
+
"Marketing Grids",
|
|
332
|
+
"Lead Capture & Interactive",
|
|
333
|
+
"Dynamic Content",
|
|
334
|
+
"Basic Content Elements",
|
|
335
|
+
"Custom Blocks",
|
|
336
|
+
];
|
|
337
|
+
|
|
338
|
+
const dynamicCategories = Object.entries(groupedBlocks)
|
|
339
|
+
.sort(([titleA], [titleB]) => {
|
|
340
|
+
const idxA = categoryOrder.indexOf(titleA);
|
|
341
|
+
const idxB = categoryOrder.indexOf(titleB);
|
|
342
|
+
if (idxA !== -1 && idxB !== -1) return idxA - idxB;
|
|
343
|
+
if (idxA !== -1) return -1;
|
|
344
|
+
if (idxB !== -1) return 1;
|
|
345
|
+
return titleA.localeCompare(titleB);
|
|
346
|
+
})
|
|
347
|
+
.map(([title, blocks]) => ({
|
|
348
|
+
title,
|
|
349
|
+
blocks,
|
|
350
|
+
}));
|
|
351
|
+
|
|
102
352
|
const storeRef = useRef<BlocksStoreApi | null>(null);
|
|
103
353
|
if (!storeRef.current) {
|
|
104
|
-
storeRef.current = createBlocksStore();
|
|
354
|
+
storeRef.current = createBlocksStore(allowedBlocks, dynamicCategories);
|
|
105
355
|
}
|
|
106
356
|
const store = storeRef.current;
|
|
107
357
|
|
|
108
358
|
const blocks = useStore(store, (s) => s.blocks);
|
|
109
359
|
const [activeDrag, setActiveDrag] = useState<Active | null>(null);
|
|
360
|
+
const [editingBlockId, setEditingBlockId] = useState<string | null>(null);
|
|
361
|
+
|
|
362
|
+
// Track previous blocks count and IDs to auto-open newly added blocks
|
|
363
|
+
const prevBlocksLengthRef = useRef(blocks.length);
|
|
364
|
+
const prevBlockIdsRef = useRef(new Set(blocks.map((b) => b.id)));
|
|
365
|
+
const isInitializedRef = useRef(false);
|
|
366
|
+
|
|
367
|
+
useEffect(() => {
|
|
368
|
+
if (isInitializedRef.current) {
|
|
369
|
+
if (blocks.length > prevBlocksLengthRef.current) {
|
|
370
|
+
// Find the new block ID that wasn't present before
|
|
371
|
+
const newBlock = blocks.find((b) => b.id && !prevBlockIdsRef.current.has(b.id));
|
|
372
|
+
if (newBlock) {
|
|
373
|
+
setEditingBlockId(newBlock.id);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
prevBlocksLengthRef.current = blocks.length;
|
|
378
|
+
prevBlockIdsRef.current = new Set(blocks.map((b) => b.id));
|
|
379
|
+
}, [blocks]);
|
|
110
380
|
|
|
111
381
|
// Register blocks change callback
|
|
112
382
|
useEffect(() => {
|
|
@@ -124,35 +394,34 @@ export const BlocksField: React.FC<BlocksFieldProps> = ({
|
|
|
124
394
|
useEffect(() => {
|
|
125
395
|
const valueArray = Array.isArray(value) ? value : [];
|
|
126
396
|
const lastValueArray = lastValueRef.current || [];
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
397
|
+
|
|
398
|
+
// Deep compare to catch external data changes (e.g. discard draft / auto-save restore)
|
|
399
|
+
if (JSON.stringify(valueArray) !== JSON.stringify(lastValueArray)) {
|
|
400
|
+
const valueArrayCopy = [...valueArray];
|
|
401
|
+
prevBlocksLengthRef.current = valueArrayCopy.length;
|
|
402
|
+
prevBlockIdsRef.current = new Set(valueArrayCopy.map((b: Record<string, unknown>) => b.id));
|
|
403
|
+
store.getState().setBlocks(valueArrayCopy);
|
|
404
|
+
lastValueRef.current = valueArrayCopy;
|
|
405
|
+
isInitializedRef.current = true;
|
|
406
|
+
} else if (valueArray.length === 0 && !isInitializedRef.current) {
|
|
407
|
+
isInitializedRef.current = true;
|
|
408
|
+
lastValueRef.current = []; // Fix for new pages starting with empty arrays
|
|
134
409
|
}
|
|
135
410
|
}, [value, field.name, store]);
|
|
136
411
|
|
|
137
|
-
//
|
|
138
|
-
const onChangeTimer = useRef<number | null>(null);
|
|
412
|
+
// Propagate blocks to parent only when they differ from the last loaded value
|
|
139
413
|
const onChangeRef = useRef(onChange);
|
|
140
414
|
onChangeRef.current = onChange;
|
|
141
415
|
useEffect(() => {
|
|
142
416
|
if (!onChangeRef.current) return;
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
417
|
+
const lastValue = lastValueRef.current;
|
|
418
|
+
if (!lastValue) return; // Wait until initialized
|
|
419
|
+
|
|
420
|
+
// Deep compare blocks vs lastValue to detect content edits, not just ID changes
|
|
421
|
+
if (JSON.stringify(blocks) !== JSON.stringify(lastValue)) {
|
|
422
|
+
lastValueRef.current = [...blocks]; // Update ref BEFORE firing onChange to prevent loops
|
|
423
|
+
onChangeRef.current(blocks);
|
|
146
424
|
}
|
|
147
|
-
onChangeTimer.current = window.setTimeout(() => {
|
|
148
|
-
onChangeRef.current?.(blocks);
|
|
149
|
-
}, 250);
|
|
150
|
-
return () => {
|
|
151
|
-
if (onChangeTimer.current) {
|
|
152
|
-
window.clearTimeout(onChangeTimer.current);
|
|
153
|
-
onChangeTimer.current = null;
|
|
154
|
-
}
|
|
155
|
-
};
|
|
156
425
|
}, [blocks]);
|
|
157
426
|
|
|
158
427
|
// Determine left border style based on document status
|
|
@@ -177,6 +446,31 @@ export const BlocksField: React.FC<BlocksFieldProps> = ({
|
|
|
177
446
|
[store],
|
|
178
447
|
);
|
|
179
448
|
|
|
449
|
+
const duplicateBlock = useCallback(
|
|
450
|
+
(blockId: string) => {
|
|
451
|
+
const blockIndex = blocks.findIndex((b) => b.id === blockId);
|
|
452
|
+
if (blockIndex === -1) return;
|
|
453
|
+
|
|
454
|
+
const blockToClone = blocks[blockIndex];
|
|
455
|
+
|
|
456
|
+
const cloneBlock = (b: any): any => {
|
|
457
|
+
const newId = Math.random().toString(36).substr(2, 9);
|
|
458
|
+
return {
|
|
459
|
+
...b,
|
|
460
|
+
id: newId,
|
|
461
|
+
children: b.children ? b.children.map((c: any) => cloneBlock(c)) : b.children,
|
|
462
|
+
data: b.data ? JSON.parse(JSON.stringify(b.data)) : b.data,
|
|
463
|
+
};
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const cloned = cloneBlock(blockToClone);
|
|
467
|
+
const newBlocks = [...blocks];
|
|
468
|
+
newBlocks.splice(blockIndex + 1, 0, cloned);
|
|
469
|
+
store.getState().setBlocks(newBlocks);
|
|
470
|
+
},
|
|
471
|
+
[blocks, store],
|
|
472
|
+
);
|
|
473
|
+
|
|
180
474
|
// Set up dnd-kit sensors
|
|
181
475
|
const sensors = useSensors(
|
|
182
476
|
useSensor(PointerSensor, {
|
|
@@ -248,49 +542,119 @@ const activeBlockLabel = activeBlock
|
|
|
248
542
|
|
|
249
543
|
const borderClass = getBorderClass();
|
|
250
544
|
|
|
545
|
+
useEffect(() => {
|
|
546
|
+
if (!isDropdownOpen) return;
|
|
547
|
+
const handleClick = (e: MouseEvent) => {
|
|
548
|
+
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
|
549
|
+
setIsDropdownOpen(false);
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
document.addEventListener("mousedown", handleClick);
|
|
553
|
+
return () => document.removeEventListener("mousedown", handleClick);
|
|
554
|
+
}, [isDropdownOpen]);
|
|
555
|
+
|
|
251
556
|
return (
|
|
252
|
-
<BlocksContext.Provider value={
|
|
253
|
-
<div className=
|
|
557
|
+
<BlocksContext.Provider value={storeRef.current}>
|
|
558
|
+
<div className="kyro-blocks-field">
|
|
254
559
|
<DndContext
|
|
255
560
|
sensors={sensors}
|
|
256
561
|
collisionDetection={closestCenter}
|
|
257
562
|
onDragStart={handleDragStart}
|
|
258
563
|
onDragEnd={handleDragEnd}
|
|
259
564
|
>
|
|
260
|
-
{/* Block Builder Toolbar */}
|
|
261
565
|
<div className="flex items-center justify-between mb-2">
|
|
262
566
|
<label className="kyro-form-label">{field.label || field.name}</label>
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
567
|
+
{pickerMode === "dropdown" ? (
|
|
568
|
+
<div ref={dropdownRef} className="relative">
|
|
569
|
+
<button
|
|
570
|
+
type="button"
|
|
571
|
+
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
|
572
|
+
disabled={disabled}
|
|
573
|
+
className="flex items-center gap-1.5 px-3 py-2 text-sm text-[var(--kyro-primary)] hover:bg-[var(--kyro-surface-accent)]/30 rounded-md transition-colors disabled:opacity-50 font-semibold"
|
|
574
|
+
>
|
|
575
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
576
|
+
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
|
577
|
+
</svg>
|
|
578
|
+
Add Element
|
|
579
|
+
<ChevronDown className={`w-3.5 h-3.5 transition-transform ${isDropdownOpen ? "rotate-180" : ""}`} />
|
|
580
|
+
</button>
|
|
581
|
+
|
|
582
|
+
{isDropdownOpen && (
|
|
583
|
+
<div className="absolute right-0 top-full mt-1 w-56 bg-[var(--kyro-surface)] border border-[var(--kyro-border)] rounded-lg shadow-xl z-50 py-2 max-h-80 overflow-y-auto">
|
|
584
|
+
{dynamicCategories.map((category) => (
|
|
585
|
+
<div key={category.title}>
|
|
586
|
+
{dynamicCategories.length > 1 && (
|
|
587
|
+
<div className="px-3 py-1.5 text-[10px] font-bold tracking-wider text-[var(--kyro-text-muted)] uppercase">
|
|
588
|
+
{category.title}
|
|
589
|
+
</div>
|
|
590
|
+
)}
|
|
591
|
+
{category.blocks.map((block) => (
|
|
592
|
+
<button
|
|
593
|
+
key={block.slug}
|
|
594
|
+
type="button"
|
|
595
|
+
onClick={() => {
|
|
596
|
+
handleAddBlock(block.slug);
|
|
597
|
+
setIsDropdownOpen(false);
|
|
598
|
+
}}
|
|
599
|
+
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-[var(--kyro-text-primary)] hover:bg-[var(--kyro-surface-accent)]/50 transition-colors text-left"
|
|
600
|
+
>
|
|
601
|
+
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-[var(--kyro-text-muted)]">
|
|
602
|
+
{blockIcons[block.slug as keyof typeof blockIcons] || <Box className="w-4 h-4" />}
|
|
603
|
+
</span>
|
|
604
|
+
<div className="min-w-0 flex-1">
|
|
605
|
+
<div className="text-xs font-semibold truncate">{block.label}</div>
|
|
606
|
+
{block.admin?.description && (
|
|
607
|
+
<div className="text-[10px] text-[var(--kyro-text-muted)] truncate">{block.admin.description}</div>
|
|
608
|
+
)}
|
|
609
|
+
</div>
|
|
610
|
+
</button>
|
|
611
|
+
))}
|
|
612
|
+
</div>
|
|
613
|
+
))}
|
|
614
|
+
</div>
|
|
615
|
+
)}
|
|
616
|
+
</div>
|
|
617
|
+
) : (
|
|
618
|
+
<button
|
|
619
|
+
type="button"
|
|
620
|
+
onClick={() => setIsDrawerOpen(true)}
|
|
621
|
+
disabled={disabled}
|
|
622
|
+
className="flex items-center gap-2 px-3 py-2 text-sm text-[var(--kyro-primary)] hover:bg-[var(--kyro-surface-accent)]/30 rounded-md transition-colors disabled:opacity-50 font-semibold"
|
|
623
|
+
>
|
|
624
|
+
<Plus className="w-4 h-4" />
|
|
625
|
+
Add Block
|
|
626
|
+
</button>
|
|
627
|
+
)}
|
|
628
|
+
</div>
|
|
629
|
+
|
|
630
|
+
<div className="mb-4">
|
|
272
631
|
<BlockDrawer
|
|
273
632
|
open={isDrawerOpen}
|
|
274
633
|
onClose={() => setIsDrawerOpen(false)}
|
|
275
634
|
onSelect={handleAddBlock}
|
|
276
635
|
>
|
|
277
636
|
<div className="space-y-4">
|
|
278
|
-
{
|
|
637
|
+
{dynamicCategories.map((category) => (
|
|
279
638
|
<div key={category.title}>
|
|
280
|
-
<h3 className="text-xs font-semibold text-[var(--kyro-text-muted)] mb-2
|
|
639
|
+
<h3 className="text-xs font-semibold text-[var(--kyro-text-muted)] mb-2 tracking-wider">
|
|
281
640
|
{category.title}
|
|
282
641
|
</h3>
|
|
283
642
|
<div className="grid grid-cols-3 gap-2">
|
|
284
643
|
{category.blocks.map((block) => (
|
|
285
644
|
<DraggableBlockType
|
|
286
|
-
key={block.
|
|
287
|
-
block={
|
|
645
|
+
key={block.slug}
|
|
646
|
+
block={{
|
|
647
|
+
type: block.slug,
|
|
648
|
+
label: block.label,
|
|
649
|
+
description: block.admin?.description || "",
|
|
650
|
+
icon: null,
|
|
651
|
+
}}
|
|
288
652
|
onSelect={handleAddBlock}
|
|
289
653
|
>
|
|
290
654
|
<div className="w-6 h-6 flex items-center justify-center rounded group-hover:bg-[var(--kyro-primary)]/10 group-hover:text-[var(--kyro-primary)] transition-all duration-300">
|
|
291
655
|
<span className="text-[var(--kyro-text-muted)]">
|
|
292
656
|
{blockIcons[
|
|
293
|
-
block.
|
|
657
|
+
block.slug as keyof typeof blockIcons
|
|
294
658
|
] || <Box className="w-4 h-4" />}
|
|
295
659
|
</span>
|
|
296
660
|
</div>
|
|
@@ -308,13 +672,27 @@ const activeBlockLabel = activeBlock
|
|
|
308
672
|
items={blocks.map((b) => b.id)}
|
|
309
673
|
strategy={verticalListSortingStrategy}
|
|
310
674
|
>
|
|
311
|
-
<div className="space-y-4">
|
|
312
|
-
{blocks.map((block, index) =>
|
|
313
|
-
|
|
314
|
-
|
|
675
|
+
<div className={pickerMode === "dropdown" ? "flex flex-wrap gap-1.5" : "space-y-4"}>
|
|
676
|
+
{blocks.map((block, index) => {
|
|
677
|
+
const blockSchema = (field.blocks as any[])?.find(
|
|
678
|
+
(b) => b.slug === block.type
|
|
679
|
+
);
|
|
680
|
+
return (
|
|
681
|
+
<SortableBlock
|
|
682
|
+
key={block.id || index}
|
|
683
|
+
block={block}
|
|
684
|
+
index={index}
|
|
685
|
+
blockSchema={blockSchema}
|
|
686
|
+
editingBlockId={editingBlockId}
|
|
687
|
+
setEditingBlockId={setEditingBlockId}
|
|
688
|
+
onDuplicate={duplicateBlock}
|
|
689
|
+
compact={pickerMode === "dropdown"}
|
|
690
|
+
/>
|
|
691
|
+
);
|
|
692
|
+
})}
|
|
315
693
|
{blocks.length === 0 && (
|
|
316
|
-
<div className="text-center py-12 text-[var(--kyro-text-muted)] border-2 border-dashed border-[var(--kyro-border)] rounded-lg">
|
|
317
|
-
Click the button above to add your first block
|
|
694
|
+
<div className={pickerMode === "dropdown" ? "text-xs text-[var(--kyro-text-muted)] italic py-1" : "text-center py-12 text-[var(--kyro-text-muted)] border-2 border-dashed border-[var(--kyro-border)] rounded-lg"}>
|
|
695
|
+
{pickerMode === "dropdown" ? "No elements added" : "Click the button above to add your first block"}
|
|
318
696
|
</div>
|
|
319
697
|
)}
|
|
320
698
|
</div>
|