@kyro-cms/admin 0.9.0 → 0.9.1

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 (100) hide show
  1. package/dist/index.cjs +11960 -11006
  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 +563 -0
  6. package/dist/index.d.ts +7 -7
  7. package/dist/index.js +12183 -11238
  8. package/dist/index.js.map +1 -1
  9. package/package.json +15 -11
  10. package/src/components/ActionBar.tsx +27 -14
  11. package/src/components/Admin.tsx +1 -1
  12. package/src/components/ApiKeysManager.tsx +5 -5
  13. package/src/components/AutoForm.tsx +585 -369
  14. package/src/components/BrandingHub.tsx +7 -4
  15. package/src/components/CreateView.tsx +2 -0
  16. package/src/components/DetailView.tsx +71 -56
  17. package/src/components/DeveloperCenter.tsx +8 -6
  18. package/src/components/FieldRenderer.tsx +94 -19
  19. package/src/components/ListView.tsx +33 -20
  20. package/src/components/MediaGallery.tsx +219 -194
  21. package/src/components/PluginsManager.tsx +197 -70
  22. package/src/components/RestPlayground.tsx +7 -7
  23. package/src/components/SessionsManager.tsx +1 -1
  24. package/src/components/SettingsPage.tsx +22 -0
  25. package/src/components/Sidebar.astro +13 -41
  26. package/src/components/UserManagement.tsx +153 -15
  27. package/src/components/UserMenu.tsx +30 -4
  28. package/src/components/VersionHistoryPanel.tsx +112 -119
  29. package/src/components/WebhookManager.tsx +6 -4
  30. package/src/components/blocks/ArrayBlock.tsx +6 -23
  31. package/src/components/blocks/BlockEditModal.tsx +82 -309
  32. package/src/components/blocks/CardBlock.tsx +35 -0
  33. package/src/components/blocks/ChildBlocksTree.tsx +57 -31
  34. package/src/components/blocks/GenericBlock.tsx +44 -0
  35. package/src/components/blocks/HeadingSubheadingBlock.tsx +32 -0
  36. package/src/components/blocks/HeroBlock.tsx +5 -14
  37. package/src/components/blocks/RichTextBlock.tsx +5 -5
  38. package/src/components/blocks/index.ts +5 -3
  39. package/src/components/fields/AccordionField.tsx +2 -2
  40. package/src/components/fields/ArrayField.tsx +1 -1
  41. package/src/components/fields/ArrayLayout.tsx +120 -29
  42. package/src/components/fields/BlocksField.tsx +430 -50
  43. package/src/components/fields/CardField.tsx +73 -0
  44. package/src/components/fields/CheckboxField.tsx +7 -3
  45. package/src/components/fields/DateField.tsx +4 -1
  46. package/src/components/fields/GroupLayout.tsx +2 -2
  47. package/src/components/fields/HeadingSubheadingField.tsx +43 -0
  48. package/src/components/fields/ListField.tsx +2 -2
  49. package/src/components/fields/NumberField.tsx +4 -1
  50. package/src/components/fields/RelationshipField.tsx +153 -87
  51. package/src/components/fields/RichTextField.tsx +781 -0
  52. package/src/components/fields/SecretField.tsx +102 -0
  53. package/src/components/fields/SelectField.tsx +19 -6
  54. package/src/components/fields/TabsLayout.tsx +19 -9
  55. package/src/components/fields/TextField.tsx +4 -1
  56. package/src/components/fields/UploadField.tsx +122 -56
  57. package/src/components/fields/extensions/blockComponents.tsx +103 -174
  58. package/src/components/fields/extensions/blocksStore.ts +8 -1
  59. package/src/components/fields/index.ts +4 -2
  60. package/src/components/ui/PageHeader.tsx +5 -5
  61. package/src/components/ui/SlidePanel.tsx +8 -3
  62. package/src/components/ui/icons.tsx +109 -109
  63. package/src/components/users/UserDetail.tsx +79 -16
  64. package/src/hooks/useAutoFormState.ts +125 -62
  65. package/src/integration.ts +148 -46
  66. package/src/kyro-cms.d.ts +7 -2
  67. package/src/layouts/AuthLayout.astro +14 -2
  68. package/src/lib/autoform-store.ts +85 -52
  69. package/src/lib/change-source.ts +9 -0
  70. package/src/lib/config.ts +104 -8
  71. package/src/lib/globals.ts +44 -9
  72. package/src/lib/normalize-upload-fields.ts +41 -0
  73. package/src/lib/paths.ts +2 -2
  74. package/src/lib/resolve-field-value.ts +110 -0
  75. package/src/lib/shim/use-sync-external-store-with-selector.js +30 -0
  76. package/src/lib/shim/use-sync-external-store.js +1 -0
  77. package/src/lib/stores/index.ts +1 -0
  78. package/src/lib/useResourceManager.ts +4 -4
  79. package/src/lib/vite-shim-plugin.ts +100 -0
  80. package/src/pages/[collection]/[id].astro +1 -1
  81. package/src/pages/preview/[collection]/[id].astro +4 -4
  82. package/src/pages/settings/[slug].astro +2 -2
  83. package/src/styles/main.css +60 -54
  84. package/README.md +0 -46
  85. package/dist/EditorClient-Q23UXR37.cjs +0 -468
  86. package/dist/EditorClient-Q23UXR37.cjs.map +0 -1
  87. package/dist/EditorClient-T5PASFNR.js +0 -466
  88. package/dist/EditorClient-T5PASFNR.js.map +0 -1
  89. package/dist/chunk-3BGDYKTD.cjs +0 -348
  90. package/dist/chunk-3BGDYKTD.cjs.map +0 -1
  91. package/dist/chunk-EEFXLQVT.js +0 -3
  92. package/dist/chunk-EEFXLQVT.js.map +0 -1
  93. package/src/components/blocks/ButtonBlock.tsx +0 -64
  94. package/src/components/blocks/ColumnsBlock.tsx +0 -55
  95. package/src/components/blocks/DividerBlock.tsx +0 -43
  96. package/src/components/blocks/LinkBlock.tsx +0 -65
  97. package/src/components/blocks/VStackBlock.tsx +0 -29
  98. package/src/components/fields/EditorClient.tsx +0 -535
  99. package/src/components/fields/PortableTextField.tsx +0 -155
  100. 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(() => {
@@ -129,30 +399,31 @@ export const BlocksField: React.FC<BlocksFieldProps> = ({
129
399
 
130
400
  if (valueIds !== lastValueIds) {
131
401
  console.log("BlocksField sync: value=", value, "valueIds=", valueIds, "lastValueIds=", lastValueIds);
132
- store.getState().setBlocks(valueArray);
133
- lastValueRef.current = [...valueArray];
402
+ const valueArrayCopy = [...valueArray];
403
+ prevBlocksLengthRef.current = valueArrayCopy.length;
404
+ prevBlockIdsRef.current = new Set(valueArrayCopy.map((b: Record<string, unknown>) => b.id));
405
+ store.getState().setBlocks(valueArrayCopy);
406
+ lastValueRef.current = valueArrayCopy;
407
+ isInitializedRef.current = true;
408
+ } else if (valueArray.length === 0 && !isInitializedRef.current) {
409
+ isInitializedRef.current = true;
134
410
  }
135
411
  }, [value, field.name, store]);
136
412
 
137
- // Debounced sync of store changes back to parent form to reduce re-renders
138
- const onChangeTimer = useRef<number | null>(null);
413
+ // Propagate blocks to parent only when they differ from the last loaded value
139
414
  const onChangeRef = useRef(onChange);
140
415
  onChangeRef.current = onChange;
141
416
  useEffect(() => {
142
417
  if (!onChangeRef.current) return;
143
- if (onChangeTimer.current) {
144
- window.clearTimeout(onChangeTimer.current);
145
- onChangeTimer.current = null;
418
+ const lastValue = lastValueRef.current;
419
+ if (!lastValue) return;
420
+
421
+ const currentIds = blocks.map((b: Record<string, unknown>) => b.id).join(",");
422
+ const lastIds = lastValue.map((b: Record<string, unknown>) => b.id).join(",");
423
+
424
+ if (currentIds !== lastIds) {
425
+ onChangeRef.current(blocks);
146
426
  }
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
427
  }, [blocks]);
157
428
 
158
429
  // Determine left border style based on document status
@@ -177,6 +448,31 @@ export const BlocksField: React.FC<BlocksFieldProps> = ({
177
448
  [store],
178
449
  );
179
450
 
451
+ const duplicateBlock = useCallback(
452
+ (blockId: string) => {
453
+ const blockIndex = blocks.findIndex((b) => b.id === blockId);
454
+ if (blockIndex === -1) return;
455
+
456
+ const blockToClone = blocks[blockIndex];
457
+
458
+ const cloneBlock = (b: any): any => {
459
+ const newId = Math.random().toString(36).substr(2, 9);
460
+ return {
461
+ ...b,
462
+ id: newId,
463
+ children: b.children ? b.children.map((c: any) => cloneBlock(c)) : b.children,
464
+ data: b.data ? JSON.parse(JSON.stringify(b.data)) : b.data,
465
+ };
466
+ };
467
+
468
+ const cloned = cloneBlock(blockToClone);
469
+ const newBlocks = [...blocks];
470
+ newBlocks.splice(blockIndex + 1, 0, cloned);
471
+ store.getState().setBlocks(newBlocks);
472
+ },
473
+ [blocks, store],
474
+ );
475
+
180
476
  // Set up dnd-kit sensors
181
477
  const sensors = useSensors(
182
478
  useSensor(PointerSensor, {
@@ -248,49 +544,119 @@ const activeBlockLabel = activeBlock
248
544
 
249
545
  const borderClass = getBorderClass();
250
546
 
547
+ useEffect(() => {
548
+ if (!isDropdownOpen) return;
549
+ const handleClick = (e: MouseEvent) => {
550
+ if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
551
+ setIsDropdownOpen(false);
552
+ }
553
+ };
554
+ document.addEventListener("mousedown", handleClick);
555
+ return () => document.removeEventListener("mousedown", handleClick);
556
+ }, [isDropdownOpen]);
557
+
251
558
  return (
252
- <BlocksContext.Provider value={store}>
253
- <div className={`kyro-form-field ${borderClass}`}>
559
+ <BlocksContext.Provider value={storeRef.current}>
560
+ <div className="kyro-blocks-field">
254
561
  <DndContext
255
562
  sensors={sensors}
256
563
  collisionDetection={closestCenter}
257
564
  onDragStart={handleDragStart}
258
565
  onDragEnd={handleDragEnd}
259
566
  >
260
- {/* Block Builder Toolbar */}
261
567
  <div className="flex items-center justify-between mb-2">
262
568
  <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>
569
+ {pickerMode === "dropdown" ? (
570
+ <div ref={dropdownRef} className="relative">
571
+ <button
572
+ type="button"
573
+ onClick={() => setIsDropdownOpen(!isDropdownOpen)}
574
+ disabled={disabled}
575
+ 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"
576
+ >
577
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
578
+ <line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
579
+ </svg>
580
+ Add Element
581
+ <ChevronDown className={`w-3.5 h-3.5 transition-transform ${isDropdownOpen ? "rotate-180" : ""}`} />
582
+ </button>
583
+
584
+ {isDropdownOpen && (
585
+ <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">
586
+ {dynamicCategories.map((category) => (
587
+ <div key={category.title}>
588
+ {dynamicCategories.length > 1 && (
589
+ <div className="px-3 py-1.5 text-[10px] font-bold tracking-wider text-[var(--kyro-text-muted)] uppercase">
590
+ {category.title}
591
+ </div>
592
+ )}
593
+ {category.blocks.map((block) => (
594
+ <button
595
+ key={block.slug}
596
+ type="button"
597
+ onClick={() => {
598
+ handleAddBlock(block.slug);
599
+ setIsDropdownOpen(false);
600
+ }}
601
+ 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"
602
+ >
603
+ <span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-[var(--kyro-text-muted)]">
604
+ {blockIcons[block.slug as keyof typeof blockIcons] || <Box className="w-4 h-4" />}
605
+ </span>
606
+ <div className="min-w-0 flex-1">
607
+ <div className="text-xs font-semibold truncate">{block.label}</div>
608
+ {block.admin?.description && (
609
+ <div className="text-[10px] text-[var(--kyro-text-muted)] truncate">{block.admin.description}</div>
610
+ )}
611
+ </div>
612
+ </button>
613
+ ))}
614
+ </div>
615
+ ))}
616
+ </div>
617
+ )}
618
+ </div>
619
+ ) : (
620
+ <button
621
+ type="button"
622
+ onClick={() => setIsDrawerOpen(true)}
623
+ disabled={disabled}
624
+ 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"
625
+ >
626
+ <Plus className="w-4 h-4" />
627
+ Add Block
628
+ </button>
629
+ )}
630
+ </div>
631
+
632
+ <div className="mb-4">
272
633
  <BlockDrawer
273
634
  open={isDrawerOpen}
274
635
  onClose={() => setIsDrawerOpen(false)}
275
636
  onSelect={handleAddBlock}
276
637
  >
277
638
  <div className="space-y-4">
278
- {blockCategories.map((category) => (
639
+ {dynamicCategories.map((category) => (
279
640
  <div key={category.title}>
280
- <h3 className="text-xs font-semibold text-[var(--kyro-text-muted)] mb-2 tracking-wider">
641
+ <h3 className="text-xs font-semibold text-[var(--kyro-text-muted)] mb-2 tracking-wider">
281
642
  {category.title}
282
643
  </h3>
283
644
  <div className="grid grid-cols-3 gap-2">
284
645
  {category.blocks.map((block) => (
285
646
  <DraggableBlockType
286
- key={block.type}
287
- block={block}
647
+ key={block.slug}
648
+ block={{
649
+ type: block.slug,
650
+ label: block.label,
651
+ description: block.admin?.description || "",
652
+ icon: null,
653
+ }}
288
654
  onSelect={handleAddBlock}
289
655
  >
290
656
  <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
657
  <span className="text-[var(--kyro-text-muted)]">
292
658
  {blockIcons[
293
- block.icon as keyof typeof blockIcons
659
+ block.slug as keyof typeof blockIcons
294
660
  ] || <Box className="w-4 h-4" />}
295
661
  </span>
296
662
  </div>
@@ -308,13 +674,27 @@ const activeBlockLabel = activeBlock
308
674
  items={blocks.map((b) => b.id)}
309
675
  strategy={verticalListSortingStrategy}
310
676
  >
311
- <div className="space-y-4">
312
- {blocks.map((block, index) => (
313
- <SortableBlock key={block.id || index} block={block} index={index} />
314
- ))}
677
+ <div className={pickerMode === "dropdown" ? "flex flex-wrap gap-1.5" : "space-y-4"}>
678
+ {blocks.map((block, index) => {
679
+ const blockSchema = (field.blocks as any[])?.find(
680
+ (b) => b.slug === block.type
681
+ );
682
+ return (
683
+ <SortableBlock
684
+ key={block.id || index}
685
+ block={block}
686
+ index={index}
687
+ blockSchema={blockSchema}
688
+ editingBlockId={editingBlockId}
689
+ setEditingBlockId={setEditingBlockId}
690
+ onDuplicate={duplicateBlock}
691
+ compact={pickerMode === "dropdown"}
692
+ />
693
+ );
694
+ })}
315
695
  {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
696
+ <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"}>
697
+ {pickerMode === "dropdown" ? "No elements added" : "Click the button above to add your first block"}
318
698
  </div>
319
699
  )}
320
700
  </div>
@@ -0,0 +1,73 @@
1
+ import React from "react";
2
+
3
+ interface CardFieldProps {
4
+ title?: string;
5
+ description?: string;
6
+ icon?: string;
7
+ link?: string;
8
+ linkText?: string;
9
+ onChange: (field: string, value: string) => void;
10
+ compact?: boolean;
11
+ }
12
+
13
+ export const CardField: React.FC<CardFieldProps> = ({
14
+ title = "",
15
+ description = "",
16
+ icon = "",
17
+ link = "",
18
+ linkText = "",
19
+ onChange,
20
+ compact = false,
21
+ }) => {
22
+ const inputClass = compact
23
+ ? "w-full px-2.5 py-1.5 border border-[var(--kyro-border)] rounded 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"
24
+ : "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";
25
+
26
+ const textareaClass = compact
27
+ ? "w-full px-2.5 py-1.5 border border-[var(--kyro-border)] rounded bg-[var(--kyro-bg-secondary)] text-sm text-[var(--kyro-text-primary)] min-h-[50px] resize-none focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent"
28
+ : "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)] min-h-[80px] resize-none focus:outline-none focus:ring-1 focus:ring-[var(--kyro-sidebar-active)] focus:border-transparent";
29
+
30
+ return (
31
+ <div className={compact ? "space-y-2" : "space-y-3"}>
32
+ <input
33
+ type="text"
34
+ value={title}
35
+ onChange={(e) => onChange("title", e.target.value)}
36
+ className={`${inputClass} font-bold text-base`}
37
+ placeholder="Card title..."
38
+ />
39
+ <textarea
40
+ value={description}
41
+ onChange={(e) => onChange("description", e.target.value)}
42
+ className={textareaClass}
43
+ placeholder="Card description..."
44
+ />
45
+ <input
46
+ type="text"
47
+ value={icon}
48
+ onChange={(e) => onChange("icon", e.target.value)}
49
+ className={inputClass}
50
+ placeholder="Icon (emoji or name)..."
51
+ />
52
+ <div className="flex items-center gap-2">
53
+ <input
54
+ type="text"
55
+ value={linkText}
56
+ onChange={(e) => onChange("linkText", e.target.value)}
57
+ className="flex-1 px-2.5 py-1.5 border border-[var(--kyro-border)] rounded 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"
58
+ placeholder="Link text..."
59
+ />
60
+ <span className="text-[var(--kyro-text-muted)] text-xs">→</span>
61
+ <input
62
+ type="url"
63
+ value={link}
64
+ onChange={(e) => onChange("link", e.target.value)}
65
+ className="flex-1 px-2.5 py-1.5 border border-[var(--kyro-border)] rounded 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 font-mono text-xs"
66
+ placeholder="https://..."
67
+ />
68
+ </div>
69
+ </div>
70
+ );
71
+ };
72
+
73
+ export default CardField;