@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.
Files changed (114) hide show
  1. package/dist/index.cjs +11715 -11292
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +67 -65
  4. package/dist/index.css.map +1 -1
  5. package/dist/index.d.cts +564 -0
  6. package/dist/index.d.ts +11 -10
  7. package/dist/index.js +11326 -10912
  8. package/dist/index.js.map +1 -1
  9. package/package.json +16 -12
  10. package/src/components/ActionBar.tsx +25 -161
  11. package/src/components/Admin.tsx +2 -4
  12. package/src/components/ApiKeysManager.tsx +5 -5
  13. package/src/components/AuditLogsPage.tsx +2 -13
  14. package/src/components/AutoForm.tsx +572 -461
  15. package/src/components/BrandingHub.tsx +7 -4
  16. package/src/components/CreateView.tsx +2 -0
  17. package/src/components/DetailView.tsx +52 -65
  18. package/src/components/DeveloperCenter.tsx +8 -6
  19. package/src/components/FieldRenderer.tsx +94 -19
  20. package/src/components/ListView.tsx +57 -216
  21. package/src/components/MediaGallery.tsx +334 -367
  22. package/src/components/PluginsManager.tsx +197 -70
  23. package/src/components/RestPlayground.tsx +59 -52
  24. package/src/components/SessionsManager.tsx +1 -1
  25. package/src/components/SettingsPage.tsx +22 -0
  26. package/src/components/Sidebar.astro +13 -41
  27. package/src/components/UserManagement.tsx +153 -15
  28. package/src/components/UserMenu.tsx +30 -4
  29. package/src/components/VersionHistoryPanel.tsx +112 -119
  30. package/src/components/WebhookManager.tsx +6 -4
  31. package/src/components/blocks/ArrayBlock.tsx +6 -23
  32. package/src/components/blocks/BlockEditModal.tsx +82 -309
  33. package/src/components/blocks/CardBlock.tsx +35 -0
  34. package/src/components/blocks/ChildBlocksTree.tsx +57 -31
  35. package/src/components/blocks/GenericBlock.tsx +44 -0
  36. package/src/components/blocks/HeadingSubheadingBlock.tsx +32 -0
  37. package/src/components/blocks/HeroBlock.tsx +5 -14
  38. package/src/components/blocks/RichTextBlock.tsx +5 -5
  39. package/src/components/blocks/index.ts +5 -3
  40. package/src/components/fields/AccordionField.tsx +2 -2
  41. package/src/components/fields/ArrayField.tsx +1 -1
  42. package/src/components/fields/ArrayLayout.tsx +120 -29
  43. package/src/components/fields/BlocksField.tsx +433 -55
  44. package/src/components/fields/CardField.tsx +73 -0
  45. package/src/components/fields/CheckboxField.tsx +7 -3
  46. package/src/components/fields/DateField.tsx +4 -1
  47. package/src/components/fields/GroupLayout.tsx +2 -2
  48. package/src/components/fields/HeadingSubheadingField.tsx +43 -0
  49. package/src/components/fields/ListField.tsx +2 -2
  50. package/src/components/fields/NumberField.tsx +4 -1
  51. package/src/components/fields/RelationshipBlockField.tsx +2 -3
  52. package/src/components/fields/RelationshipField.tsx +155 -90
  53. package/src/components/fields/RichTextField.tsx +781 -0
  54. package/src/components/fields/SecretField.tsx +102 -0
  55. package/src/components/fields/SelectField.tsx +19 -6
  56. package/src/components/fields/TabsLayout.tsx +19 -9
  57. package/src/components/fields/TextField.tsx +4 -1
  58. package/src/components/fields/UploadField.tsx +122 -56
  59. package/src/components/fields/extensions/blockComponents.tsx +103 -174
  60. package/src/components/fields/extensions/blocksStore.ts +8 -1
  61. package/src/components/fields/index.ts +4 -2
  62. package/src/components/fix_imports.cjs +23 -0
  63. package/src/components/fix_imports2.cjs +19 -0
  64. package/src/components/replace_svgs.cjs +63 -0
  65. package/src/components/ui/Dropdown.tsx +7 -2
  66. package/src/components/ui/Modal.tsx +24 -27
  67. package/src/components/ui/PageHeader.tsx +5 -5
  68. package/src/components/ui/PromptModal.tsx +2 -10
  69. package/src/components/ui/SlidePanel.tsx +10 -13
  70. package/src/components/ui/SplitButton.tsx +107 -0
  71. package/src/components/ui/Toaster.tsx +0 -1
  72. package/src/components/ui/icons.tsx +110 -109
  73. package/src/components/users/UserDetail.tsx +79 -16
  74. package/src/components/users/UsersList.tsx +8 -85
  75. package/src/hooks/useAutoFormState.ts +187 -196
  76. package/src/hooks/useQueue.ts +60 -0
  77. package/src/integration.ts +148 -46
  78. package/src/kyro-cms.d.ts +7 -2
  79. package/src/layouts/AdminLayout.astro +22 -2
  80. package/src/layouts/AuthLayout.astro +67 -7
  81. package/src/lib/autoform-store.ts +90 -53
  82. package/src/lib/change-source.ts +9 -0
  83. package/src/lib/config.ts +104 -8
  84. package/src/lib/globals.ts +48 -11
  85. package/src/lib/normalize-upload-fields.ts +41 -0
  86. package/src/lib/paths.ts +2 -2
  87. package/src/lib/resolve-field-value.ts +110 -0
  88. package/src/lib/shim/use-sync-external-store-with-selector.js +30 -0
  89. package/src/lib/shim/use-sync-external-store.js +1 -0
  90. package/src/lib/stores/index.ts +1 -0
  91. package/src/lib/useResourceManager.ts +4 -4
  92. package/src/lib/vite-shim-plugin.ts +100 -0
  93. package/src/pages/[collection]/[id].astro +1 -1
  94. package/src/pages/auth/register.astro +5 -2
  95. package/src/pages/preview/[collection]/[id].astro +4 -4
  96. package/src/pages/settings/[slug].astro +2 -2
  97. package/src/styles/main.css +60 -54
  98. package/README.md +0 -46
  99. package/dist/EditorClient-Q23UXR37.cjs +0 -468
  100. package/dist/EditorClient-Q23UXR37.cjs.map +0 -1
  101. package/dist/EditorClient-T5PASFNR.js +0 -466
  102. package/dist/EditorClient-T5PASFNR.js.map +0 -1
  103. package/dist/chunk-3BGDYKTD.cjs +0 -348
  104. package/dist/chunk-3BGDYKTD.cjs.map +0 -1
  105. package/dist/chunk-EEFXLQVT.js +0 -3
  106. package/dist/chunk-EEFXLQVT.js.map +0 -1
  107. package/src/components/blocks/ButtonBlock.tsx +0 -64
  108. package/src/components/blocks/ColumnsBlock.tsx +0 -55
  109. package/src/components/blocks/DividerBlock.tsx +0 -43
  110. package/src/components/blocks/LinkBlock.tsx +0 -65
  111. package/src/components/blocks/VStackBlock.tsx +0 -29
  112. package/src/components/fields/EditorClient.tsx +0 -535
  113. package/src/components/fields/PortableTextField.tsx +0 -155
  114. 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 Component = getBlockComponent(block.type);
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
- {Component ? (
79
- <Component block={block} index={index} />
80
- ) : (
81
- <div className="p-4 border border-[var(--kyro-border)] rounded-md">
82
- Unknown block: {block.type}
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
- const valueIds = valueArray.map((b: Record<string, unknown>) => b.id).join(",");
128
- const lastValueIds = lastValueArray.map((b: Record<string, unknown>) => b.id).join(",");
129
-
130
- if (valueIds !== lastValueIds) {
131
- console.log("BlocksField sync: value=", value, "valueIds=", valueIds, "lastValueIds=", lastValueIds);
132
- store.getState().setBlocks(valueArray);
133
- lastValueRef.current = [...valueArray];
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
- // Debounced sync of store changes back to parent form to reduce re-renders
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
- if (onChangeTimer.current) {
144
- window.clearTimeout(onChangeTimer.current);
145
- onChangeTimer.current = null;
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={store}>
253
- <div className={`kyro-form-field ${borderClass}`}>
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
- <button
264
- type="button"
265
- onClick={() => setIsDrawerOpen(true)}
266
- disabled={disabled}
267
- 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"
268
- >
269
- <Plus className="w-4 h-4" />
270
- Add Block
271
- </button>
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
- {blockCategories.map((category) => (
637
+ {dynamicCategories.map((category) => (
279
638
  <div key={category.title}>
280
- <h3 className="text-xs font-semibold text-[var(--kyro-text-muted)] mb-2 tracking-wider">
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.type}
287
- block={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.icon as keyof typeof blockIcons
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
- <SortableBlock key={block.id || index} block={block} index={index} />
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>