@bygghammar/cms-platform 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -96,7 +96,8 @@
|
|
|
96
96
|
"./backoffice/media/ReviewImageUpload": "./src/backoffice/media/ReviewImageUpload.tsx",
|
|
97
97
|
"./migrations/*": "./migrations/*",
|
|
98
98
|
"./backoffice/seo/PageSeoPanelView": "./src/backoffice/seo/PageSeoPanelView.tsx",
|
|
99
|
-
"./backoffice/SectionFormView": "./src/backoffice/SectionFormView.tsx"
|
|
99
|
+
"./backoffice/SectionFormView": "./src/backoffice/SectionFormView.tsx",
|
|
100
|
+
"./backoffice/PageEditorView": "./src/backoffice/PageEditorView.tsx"
|
|
100
101
|
},
|
|
101
102
|
"dependencies": {
|
|
102
103
|
"fflate": "^0.8.3"
|
|
@@ -120,7 +121,10 @@
|
|
|
120
121
|
"@tiptap/extension-subscript": ">=3",
|
|
121
122
|
"@tiptap/extension-superscript": ">=3",
|
|
122
123
|
"@tiptap/extension-text-style": ">=3",
|
|
123
|
-
"@tiptap/extension-table": ">=3"
|
|
124
|
+
"@tiptap/extension-table": ">=3",
|
|
125
|
+
"@dnd-kit/core": ">=6",
|
|
126
|
+
"@dnd-kit/sortable": ">=3",
|
|
127
|
+
"@dnd-kit/utilities": ">=3"
|
|
124
128
|
},
|
|
125
129
|
"peerDependenciesMeta": {
|
|
126
130
|
"@supabase/supabase-js": {
|
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, useCallback, type ComponentType } from "react";
|
|
2
|
+
import {
|
|
3
|
+
DndContext,
|
|
4
|
+
closestCenter,
|
|
5
|
+
KeyboardSensor,
|
|
6
|
+
PointerSensor,
|
|
7
|
+
useSensor,
|
|
8
|
+
useSensors,
|
|
9
|
+
type DragEndEvent,
|
|
10
|
+
} from "@dnd-kit/core";
|
|
11
|
+
import {
|
|
12
|
+
SortableContext,
|
|
13
|
+
arrayMove,
|
|
14
|
+
sortableKeyboardCoordinates,
|
|
15
|
+
useSortable,
|
|
16
|
+
verticalListSortingStrategy,
|
|
17
|
+
} from "@dnd-kit/sortable";
|
|
18
|
+
import { CSS } from "@dnd-kit/utilities";
|
|
19
|
+
import {
|
|
20
|
+
Monitor,
|
|
21
|
+
Tablet,
|
|
22
|
+
Smartphone,
|
|
23
|
+
Undo2,
|
|
24
|
+
Redo2,
|
|
25
|
+
GripVertical,
|
|
26
|
+
Trash2,
|
|
27
|
+
Eye,
|
|
28
|
+
EyeOff,
|
|
29
|
+
Copy,
|
|
30
|
+
ChevronDown,
|
|
31
|
+
ExternalLink,
|
|
32
|
+
X,
|
|
33
|
+
BookmarkPlus,
|
|
34
|
+
Globe,
|
|
35
|
+
Pencil,
|
|
36
|
+
Check,
|
|
37
|
+
} from "lucide-react";
|
|
38
|
+
import type { BlockInstance, BlockProps } from "../blocks/types";
|
|
39
|
+
import { subscribeSharedDrafts } from "../cms/shared-draft-store";
|
|
40
|
+
import { SaveGlobalDialog, type SaveGlobalResult } from "./SaveGlobalDialog";
|
|
41
|
+
|
|
42
|
+
function uid(prefix = "b") {
|
|
43
|
+
return `${prefix}_${Math.random().toString(36).slice(2, 9)}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type Device = "desktop" | "tablet" | "mobile";
|
|
47
|
+
const DEVICE_WIDTHS: Record<Device, number | null> = {
|
|
48
|
+
desktop: null,
|
|
49
|
+
tablet: 820,
|
|
50
|
+
mobile: 420,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type GlobalSectionOption = {
|
|
54
|
+
key: string;
|
|
55
|
+
name: string;
|
|
56
|
+
kind?: "block" | "section";
|
|
57
|
+
blocks?: BlockInstance[];
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Host-provided plumbing: locales, block registry labels, UI and persistence. */
|
|
61
|
+
export type PageEditorDeps = {
|
|
62
|
+
locales: readonly string[];
|
|
63
|
+
localeLabels: Record<string, string>;
|
|
64
|
+
defaultLocale: string;
|
|
65
|
+
/** Human label for a block type (from the host's block registry). */
|
|
66
|
+
labelForType: (type: string) => string;
|
|
67
|
+
/** Block library / add-block control. */
|
|
68
|
+
BlockPickerComponent: ComponentType<{
|
|
69
|
+
onAdd: (block: BlockInstance) => void;
|
|
70
|
+
globalSections: GlobalSectionOption[];
|
|
71
|
+
templates: unknown[];
|
|
72
|
+
onDeleteTemplate: (id: string) => void;
|
|
73
|
+
hideGlobals?: boolean;
|
|
74
|
+
}>;
|
|
75
|
+
/** Per-block property editor. */
|
|
76
|
+
InspectorComponent: ComponentType<{
|
|
77
|
+
block: BlockInstance;
|
|
78
|
+
patch: (props: BlockProps) => void;
|
|
79
|
+
globalSections: GlobalSectionOption[];
|
|
80
|
+
}>;
|
|
81
|
+
/** Optional tooltip primitives; plain titles are used when omitted. */
|
|
82
|
+
tooltip?: {
|
|
83
|
+
Tooltip: ComponentType<{ children?: React.ReactNode }>;
|
|
84
|
+
TooltipTrigger: ComponentType<{ asChild?: boolean; children?: React.ReactNode }>;
|
|
85
|
+
TooltipContent: ComponentType<{
|
|
86
|
+
side?: string;
|
|
87
|
+
className?: string;
|
|
88
|
+
children?: React.ReactNode;
|
|
89
|
+
}>;
|
|
90
|
+
};
|
|
91
|
+
templates: unknown[];
|
|
92
|
+
onDeleteTemplate: (id: string) => void | Promise<void>;
|
|
93
|
+
onSaveTemplate: (args: {
|
|
94
|
+
name: string;
|
|
95
|
+
description: string | null;
|
|
96
|
+
block: BlockInstance;
|
|
97
|
+
}) => Promise<void>;
|
|
98
|
+
onCreateGlobalSection: (args: {
|
|
99
|
+
key: string;
|
|
100
|
+
name: string;
|
|
101
|
+
kind: "block" | "section";
|
|
102
|
+
blocks: BlockInstance[];
|
|
103
|
+
}) => Promise<void>;
|
|
104
|
+
/** Mirror the canvas so a standalone preview tab shows unsaved changes. */
|
|
105
|
+
writeDraft: (
|
|
106
|
+
pageKey: string,
|
|
107
|
+
payload: { blocks: BlockInstance[]; globals: Record<string, BlockInstance[]>; locale: string },
|
|
108
|
+
) => void;
|
|
109
|
+
/** Preview URL for the iframe and the "open preview" button. */
|
|
110
|
+
previewUrl: (pageKey: string, locale?: string) => string;
|
|
111
|
+
notify?: {
|
|
112
|
+
success?: (msg: string) => void;
|
|
113
|
+
error?: (msg: string) => void;
|
|
114
|
+
info?: (msg: string) => void;
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/* ---------------- Outline row ---------------- */
|
|
119
|
+
function OutlineRow({
|
|
120
|
+
block,
|
|
121
|
+
selected,
|
|
122
|
+
onSelect,
|
|
123
|
+
onDuplicate,
|
|
124
|
+
onDelete,
|
|
125
|
+
onToggleVisible,
|
|
126
|
+
onRename,
|
|
127
|
+
globalSections,
|
|
128
|
+
labelForType,
|
|
129
|
+
singleBlock = false,
|
|
130
|
+
}: {
|
|
131
|
+
block: BlockInstance;
|
|
132
|
+
selected: boolean;
|
|
133
|
+
onSelect: () => void;
|
|
134
|
+
onDuplicate: () => void;
|
|
135
|
+
onDelete: () => void;
|
|
136
|
+
onToggleVisible: () => void;
|
|
137
|
+
onRename: (name: string) => void;
|
|
138
|
+
globalSections: GlobalSectionOption[];
|
|
139
|
+
labelForType: (type: string) => string;
|
|
140
|
+
singleBlock?: boolean;
|
|
141
|
+
}) {
|
|
142
|
+
const isGlobalRef = block.type === "globalRef";
|
|
143
|
+
const sectionKey = isGlobalRef
|
|
144
|
+
? String((block.props as { sectionKey?: string })?.sectionKey ?? "")
|
|
145
|
+
: "";
|
|
146
|
+
const typeName = isGlobalRef
|
|
147
|
+
? (globalSections.find((g) => g.key === sectionKey)?.name ?? labelForType(block.type))
|
|
148
|
+
: labelForType(block.type);
|
|
149
|
+
const displayName = block.name?.trim() || typeName;
|
|
150
|
+
const [renaming, setRenaming] = useState(false);
|
|
151
|
+
const [draftName, setDraftName] = useState(block.name ?? "");
|
|
152
|
+
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
153
|
+
id: block.id,
|
|
154
|
+
});
|
|
155
|
+
const style: React.CSSProperties = {
|
|
156
|
+
transform: CSS.Transform.toString(transform),
|
|
157
|
+
transition,
|
|
158
|
+
opacity: isDragging ? 0.5 : 1,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<div
|
|
163
|
+
ref={setNodeRef}
|
|
164
|
+
style={style}
|
|
165
|
+
data-outline-id={block.id}
|
|
166
|
+
data-selected={selected ? "true" : "false"}
|
|
167
|
+
className={`group flex items-center gap-1.5 border px-2 py-1.5 text-xs cursor-pointer ${
|
|
168
|
+
selected ? "border-sea bg-ice/40" : "border-ink/10 bg-white hover:border-ink/30"
|
|
169
|
+
} ${block.visible === false ? "opacity-50" : ""}`}
|
|
170
|
+
onClick={onSelect}
|
|
171
|
+
>
|
|
172
|
+
{!singleBlock && (
|
|
173
|
+
<button
|
|
174
|
+
{...attributes}
|
|
175
|
+
{...listeners}
|
|
176
|
+
className="cursor-grab active:cursor-grabbing text-ink/30 hover:text-ink/70"
|
|
177
|
+
title="Drag"
|
|
178
|
+
onClick={(e) => e.stopPropagation()}
|
|
179
|
+
>
|
|
180
|
+
<GripVertical className="size-3.5" />
|
|
181
|
+
</button>
|
|
182
|
+
)}
|
|
183
|
+
{renaming ? (
|
|
184
|
+
<input
|
|
185
|
+
autoFocus
|
|
186
|
+
value={draftName}
|
|
187
|
+
placeholder={typeName}
|
|
188
|
+
onClick={(e) => e.stopPropagation()}
|
|
189
|
+
onChange={(e) => setDraftName(e.target.value)}
|
|
190
|
+
onKeyDown={(e) => {
|
|
191
|
+
e.stopPropagation();
|
|
192
|
+
if (e.key === "Enter") {
|
|
193
|
+
onRename(draftName.trim());
|
|
194
|
+
setRenaming(false);
|
|
195
|
+
}
|
|
196
|
+
if (e.key === "Escape") {
|
|
197
|
+
setDraftName(block.name ?? "");
|
|
198
|
+
setRenaming(false);
|
|
199
|
+
}
|
|
200
|
+
}}
|
|
201
|
+
onBlur={() => {
|
|
202
|
+
onRename(draftName.trim());
|
|
203
|
+
setRenaming(false);
|
|
204
|
+
}}
|
|
205
|
+
className="flex-1 min-w-0 border border-sea bg-white px-1 py-0.5 text-xs focus:outline-none"
|
|
206
|
+
/>
|
|
207
|
+
) : (
|
|
208
|
+
<span
|
|
209
|
+
className="flex-1 truncate text-ink flex items-center gap-1.5"
|
|
210
|
+
title={block.name ? `${block.name} · ${typeName}` : typeName}
|
|
211
|
+
onDoubleClick={(e) => {
|
|
212
|
+
e.stopPropagation();
|
|
213
|
+
setDraftName(block.name ?? "");
|
|
214
|
+
setRenaming(true);
|
|
215
|
+
}}
|
|
216
|
+
>
|
|
217
|
+
{displayName}
|
|
218
|
+
{block.name?.trim() ? (
|
|
219
|
+
<span className="shrink-0 text-[0.6rem] uppercase tracking-[0.12em] text-ink/35">
|
|
220
|
+
{typeName}
|
|
221
|
+
</span>
|
|
222
|
+
) : null}
|
|
223
|
+
{isGlobalRef && (
|
|
224
|
+
<Globe className="size-3 text-ink/40 shrink-0" aria-label="Global block" />
|
|
225
|
+
)}
|
|
226
|
+
</span>
|
|
227
|
+
)}
|
|
228
|
+
|
|
229
|
+
<button
|
|
230
|
+
onClick={(e) => {
|
|
231
|
+
e.stopPropagation();
|
|
232
|
+
if (renaming) {
|
|
233
|
+
onRename(draftName.trim());
|
|
234
|
+
setRenaming(false);
|
|
235
|
+
} else {
|
|
236
|
+
setDraftName(block.name ?? "");
|
|
237
|
+
setRenaming(true);
|
|
238
|
+
}
|
|
239
|
+
}}
|
|
240
|
+
className="text-ink/40 hover:text-ink"
|
|
241
|
+
title={renaming ? "Save name" : "Rename block"}
|
|
242
|
+
>
|
|
243
|
+
{renaming ? <Check className="size-3.5" /> : <Pencil className="size-3.5" />}
|
|
244
|
+
</button>
|
|
245
|
+
|
|
246
|
+
<button
|
|
247
|
+
onClick={(e) => {
|
|
248
|
+
e.stopPropagation();
|
|
249
|
+
onToggleVisible();
|
|
250
|
+
}}
|
|
251
|
+
className="text-ink/40 hover:text-ink"
|
|
252
|
+
title={block.visible === false ? "Show" : "Hide"}
|
|
253
|
+
>
|
|
254
|
+
{block.visible === false ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
|
|
255
|
+
</button>
|
|
256
|
+
{!singleBlock && (
|
|
257
|
+
<button
|
|
258
|
+
onClick={(e) => {
|
|
259
|
+
e.stopPropagation();
|
|
260
|
+
onDuplicate();
|
|
261
|
+
}}
|
|
262
|
+
className="text-ink/40 hover:text-ink opacity-0 group-hover:opacity-100"
|
|
263
|
+
title="Duplicate"
|
|
264
|
+
>
|
|
265
|
+
<Copy className="size-3.5" />
|
|
266
|
+
</button>
|
|
267
|
+
)}
|
|
268
|
+
<button
|
|
269
|
+
onClick={(e) => {
|
|
270
|
+
e.stopPropagation();
|
|
271
|
+
onDelete();
|
|
272
|
+
}}
|
|
273
|
+
className="text-red-500 hover:text-red-700 opacity-0 group-hover:opacity-100"
|
|
274
|
+
title="Delete"
|
|
275
|
+
>
|
|
276
|
+
<Trash2 className="size-3.5" />
|
|
277
|
+
</button>
|
|
278
|
+
</div>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/* ---------------- PageEditorView ---------------- */
|
|
283
|
+
|
|
284
|
+
export function PageEditorView({
|
|
285
|
+
pageKey,
|
|
286
|
+
initialBlocks,
|
|
287
|
+
globals,
|
|
288
|
+
globalSections,
|
|
289
|
+
onDirtyChange,
|
|
290
|
+
onBlocksChange,
|
|
291
|
+
blocks: controlledBlocks,
|
|
292
|
+
locale: controlledLocale,
|
|
293
|
+
onLocaleChange,
|
|
294
|
+
singleBlock = false,
|
|
295
|
+
deps,
|
|
296
|
+
}: {
|
|
297
|
+
pageKey: string;
|
|
298
|
+
initialBlocks: BlockInstance[];
|
|
299
|
+
globals: Record<string, BlockInstance[]>;
|
|
300
|
+
globalSections: GlobalSectionOption[];
|
|
301
|
+
onDirtyChange: (dirty: boolean) => void;
|
|
302
|
+
onBlocksChange: (blocks: BlockInstance[]) => void;
|
|
303
|
+
blocks?: BlockInstance[];
|
|
304
|
+
/** When provided, the parent owns the preview language. */
|
|
305
|
+
locale?: string;
|
|
306
|
+
onLocaleChange?: (locale: string) => void;
|
|
307
|
+
/** Global blocks hold exactly one block: no adding, reordering or duplicating. */
|
|
308
|
+
singleBlock?: boolean;
|
|
309
|
+
deps: PageEditorDeps;
|
|
310
|
+
}) {
|
|
311
|
+
const {
|
|
312
|
+
locales,
|
|
313
|
+
localeLabels,
|
|
314
|
+
defaultLocale,
|
|
315
|
+
labelForType,
|
|
316
|
+
BlockPickerComponent,
|
|
317
|
+
InspectorComponent,
|
|
318
|
+
templates,
|
|
319
|
+
onDeleteTemplate,
|
|
320
|
+
onSaveTemplate,
|
|
321
|
+
onCreateGlobalSection,
|
|
322
|
+
writeDraft,
|
|
323
|
+
previewUrl,
|
|
324
|
+
notify,
|
|
325
|
+
} = deps;
|
|
326
|
+
|
|
327
|
+
const Tooltip = deps.tooltip?.Tooltip;
|
|
328
|
+
const TooltipTrigger = deps.tooltip?.TooltipTrigger;
|
|
329
|
+
const TooltipContent = deps.tooltip?.TooltipContent;
|
|
330
|
+
|
|
331
|
+
const [blocks, setBlocksInternal] = useState<BlockInstance[]>(initialBlocks);
|
|
332
|
+
useEffect(() => {
|
|
333
|
+
setBlocksInternal(initialBlocks);
|
|
334
|
+
setPast([]);
|
|
335
|
+
setFuture([]);
|
|
336
|
+
onDirtyChange(false);
|
|
337
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
338
|
+
}, [initialBlocks]);
|
|
339
|
+
|
|
340
|
+
// History
|
|
341
|
+
const [past, setPast] = useState<BlockInstance[][]>([]);
|
|
342
|
+
const [future, setFuture] = useState<BlockInstance[][]>([]);
|
|
343
|
+
|
|
344
|
+
const commit = useCallback(
|
|
345
|
+
(next: BlockInstance[]) => {
|
|
346
|
+
setPast((p) => [...p, blocks].slice(-50));
|
|
347
|
+
setFuture([]);
|
|
348
|
+
setBlocksInternal(next);
|
|
349
|
+
onBlocksChange(next);
|
|
350
|
+
onDirtyChange(true);
|
|
351
|
+
},
|
|
352
|
+
[blocks, onBlocksChange, onDirtyChange],
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
const undo = useCallback(() => {
|
|
356
|
+
setPast((p) => {
|
|
357
|
+
if (!p.length) return p;
|
|
358
|
+
const prev = p[p.length - 1];
|
|
359
|
+
setFuture((f) => [blocks, ...f]);
|
|
360
|
+
setBlocksInternal(prev);
|
|
361
|
+
onBlocksChange(prev);
|
|
362
|
+
onDirtyChange(true);
|
|
363
|
+
return p.slice(0, -1);
|
|
364
|
+
});
|
|
365
|
+
}, [blocks, onBlocksChange, onDirtyChange]);
|
|
366
|
+
|
|
367
|
+
const redo = useCallback(() => {
|
|
368
|
+
setFuture((f) => {
|
|
369
|
+
if (!f.length) return f;
|
|
370
|
+
const [nxt, ...rest] = f;
|
|
371
|
+
setPast((p) => [...p, blocks]);
|
|
372
|
+
setBlocksInternal(nxt);
|
|
373
|
+
onBlocksChange(nxt);
|
|
374
|
+
onDirtyChange(true);
|
|
375
|
+
return rest;
|
|
376
|
+
});
|
|
377
|
+
}, [blocks, onBlocksChange, onDirtyChange]);
|
|
378
|
+
|
|
379
|
+
useEffect(() => {
|
|
380
|
+
const onKey = (e: KeyboardEvent) => {
|
|
381
|
+
const meta = e.metaKey || e.ctrlKey;
|
|
382
|
+
if (!meta) return;
|
|
383
|
+
if (e.key.toLowerCase() === "z" && !e.shiftKey) {
|
|
384
|
+
e.preventDefault();
|
|
385
|
+
undo();
|
|
386
|
+
} else if ((e.key.toLowerCase() === "z" && e.shiftKey) || e.key.toLowerCase() === "y") {
|
|
387
|
+
e.preventDefault();
|
|
388
|
+
redo();
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
document.addEventListener("keydown", onKey);
|
|
392
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
393
|
+
}, [undo, redo]);
|
|
394
|
+
|
|
395
|
+
// Selection & preview state
|
|
396
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
397
|
+
const [device, setDevice] = useState<Device>("desktop");
|
|
398
|
+
const [localeInternal, setLocaleInternal] = useState<string>(defaultLocale);
|
|
399
|
+
const locale = controlledLocale ?? localeInternal;
|
|
400
|
+
const setLocale = useCallback(
|
|
401
|
+
(l: string) => {
|
|
402
|
+
setLocaleInternal(l);
|
|
403
|
+
onLocaleChange?.(l);
|
|
404
|
+
},
|
|
405
|
+
[onLocaleChange],
|
|
406
|
+
);
|
|
407
|
+
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
408
|
+
const [previewReady, setPreviewReady] = useState(false);
|
|
409
|
+
|
|
410
|
+
const currentBlocks = controlledBlocks ?? blocks;
|
|
411
|
+
|
|
412
|
+
// Unsaved shared-content edits made from the inspector, mirrored into the
|
|
413
|
+
// canvas so shared sections update live.
|
|
414
|
+
const [sharedDrafts, setSharedDrafts] = useState<Record<string, unknown>>({});
|
|
415
|
+
useEffect(() => subscribeSharedDrafts(setSharedDrafts), []);
|
|
416
|
+
|
|
417
|
+
// Push draft to iframe whenever blocks/locale/selection change.
|
|
418
|
+
useEffect(() => {
|
|
419
|
+
if (!previewReady) return;
|
|
420
|
+
iframeRef.current?.contentWindow?.postMessage(
|
|
421
|
+
{
|
|
422
|
+
source: "lovable-editor",
|
|
423
|
+
type: "draft",
|
|
424
|
+
blocks: currentBlocks,
|
|
425
|
+
globals,
|
|
426
|
+
selectedId,
|
|
427
|
+
locale,
|
|
428
|
+
sharedContent: sharedDrafts,
|
|
429
|
+
},
|
|
430
|
+
"*",
|
|
431
|
+
);
|
|
432
|
+
}, [currentBlocks, globals, selectedId, locale, previewReady, sharedDrafts]);
|
|
433
|
+
|
|
434
|
+
useEffect(() => {
|
|
435
|
+
const t = setTimeout(() => {
|
|
436
|
+
writeDraft(pageKey, { blocks: currentBlocks, globals, locale });
|
|
437
|
+
}, 250);
|
|
438
|
+
return () => clearTimeout(t);
|
|
439
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
440
|
+
}, [pageKey, currentBlocks, globals, locale]);
|
|
441
|
+
|
|
442
|
+
/** Select a block from the outline and scroll the preview to it. */
|
|
443
|
+
const selectAndReveal = useCallback((id: string) => {
|
|
444
|
+
setSelectedId(id);
|
|
445
|
+
iframeRef.current?.contentWindow?.postMessage(
|
|
446
|
+
{ source: "lovable-editor", type: "scrollTo", blockId: id },
|
|
447
|
+
"*",
|
|
448
|
+
);
|
|
449
|
+
}, []);
|
|
450
|
+
|
|
451
|
+
// Listen for iframe messages.
|
|
452
|
+
useEffect(() => {
|
|
453
|
+
const onMsg = (ev: MessageEvent) => {
|
|
454
|
+
const d = ev.data as { source?: string; type?: string; blockId?: string };
|
|
455
|
+
if (d?.source !== "lovable-preview") return;
|
|
456
|
+
if (d.type === "ready") setPreviewReady(true);
|
|
457
|
+
if (d.type === "select" && d.blockId) {
|
|
458
|
+
if (currentBlocks.some((b) => b.id === d.blockId)) setSelectedId(d.blockId);
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
window.addEventListener("message", onMsg);
|
|
462
|
+
return () => window.removeEventListener("message", onMsg);
|
|
463
|
+
}, [currentBlocks]);
|
|
464
|
+
|
|
465
|
+
// Keep the outline highlight visible whenever the selection changes.
|
|
466
|
+
useEffect(() => {
|
|
467
|
+
if (!selectedId) return;
|
|
468
|
+
const el = document.querySelector(
|
|
469
|
+
`[data-outline-id="${window.CSS.escape(selectedId)}"]`,
|
|
470
|
+
) as HTMLElement | null;
|
|
471
|
+
el?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
472
|
+
}, [selectedId]);
|
|
473
|
+
|
|
474
|
+
// Mutations
|
|
475
|
+
const addBlock = (b: BlockInstance) => {
|
|
476
|
+
commit([...blocks, b]);
|
|
477
|
+
setSelectedId(b.id);
|
|
478
|
+
};
|
|
479
|
+
const patchBlock = (id: string, props: BlockProps) => {
|
|
480
|
+
commit(blocks.map((b) => (b.id === id ? { ...b, props } : b)));
|
|
481
|
+
};
|
|
482
|
+
const removeBlock = (id: string) => {
|
|
483
|
+
commit(blocks.filter((b) => b.id !== id));
|
|
484
|
+
if (selectedId === id) setSelectedId(null);
|
|
485
|
+
};
|
|
486
|
+
const duplicateBlock = (id: string) => {
|
|
487
|
+
const i = blocks.findIndex((b) => b.id === id);
|
|
488
|
+
if (i < 0) return;
|
|
489
|
+
const src = blocks[i];
|
|
490
|
+
const clone: BlockInstance = {
|
|
491
|
+
...src,
|
|
492
|
+
id: uid(src.type),
|
|
493
|
+
props: structuredClone(src.props ?? {}),
|
|
494
|
+
};
|
|
495
|
+
const next = blocks.slice();
|
|
496
|
+
next.splice(i + 1, 0, clone);
|
|
497
|
+
commit(next);
|
|
498
|
+
setSelectedId(clone.id);
|
|
499
|
+
};
|
|
500
|
+
const renameBlock = (id: string, name: string) => {
|
|
501
|
+
commit(
|
|
502
|
+
blocks.map((b) => (b.id === id ? { ...b, ...(name ? { name } : { name: undefined }) } : b)),
|
|
503
|
+
);
|
|
504
|
+
};
|
|
505
|
+
const toggleVisible = (id: string) => {
|
|
506
|
+
commit(
|
|
507
|
+
blocks.map((b) => (b.id === id ? { ...b, visible: b.visible === false ? true : false } : b)),
|
|
508
|
+
);
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
const sensors = useSensors(
|
|
512
|
+
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
|
513
|
+
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
const onDragEnd = (e: DragEndEvent) => {
|
|
517
|
+
const { active, over } = e;
|
|
518
|
+
if (!over || active.id === over.id) return;
|
|
519
|
+
const oi = blocks.findIndex((b) => b.id === active.id);
|
|
520
|
+
const ni = blocks.findIndex((b) => b.id === over.id);
|
|
521
|
+
if (oi < 0 || ni < 0) return;
|
|
522
|
+
commit(arrayMove(blocks, oi, ni));
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
const selected = useMemo(
|
|
526
|
+
() => blocks.find((b) => b.id === selectedId) ?? null,
|
|
527
|
+
[blocks, selectedId],
|
|
528
|
+
);
|
|
529
|
+
const selectedLabel = useMemo(() => {
|
|
530
|
+
if (!selected) return "Nothing selected";
|
|
531
|
+
if (selected.name?.trim()) return selected.name.trim();
|
|
532
|
+
if (selected.type === "globalRef") {
|
|
533
|
+
const key = String((selected.props as { sectionKey?: string })?.sectionKey ?? "");
|
|
534
|
+
const name = globalSections.find((g) => g.key === key)?.name;
|
|
535
|
+
return name ?? labelForType(selected.type);
|
|
536
|
+
}
|
|
537
|
+
return labelForType(selected.type);
|
|
538
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
539
|
+
}, [selected, globalSections]);
|
|
540
|
+
|
|
541
|
+
const suggestName = useCallback((b: BlockInstance): string => {
|
|
542
|
+
const props = (b.props ?? {}) as Record<string, unknown>;
|
|
543
|
+
if (b.name?.trim()) return b.name.trim();
|
|
544
|
+
const pick = (v: unknown): string => {
|
|
545
|
+
if (typeof v === "string") return v;
|
|
546
|
+
if (v && typeof v === "object") {
|
|
547
|
+
const first = Object.values(v as Record<string, unknown>)[0];
|
|
548
|
+
if (typeof first === "string") return first;
|
|
549
|
+
}
|
|
550
|
+
return "";
|
|
551
|
+
};
|
|
552
|
+
for (const key of ["title", "heading", "name", "eyebrow", "label"]) {
|
|
553
|
+
const s = pick(props[key]).trim();
|
|
554
|
+
if (s) return s.slice(0, 60);
|
|
555
|
+
}
|
|
556
|
+
return "";
|
|
557
|
+
}, []);
|
|
558
|
+
|
|
559
|
+
const [saveDialog, setSaveDialog] = useState<null | "global" | "template">(null);
|
|
560
|
+
const [saveDialogError, setSaveDialogError] = useState<string | null>(null);
|
|
561
|
+
const [saveBusy, setSaveBusy] = useState(false);
|
|
562
|
+
|
|
563
|
+
const saveDialogDefaultName = useMemo(() => {
|
|
564
|
+
if (!selected) return "";
|
|
565
|
+
return suggestName(selected) || labelForType(selected.type);
|
|
566
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
567
|
+
}, [selected, suggestName]);
|
|
568
|
+
|
|
569
|
+
const saveSelectedAsTemplate = useCallback(() => {
|
|
570
|
+
if (!selected) return;
|
|
571
|
+
setSaveDialogError(null);
|
|
572
|
+
setSaveDialog("template");
|
|
573
|
+
}, [selected]);
|
|
574
|
+
|
|
575
|
+
const saveSelectedAsGlobalSection = useCallback(() => {
|
|
576
|
+
if (!selected) return;
|
|
577
|
+
if (selected.type === "globalRef") {
|
|
578
|
+
notify?.info?.("This block is already a global block.");
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
setSaveDialogError(null);
|
|
582
|
+
setSaveDialog("global");
|
|
583
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
584
|
+
}, [selected]);
|
|
585
|
+
|
|
586
|
+
const handleSaveDialogSubmit = useCallback(
|
|
587
|
+
async (result: SaveGlobalResult) => {
|
|
588
|
+
if (!selected) return;
|
|
589
|
+
setSaveBusy(true);
|
|
590
|
+
setSaveDialogError(null);
|
|
591
|
+
try {
|
|
592
|
+
if (saveDialog === "template") {
|
|
593
|
+
await onSaveTemplate({
|
|
594
|
+
name: result.name,
|
|
595
|
+
description: result.description || null,
|
|
596
|
+
block: structuredClone(selected),
|
|
597
|
+
});
|
|
598
|
+
notify?.success?.("Saved to the Blocks library");
|
|
599
|
+
} else {
|
|
600
|
+
await onCreateGlobalSection({
|
|
601
|
+
key: result.key,
|
|
602
|
+
name: result.name,
|
|
603
|
+
kind: result.kind,
|
|
604
|
+
blocks: [{ ...structuredClone(selected), id: uid() }],
|
|
605
|
+
});
|
|
606
|
+
// Replace the block on this page with a reference to the new global entry.
|
|
607
|
+
commit(
|
|
608
|
+
currentBlocks.map((b) =>
|
|
609
|
+
b.id === selected.id
|
|
610
|
+
? {
|
|
611
|
+
id: b.id,
|
|
612
|
+
type: "globalRef",
|
|
613
|
+
name: result.name,
|
|
614
|
+
props: { sectionKey: result.key },
|
|
615
|
+
visible: b.visible,
|
|
616
|
+
}
|
|
617
|
+
: b,
|
|
618
|
+
),
|
|
619
|
+
);
|
|
620
|
+
notify?.success?.(
|
|
621
|
+
result.kind === "section"
|
|
622
|
+
? "Saved to Global Sections — this page now references it"
|
|
623
|
+
: "Saved as a global block — this page now references it",
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
setSaveDialog(null);
|
|
627
|
+
} catch (e) {
|
|
628
|
+
const msg = String((e as { message?: string })?.message ?? "Save failed");
|
|
629
|
+
setSaveDialogError(
|
|
630
|
+
/duplicate|unique|already exists/i.test(msg)
|
|
631
|
+
? "That key is already used. Pick a different key."
|
|
632
|
+
: msg,
|
|
633
|
+
);
|
|
634
|
+
} finally {
|
|
635
|
+
setSaveBusy(false);
|
|
636
|
+
}
|
|
637
|
+
},
|
|
638
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
639
|
+
[selected, saveDialog, commit, currentBlocks],
|
|
640
|
+
);
|
|
641
|
+
|
|
642
|
+
const iframeWidth = DEVICE_WIDTHS[device];
|
|
643
|
+
|
|
644
|
+
const openPreviewInTab = () => {
|
|
645
|
+
window.open(previewUrl(pageKey, locale), "_blank");
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
const withTooltip = (
|
|
649
|
+
trigger: React.ReactElement,
|
|
650
|
+
title: string,
|
|
651
|
+
body: string,
|
|
652
|
+
): React.ReactElement =>
|
|
653
|
+
Tooltip && TooltipTrigger && TooltipContent ? (
|
|
654
|
+
<Tooltip>
|
|
655
|
+
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
|
|
656
|
+
<TooltipContent side="bottom" className="max-w-[260px] p-3">
|
|
657
|
+
<p className="font-medium">{title}</p>
|
|
658
|
+
<p className="mt-1 text-[0.7rem] opacity-90 leading-relaxed">{body}</p>
|
|
659
|
+
</TooltipContent>
|
|
660
|
+
</Tooltip>
|
|
661
|
+
) : (
|
|
662
|
+
trigger
|
|
663
|
+
);
|
|
664
|
+
|
|
665
|
+
return (
|
|
666
|
+
<div className="border border-ink/10 bg-ice/20">
|
|
667
|
+
{/* Toolbar */}
|
|
668
|
+
<div className="flex flex-wrap items-center gap-3 border-b border-ink/10 bg-white px-3 py-2">
|
|
669
|
+
<div className="flex items-center gap-1">
|
|
670
|
+
{(["desktop", "tablet", "mobile"] as Device[]).map((d) => {
|
|
671
|
+
const Icon = d === "desktop" ? Monitor : d === "tablet" ? Tablet : Smartphone;
|
|
672
|
+
return (
|
|
673
|
+
<button
|
|
674
|
+
key={d}
|
|
675
|
+
onClick={() => setDevice(d)}
|
|
676
|
+
className={`p-1.5 border ${device === d ? "border-sea bg-ice/50 text-ink" : "border-transparent text-ink/50 hover:text-ink"}`}
|
|
677
|
+
title={d}
|
|
678
|
+
>
|
|
679
|
+
<Icon className="size-4" />
|
|
680
|
+
</button>
|
|
681
|
+
);
|
|
682
|
+
})}
|
|
683
|
+
</div>
|
|
684
|
+
<div className="h-4 w-px bg-ink/10" />
|
|
685
|
+
<div className="flex items-center gap-1">
|
|
686
|
+
{locales.map((loc) => (
|
|
687
|
+
<button
|
|
688
|
+
key={loc}
|
|
689
|
+
onClick={() => setLocale(loc)}
|
|
690
|
+
className={`px-2 py-1 text-[0.7rem] uppercase tracking-widest border ${
|
|
691
|
+
locale === loc
|
|
692
|
+
? "border-sea bg-ice/50 text-ink"
|
|
693
|
+
: "border-transparent text-ink/50 hover:text-ink"
|
|
694
|
+
}`}
|
|
695
|
+
>
|
|
696
|
+
{localeLabels[loc] ?? loc}
|
|
697
|
+
</button>
|
|
698
|
+
))}
|
|
699
|
+
</div>
|
|
700
|
+
<div className="h-4 w-px bg-ink/10" />
|
|
701
|
+
<button
|
|
702
|
+
onClick={undo}
|
|
703
|
+
disabled={!past.length}
|
|
704
|
+
className="p-1.5 text-ink/70 hover:text-ink disabled:opacity-30"
|
|
705
|
+
title="Undo (⌘Z)"
|
|
706
|
+
>
|
|
707
|
+
<Undo2 className="size-4" />
|
|
708
|
+
</button>
|
|
709
|
+
<button
|
|
710
|
+
onClick={redo}
|
|
711
|
+
disabled={!future.length}
|
|
712
|
+
className="p-1.5 text-ink/70 hover:text-ink disabled:opacity-30"
|
|
713
|
+
title="Redo (⌘⇧Z)"
|
|
714
|
+
>
|
|
715
|
+
<Redo2 className="size-4" />
|
|
716
|
+
</button>
|
|
717
|
+
<div className="ml-auto flex items-center gap-2">
|
|
718
|
+
<button
|
|
719
|
+
onClick={openPreviewInTab}
|
|
720
|
+
className="inline-flex items-center gap-1.5 border border-ink/15 px-2 py-1 text-xs hover:border-sea"
|
|
721
|
+
>
|
|
722
|
+
<ExternalLink className="size-3.5" /> Open preview
|
|
723
|
+
</button>
|
|
724
|
+
</div>
|
|
725
|
+
</div>
|
|
726
|
+
|
|
727
|
+
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_360px] xl:grid-cols-[minmax(0,1fr)_420px]">
|
|
728
|
+
{/* Preview */}
|
|
729
|
+
<div className="p-4 lg:p-6 flex justify-center overflow-x-auto bg-[repeating-linear-gradient(45deg,rgba(0,0,0,0.02)_0_10px,transparent_10px_20px)]">
|
|
730
|
+
<div
|
|
731
|
+
style={{
|
|
732
|
+
width: iframeWidth ? `${iframeWidth}px` : "100%",
|
|
733
|
+
maxWidth: "100%",
|
|
734
|
+
transition: "width .2s ease",
|
|
735
|
+
}}
|
|
736
|
+
className="bg-white shadow-[0_10px_40px_-15px_rgba(0,0,0,0.25)] border border-ink/10"
|
|
737
|
+
>
|
|
738
|
+
<iframe
|
|
739
|
+
ref={iframeRef}
|
|
740
|
+
src={previewUrl(pageKey)}
|
|
741
|
+
title="Page preview"
|
|
742
|
+
className="w-full block"
|
|
743
|
+
style={{ height: "calc(100vh - 220px)", minHeight: "600px", border: 0 }}
|
|
744
|
+
/>
|
|
745
|
+
</div>
|
|
746
|
+
</div>
|
|
747
|
+
|
|
748
|
+
{/* Inspector */}
|
|
749
|
+
<div className="min-w-0 border-t lg:border-t-0 lg:border-l border-ink/10 bg-white flex flex-col max-h-[calc(100vh-120px)]">
|
|
750
|
+
<div className="border-b border-ink/10 px-4 py-3 flex items-center justify-between">
|
|
751
|
+
<div>
|
|
752
|
+
<div className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/50">
|
|
753
|
+
Inspector
|
|
754
|
+
</div>
|
|
755
|
+
<div className="text-sm font-medium text-ink truncate flex items-center gap-1.5">
|
|
756
|
+
{selected?.type === "globalRef" && (
|
|
757
|
+
<Globe className="size-3.5 text-ink/40 shrink-0" aria-label="Global block" />
|
|
758
|
+
)}
|
|
759
|
+
{selectedLabel}
|
|
760
|
+
</div>
|
|
761
|
+
</div>
|
|
762
|
+
<div className="flex items-center gap-1">
|
|
763
|
+
{selected &&
|
|
764
|
+
withTooltip(
|
|
765
|
+
<button
|
|
766
|
+
onClick={() => saveSelectedAsTemplate()}
|
|
767
|
+
className="inline-flex items-center gap-1 border border-ink/15 px-2 py-1 text-[0.7rem] hover:border-sea"
|
|
768
|
+
>
|
|
769
|
+
<BookmarkPlus className="size-3" /> Save one-off copy
|
|
770
|
+
</button>,
|
|
771
|
+
"Save a standalone copy",
|
|
772
|
+
"Stores this block in the Blocks library as a one-off copy. Reusing it places an independent duplicate, so later edits only affect this page.",
|
|
773
|
+
)}
|
|
774
|
+
{selected &&
|
|
775
|
+
selected.type !== "globalRef" &&
|
|
776
|
+
withTooltip(
|
|
777
|
+
<button
|
|
778
|
+
onClick={() => saveSelectedAsGlobalSection()}
|
|
779
|
+
className="inline-flex items-center gap-1 border border-ink/15 px-2 py-1 text-[0.7rem] hover:border-sea"
|
|
780
|
+
>
|
|
781
|
+
<Globe className="size-3" /> Save as global block
|
|
782
|
+
</button>,
|
|
783
|
+
"Save as a global block",
|
|
784
|
+
"Creates a global block in the library. Every page or section that references it stays in sync — edit once, update everywhere.",
|
|
785
|
+
)}
|
|
786
|
+
{selected && (
|
|
787
|
+
<button
|
|
788
|
+
onClick={() => setSelectedId(null)}
|
|
789
|
+
className="text-ink/40 hover:text-ink"
|
|
790
|
+
title="Deselect"
|
|
791
|
+
>
|
|
792
|
+
<X className="size-4" />
|
|
793
|
+
</button>
|
|
794
|
+
)}
|
|
795
|
+
</div>
|
|
796
|
+
</div>
|
|
797
|
+
|
|
798
|
+
<div className="p-4 overflow-y-auto flex-1 space-y-5">
|
|
799
|
+
{selected ? (
|
|
800
|
+
<InspectorComponent
|
|
801
|
+
block={selected}
|
|
802
|
+
patch={(next) => patchBlock(selected.id, next)}
|
|
803
|
+
globalSections={globalSections}
|
|
804
|
+
/>
|
|
805
|
+
) : (
|
|
806
|
+
<div className="text-sm text-ink/60 space-y-2">
|
|
807
|
+
<p>Click any section in the preview to edit it, or add a new block below.</p>
|
|
808
|
+
<p className="text-[0.7rem] text-ink/45">Tip: use ⌘Z / ⌘⇧Z to undo & redo.</p>
|
|
809
|
+
</div>
|
|
810
|
+
)}
|
|
811
|
+
</div>
|
|
812
|
+
|
|
813
|
+
{/* Outline */}
|
|
814
|
+
<div className="border-t border-ink/10 p-3 space-y-2 max-h-[45%] overflow-y-auto">
|
|
815
|
+
<div className="flex items-center justify-between">
|
|
816
|
+
<div className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/50 flex items-center gap-1">
|
|
817
|
+
<ChevronDown className="size-3" /> {singleBlock ? "Block" : "Page outline"}
|
|
818
|
+
</div>
|
|
819
|
+
<div className="text-[0.65rem] text-ink/40">
|
|
820
|
+
{singleBlock ? "one block only" : `${blocks.length} blocks`}
|
|
821
|
+
</div>
|
|
822
|
+
</div>
|
|
823
|
+
{(!singleBlock || blocks.length === 0) && (
|
|
824
|
+
<BlockPickerComponent
|
|
825
|
+
onAdd={addBlock}
|
|
826
|
+
globalSections={globalSections}
|
|
827
|
+
templates={templates}
|
|
828
|
+
onDeleteTemplate={onDeleteTemplate}
|
|
829
|
+
hideGlobals={singleBlock}
|
|
830
|
+
/>
|
|
831
|
+
)}
|
|
832
|
+
|
|
833
|
+
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
|
834
|
+
<SortableContext
|
|
835
|
+
items={blocks.map((b) => b.id)}
|
|
836
|
+
strategy={verticalListSortingStrategy}
|
|
837
|
+
>
|
|
838
|
+
<div className="space-y-1">
|
|
839
|
+
{blocks.map((b) => (
|
|
840
|
+
<OutlineRow
|
|
841
|
+
key={b.id}
|
|
842
|
+
block={b}
|
|
843
|
+
selected={selectedId === b.id}
|
|
844
|
+
onSelect={() => selectAndReveal(b.id)}
|
|
845
|
+
onDuplicate={() => duplicateBlock(b.id)}
|
|
846
|
+
onDelete={() => removeBlock(b.id)}
|
|
847
|
+
onToggleVisible={() => toggleVisible(b.id)}
|
|
848
|
+
onRename={(name) => renameBlock(b.id, name)}
|
|
849
|
+
globalSections={globalSections}
|
|
850
|
+
labelForType={labelForType}
|
|
851
|
+
singleBlock={singleBlock}
|
|
852
|
+
/>
|
|
853
|
+
))}
|
|
854
|
+
</div>
|
|
855
|
+
</SortableContext>
|
|
856
|
+
</DndContext>
|
|
857
|
+
{!singleBlock && (
|
|
858
|
+
<BlockPickerComponent
|
|
859
|
+
onAdd={addBlock}
|
|
860
|
+
globalSections={globalSections}
|
|
861
|
+
templates={templates}
|
|
862
|
+
onDeleteTemplate={onDeleteTemplate}
|
|
863
|
+
/>
|
|
864
|
+
)}
|
|
865
|
+
</div>
|
|
866
|
+
</div>
|
|
867
|
+
</div>
|
|
868
|
+
|
|
869
|
+
<SaveGlobalDialog
|
|
870
|
+
open={saveDialog !== null}
|
|
871
|
+
mode={saveDialog === "template" ? "template" : "global"}
|
|
872
|
+
defaultName={saveDialogDefaultName}
|
|
873
|
+
error={saveDialogError}
|
|
874
|
+
busy={saveBusy}
|
|
875
|
+
onCancel={() => setSaveDialog(null)}
|
|
876
|
+
onSubmit={handleSaveDialogSubmit}
|
|
877
|
+
/>
|
|
878
|
+
</div>
|
|
879
|
+
);
|
|
880
|
+
}
|