@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.15.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
+ }