@kyro-cms/admin 0.8.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.
- package/dist/index.cjs +11960 -11006
- 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 +563 -0
- package/dist/index.d.ts +7 -7
- package/dist/index.js +12183 -11238
- package/dist/index.js.map +1 -1
- package/package.json +15 -11
- package/src/components/ActionBar.tsx +27 -14
- package/src/components/Admin.tsx +1 -1
- package/src/components/ApiKeysManager.tsx +5 -5
- package/src/components/AutoForm.tsx +585 -369
- package/src/components/BrandingHub.tsx +7 -4
- package/src/components/CreateView.tsx +2 -0
- package/src/components/DetailView.tsx +71 -56
- package/src/components/DeveloperCenter.tsx +8 -6
- package/src/components/FieldRenderer.tsx +94 -19
- package/src/components/ListView.tsx +33 -20
- package/src/components/MediaGallery.tsx +219 -194
- package/src/components/PluginsManager.tsx +197 -70
- package/src/components/RestPlayground.tsx +7 -7
- 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 +430 -50
- 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/RelationshipField.tsx +153 -87
- 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/ui/PageHeader.tsx +5 -5
- package/src/components/ui/SlidePanel.tsx +8 -3
- package/src/components/ui/icons.tsx +109 -109
- package/src/components/users/UserDetail.tsx +79 -16
- package/src/hooks/useAutoFormState.ts +125 -62
- package/src/integration.ts +148 -46
- package/src/kyro-cms.d.ts +7 -2
- package/src/layouts/AuthLayout.astro +14 -2
- package/src/lib/autoform-store.ts +85 -52
- package/src/lib/change-source.ts +9 -0
- package/src/lib/config.ts +104 -8
- package/src/lib/globals.ts +44 -9
- 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/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(() => {
|
|
@@ -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
|
-
|
|
133
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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={
|
|
253
|
-
<div className=
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
{
|
|
639
|
+
{dynamicCategories.map((category) => (
|
|
279
640
|
<div key={category.title}>
|
|
280
|
-
<h3 className="text-xs font-semibold text-[var(--kyro-text-muted)] mb-2
|
|
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.
|
|
287
|
-
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.
|
|
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
|
-
|
|
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;
|