@bygghammar/cms-platform 0.90.0-m7.9 → 0.91.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/CHANGELOG.md +241 -0
- package/README.md +23 -1
- package/docs/GOOGLE-SHEETS.md +145 -0
- package/docs/LAYOUT-BLOCKS.md +125 -0
- package/docs/MEDIA-FOLDERS.md +90 -0
- package/docs/NEW-SITE.md +35 -0
- package/docs/PAGES.md +452 -0
- package/docs/SCHEDULING.md +130 -0
- package/docs/TYPOGRAPHY.md +86 -0
- package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
- package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
- package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
- package/package.json +5 -1
- package/src/backoffice/DomainsView.tsx +152 -25
- package/src/backoffice/FormEditorView.tsx +10 -0
- package/src/backoffice/FormSettingsView.tsx +8 -0
- package/src/backoffice/MediaLibraryView.tsx +239 -21
- package/src/backoffice/NavigationView.tsx +43 -0
- package/src/backoffice/PageBuilderView.tsx +193 -36
- package/src/backoffice/PageEditorView.tsx +293 -57
- package/src/backoffice/PagesListView.tsx +294 -19
- package/src/backoffice/ScheduleFields.tsx +151 -0
- package/src/backoffice/SectionFormView.tsx +58 -0
- package/src/backoffice/StatusBadge.tsx +174 -0
- package/src/backoffice/TrackingView.tsx +247 -208
- package/src/backoffice/TypographyPanel.tsx +308 -0
- package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
- package/src/backoffice/inspectors/blocks.tsx +149 -0
- package/src/backoffice/inspectors/fields.tsx +81 -0
- package/src/backoffice/inspectors/index.tsx +3 -0
- package/src/backoffice/media/ConfirmDialog.tsx +64 -4
- package/src/backoffice/routes/host.ts +31 -0
- package/src/backoffice/routes/index.tsx +140 -15
- package/src/backoffice/routes/media-forms.tsx +5 -0
- package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
- package/src/backoffice/seo/page-seo-form.ts +11 -1
- package/src/backoffice/shell/domain-guard.ts +27 -14
- package/src/backoffice/shell/index.ts +5 -0
- package/src/backoffice/shell/use-resolved-config.tsx +67 -0
- package/src/backoffice/tracking-drafts.ts +150 -0
- package/src/blocks/block-children.tsx +40 -0
- package/src/blocks/block-tree.ts +378 -0
- package/src/blocks/components/IconGridBlock.tsx +76 -51
- package/src/blocks/components/LayoutBlock.tsx +100 -0
- package/src/blocks/countdown-time.ts +17 -87
- package/src/blocks/registry.tsx +21 -0
- package/src/blocks/types.ts +40 -0
- package/src/cms/page-publication.ts +377 -0
- package/src/cms/page-status.ts +152 -0
- package/src/cms/timezone.ts +181 -0
- package/src/cms/use-publication-boundary.ts +84 -0
- package/src/cms/use-schedule-clock.ts +43 -0
- package/src/cms/visibility-schedule.ts +267 -0
- package/src/config/modules.ts +34 -10
- package/src/config/site-config.ts +32 -0
- package/src/config/typography.ts +143 -0
- package/src/http/cache-policy.ts +49 -2
- package/src/media/folders.ts +183 -0
- package/src/media/use-media-upload.ts +21 -4
- package/src/navigation/index.ts +34 -2
- package/src/platform/contract.ts +52 -1
- package/src/runtime/server-entry.ts +8 -2
- package/src/server/content-sections-core.ts +16 -0
- package/src/server/domains-core.ts +124 -107
- package/src/server/forms-delivery-core.ts +61 -10
- package/src/server/forms-submissions-core.ts +47 -0
- package/src/server/google-service-account.ts +325 -0
- package/src/server/google-sheets-core.ts +463 -0
- package/src/server/http/sitemap.ts +32 -8
- package/src/server/injections-core.ts +230 -49
- package/src/server/media-library-core.ts +27 -6
- package/src/server/pages-core.ts +680 -184
- package/src/server/published-cache.ts +25 -4
- package/src/server/resolve-globals.ts +6 -1
- package/src/server/schema-drift.ts +4 -0
- package/src/server/schemas.ts +15 -0
- package/src/site/PageRenderer.tsx +45 -12
- package/src/site/SiteFooter.tsx +44 -3
- package/src/site/SiteHeader.tsx +23 -2
- package/src/site/google-fonts.ts +191 -0
- package/src/site/host.ts +172 -0
- package/src/site/locale-domains.ts +500 -0
- package/src/site/routes/public-page.tsx +12 -1
- package/src/site/routes.ts +206 -38
- package/src/site/site-url.ts +80 -17
- package/src/site/typography.tsx +75 -0
- package/src/tracking/injections.ts +52 -9
- package/src/ui/MobileCarousel.tsx +289 -0
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
} from "@dnd-kit/core";
|
|
11
11
|
import {
|
|
12
12
|
SortableContext,
|
|
13
|
-
arrayMove,
|
|
14
13
|
sortableKeyboardCoordinates,
|
|
15
14
|
useSortable,
|
|
16
15
|
verticalListSortingStrategy,
|
|
@@ -34,8 +33,28 @@ import {
|
|
|
34
33
|
Globe,
|
|
35
34
|
Pencil,
|
|
36
35
|
Check,
|
|
36
|
+
Columns3,
|
|
37
|
+
CornerLeftUp,
|
|
38
|
+
Plus,
|
|
37
39
|
} from "lucide-react";
|
|
38
40
|
import type { BlockInstance, BlockProps } from "../blocks/types";
|
|
41
|
+
import {
|
|
42
|
+
ROOT_TARGET,
|
|
43
|
+
blockColumns,
|
|
44
|
+
cloneBlockDeep,
|
|
45
|
+
countBlocksDeep,
|
|
46
|
+
findBlockDeep,
|
|
47
|
+
findBlockPath,
|
|
48
|
+
insertBlockIntoTree,
|
|
49
|
+
isLayoutBlock,
|
|
50
|
+
moveBlockInTree,
|
|
51
|
+
patchBlockInTree,
|
|
52
|
+
removeBlockFromTree,
|
|
53
|
+
walkBlockTree,
|
|
54
|
+
type DropTarget,
|
|
55
|
+
type MoveRefusal,
|
|
56
|
+
type InsertRefusal,
|
|
57
|
+
} from "../blocks/block-tree";
|
|
39
58
|
import { subscribeSharedDrafts } from "../cms/shared-draft-store";
|
|
40
59
|
import { SaveGlobalDialog, type SaveGlobalResult } from "./SaveGlobalDialog";
|
|
41
60
|
|
|
@@ -127,6 +146,8 @@ function OutlineRow({
|
|
|
127
146
|
globalSections,
|
|
128
147
|
labelForType,
|
|
129
148
|
singleBlock = false,
|
|
149
|
+
depth = 0,
|
|
150
|
+
onMoveOut,
|
|
130
151
|
}: {
|
|
131
152
|
block: BlockInstance;
|
|
132
153
|
selected: boolean;
|
|
@@ -138,6 +159,10 @@ function OutlineRow({
|
|
|
138
159
|
globalSections: GlobalSectionOption[];
|
|
139
160
|
labelForType: (type: string) => string;
|
|
140
161
|
singleBlock?: boolean;
|
|
162
|
+
/** Nesting level; 0 at the top of the page. */
|
|
163
|
+
depth?: number;
|
|
164
|
+
/** Present when the block sits inside a column. */
|
|
165
|
+
onMoveOut?: () => void;
|
|
141
166
|
}) {
|
|
142
167
|
const isGlobalRef = block.type === "globalRef";
|
|
143
168
|
const sectionKey = isGlobalRef
|
|
@@ -156,6 +181,7 @@ function OutlineRow({
|
|
|
156
181
|
transform: CSS.Transform.toString(transform),
|
|
157
182
|
transition,
|
|
158
183
|
opacity: isDragging ? 0.5 : 1,
|
|
184
|
+
marginLeft: depth * 14,
|
|
159
185
|
};
|
|
160
186
|
|
|
161
187
|
return (
|
|
@@ -223,9 +249,26 @@ function OutlineRow({
|
|
|
223
249
|
{isGlobalRef && (
|
|
224
250
|
<Globe className="size-3 text-ink/40 shrink-0" aria-label="Global block" />
|
|
225
251
|
)}
|
|
252
|
+
{isLayoutBlock(block) && (
|
|
253
|
+
<Columns3 className="size-3 text-ink/40 shrink-0" aria-label="Columns container" />
|
|
254
|
+
)}
|
|
226
255
|
</span>
|
|
227
256
|
)}
|
|
228
257
|
|
|
258
|
+
{onMoveOut && (
|
|
259
|
+
<button
|
|
260
|
+
onClick={(e) => {
|
|
261
|
+
e.stopPropagation();
|
|
262
|
+
onMoveOut();
|
|
263
|
+
}}
|
|
264
|
+
className="text-ink/40 hover:text-ink"
|
|
265
|
+
title="Move out of the column"
|
|
266
|
+
aria-label={`Move ${displayName} out of its column`}
|
|
267
|
+
>
|
|
268
|
+
<CornerLeftUp className="size-3.5" />
|
|
269
|
+
</button>
|
|
270
|
+
)}
|
|
271
|
+
|
|
229
272
|
<button
|
|
230
273
|
onClick={(e) => {
|
|
231
274
|
e.stopPropagation();
|
|
@@ -279,6 +322,127 @@ function OutlineRow({
|
|
|
279
322
|
);
|
|
280
323
|
}
|
|
281
324
|
|
|
325
|
+
/* ---------------- Column drop row ---------------- */
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* One column of a container block, in the outline.
|
|
329
|
+
*
|
|
330
|
+
* It is a drop target rather than a draggable: dropping a block here puts it
|
|
331
|
+
* at the end of that column, which is how a block gets *into* a layout. It is
|
|
332
|
+
* always shown, empty or not, so the columns a container has are visible
|
|
333
|
+
* without opening the preview.
|
|
334
|
+
*/
|
|
335
|
+
function ColumnRow({
|
|
336
|
+
id,
|
|
337
|
+
index,
|
|
338
|
+
count,
|
|
339
|
+
depth,
|
|
340
|
+
onAdd,
|
|
341
|
+
adding,
|
|
342
|
+
}: {
|
|
343
|
+
id: string;
|
|
344
|
+
index: number;
|
|
345
|
+
count: number;
|
|
346
|
+
depth: number;
|
|
347
|
+
onAdd: () => void;
|
|
348
|
+
adding: boolean;
|
|
349
|
+
}) {
|
|
350
|
+
const { setNodeRef, isOver } = useSortable({ id });
|
|
351
|
+
return (
|
|
352
|
+
<div
|
|
353
|
+
ref={setNodeRef}
|
|
354
|
+
style={{ marginLeft: depth * 14 }}
|
|
355
|
+
className={`flex items-center gap-1.5 border border-dashed px-2 py-1 text-[0.65rem] uppercase tracking-[0.16em] ${
|
|
356
|
+
isOver ? "border-sea bg-ice/50 text-ink" : "border-ink/15 bg-ice/20 text-ink/45"
|
|
357
|
+
}`}
|
|
358
|
+
>
|
|
359
|
+
<Columns3 className="size-3 shrink-0" />
|
|
360
|
+
<span className="flex-1 truncate">
|
|
361
|
+
{`Column ${index + 1}`}
|
|
362
|
+
<span className="ml-2 normal-case tracking-normal text-ink/35">
|
|
363
|
+
{count === 0 ? "empty — drop a block here" : `${count} block${count === 1 ? "" : "s"}`}
|
|
364
|
+
</span>
|
|
365
|
+
</span>
|
|
366
|
+
<button
|
|
367
|
+
onClick={(e) => {
|
|
368
|
+
e.stopPropagation();
|
|
369
|
+
onAdd();
|
|
370
|
+
}}
|
|
371
|
+
className="text-ink/40 hover:text-ink"
|
|
372
|
+
title={`Add a block to column ${index + 1}`}
|
|
373
|
+
aria-expanded={adding}
|
|
374
|
+
>
|
|
375
|
+
<Plus className="size-3.5" />
|
|
376
|
+
</button>
|
|
377
|
+
</div>
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/* ---------------- Outline model ---------------- */
|
|
382
|
+
|
|
383
|
+
const COLUMN_ROW_PREFIX = "cms-column:";
|
|
384
|
+
|
|
385
|
+
type OutlineEntry =
|
|
386
|
+
| { kind: "block"; id: string; block: BlockInstance; depth: number; target: DropTarget }
|
|
387
|
+
| {
|
|
388
|
+
kind: "column";
|
|
389
|
+
id: string;
|
|
390
|
+
parentId: string;
|
|
391
|
+
column: number;
|
|
392
|
+
count: number;
|
|
393
|
+
depth: number;
|
|
394
|
+
target: DropTarget;
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* The block tree as one flat list of outline rows.
|
|
399
|
+
*
|
|
400
|
+
* Drag and drop works on a single flat sortable list — which is what makes
|
|
401
|
+
* root-to-column, column-to-column and column-to-root the same gesture — while
|
|
402
|
+
* `depth` and the column rows keep the nesting readable. Each row carries the
|
|
403
|
+
* drop target it represents, so nothing has to be parsed back out of an id.
|
|
404
|
+
*/
|
|
405
|
+
function outlineEntries(blocks: BlockInstance[]): OutlineEntry[] {
|
|
406
|
+
const rows: OutlineEntry[] = [];
|
|
407
|
+
const visit = (list: BlockInstance[], parentId: string | null, column: number, depth: number) => {
|
|
408
|
+
list.forEach((block, index) => {
|
|
409
|
+
rows.push({
|
|
410
|
+
kind: "block",
|
|
411
|
+
id: block.id,
|
|
412
|
+
block,
|
|
413
|
+
depth,
|
|
414
|
+
target: { parentId, column, index },
|
|
415
|
+
});
|
|
416
|
+
blockColumns(block).forEach((children, columnIndex) => {
|
|
417
|
+
rows.push({
|
|
418
|
+
kind: "column",
|
|
419
|
+
id: `${COLUMN_ROW_PREFIX}${block.id}:${columnIndex}`,
|
|
420
|
+
parentId: block.id,
|
|
421
|
+
column: columnIndex,
|
|
422
|
+
count: children.length,
|
|
423
|
+
depth: depth + 1,
|
|
424
|
+
target: { parentId: block.id, column: columnIndex, index: children.length },
|
|
425
|
+
});
|
|
426
|
+
visit(children, block.id, columnIndex, depth + 2);
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
};
|
|
430
|
+
visit(blocks, null, 0, 0);
|
|
431
|
+
return rows;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const MOVE_REFUSED: Record<MoveRefusal, string> = {
|
|
435
|
+
self: "A container cannot be placed inside itself.",
|
|
436
|
+
descendant: "A container cannot be placed inside one of its own columns.",
|
|
437
|
+
"too-deep": "That would nest columns too deeply. Move it somewhere shallower.",
|
|
438
|
+
missing: "That block is no longer on the page.",
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
const INSERT_REFUSED: Record<InsertRefusal, string> = {
|
|
442
|
+
"missing-parent": "That column is no longer on the page.",
|
|
443
|
+
"too-deep": "That would nest columns too deeply. Add it somewhere shallower.",
|
|
444
|
+
};
|
|
445
|
+
|
|
282
446
|
/* ---------------- PageEditorView ---------------- */
|
|
283
447
|
|
|
284
448
|
export function PageEditorView({
|
|
@@ -455,7 +619,7 @@ export function PageEditorView({
|
|
|
455
619
|
if (d?.source !== "lovable-preview") return;
|
|
456
620
|
if (d.type === "ready") setPreviewReady(true);
|
|
457
621
|
if (d.type === "select" && d.blockId) {
|
|
458
|
-
if (currentBlocks
|
|
622
|
+
if (findBlockDeep(currentBlocks, d.blockId)) setSelectedId(d.blockId);
|
|
459
623
|
}
|
|
460
624
|
};
|
|
461
625
|
window.addEventListener("message", onMsg);
|
|
@@ -471,61 +635,93 @@ export function PageEditorView({
|
|
|
471
635
|
el?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
472
636
|
}, [selectedId]);
|
|
473
637
|
|
|
474
|
-
// Mutations
|
|
475
|
-
|
|
476
|
-
|
|
638
|
+
// Mutations. Every one of them works on the whole tree, so a block inside a
|
|
639
|
+
// column behaves exactly like a block at the top of the page.
|
|
640
|
+
const addBlockAt = (b: BlockInstance, target: DropTarget) => {
|
|
641
|
+
const result = insertBlockIntoTree(blocks, b, target);
|
|
642
|
+
if (!result.ok) {
|
|
643
|
+
notify?.error?.(INSERT_REFUSED[result.reason]);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
commit(result.blocks);
|
|
477
647
|
setSelectedId(b.id);
|
|
478
648
|
};
|
|
649
|
+
const addBlock = (b: BlockInstance) => addBlockAt(b, ROOT_TARGET(blocks.length));
|
|
479
650
|
const patchBlock = (id: string, props: BlockProps) => {
|
|
480
|
-
commit(blocks
|
|
651
|
+
commit(patchBlockInTree(blocks, id, (b) => ({ ...b, props })));
|
|
481
652
|
};
|
|
482
653
|
const removeBlock = (id: string) => {
|
|
483
|
-
commit(blocks
|
|
654
|
+
commit(removeBlockFromTree(blocks, id).blocks);
|
|
484
655
|
if (selectedId === id) setSelectedId(null);
|
|
485
656
|
};
|
|
486
657
|
const duplicateBlock = (id: string) => {
|
|
487
|
-
const
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
658
|
+
const path = findBlockPath(blocks, id);
|
|
659
|
+
const src = findBlockDeep(blocks, id);
|
|
660
|
+
if (!path || !src) return;
|
|
661
|
+
// A deep copy: nested blocks get fresh ids too, or the two containers would
|
|
662
|
+
// share children.
|
|
663
|
+
const clone = cloneBlockDeep(src);
|
|
664
|
+
const result = insertBlockIntoTree(blocks, clone, { ...path, index: path.index + 1 });
|
|
665
|
+
if (!result.ok) {
|
|
666
|
+
notify?.error?.(INSERT_REFUSED[result.reason]);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
commit(result.blocks);
|
|
498
670
|
setSelectedId(clone.id);
|
|
499
671
|
};
|
|
500
672
|
const renameBlock = (id: string, name: string) => {
|
|
501
673
|
commit(
|
|
502
|
-
blocks
|
|
674
|
+
patchBlockInTree(blocks, id, (b) => ({ ...b, ...(name ? { name } : { name: undefined }) })),
|
|
503
675
|
);
|
|
504
676
|
};
|
|
505
677
|
const toggleVisible = (id: string) => {
|
|
506
678
|
commit(
|
|
507
|
-
blocks
|
|
679
|
+
patchBlockInTree(blocks, id, (b) => ({ ...b, visible: b.visible === false ? true : false })),
|
|
508
680
|
);
|
|
509
681
|
};
|
|
510
682
|
|
|
683
|
+
/** Move a nested block up to sit just after the container it was inside. */
|
|
684
|
+
const moveOutOfColumn = (id: string) => {
|
|
685
|
+
const path = findBlockPath(blocks, id);
|
|
686
|
+
if (!path?.parentId) return;
|
|
687
|
+
const parentPath = findBlockPath(blocks, path.parentId);
|
|
688
|
+
if (!parentPath) return;
|
|
689
|
+
const result = moveBlockInTree(blocks, id, { ...parentPath, index: parentPath.index + 1 });
|
|
690
|
+
if (!result.ok) {
|
|
691
|
+
notify?.error?.(MOVE_REFUSED[result.reason]);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
commit(result.blocks);
|
|
695
|
+
};
|
|
696
|
+
|
|
511
697
|
const sensors = useSensors(
|
|
512
698
|
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
|
513
699
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
514
700
|
);
|
|
515
701
|
|
|
702
|
+
const entries = useMemo(() => outlineEntries(blocks), [blocks]);
|
|
703
|
+
|
|
516
704
|
const onDragEnd = (e: DragEndEvent) => {
|
|
517
705
|
const { active, over } = e;
|
|
518
706
|
if (!over || active.id === over.id) return;
|
|
519
|
-
const
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
707
|
+
const target = entries.find((row) => row.id === String(over.id))?.target;
|
|
708
|
+
if (!target) return;
|
|
709
|
+
const result = moveBlockInTree(blocks, String(active.id), target);
|
|
710
|
+
if (!result.ok) {
|
|
711
|
+
notify?.error?.(MOVE_REFUSED[result.reason]);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
commit(result.blocks);
|
|
523
715
|
};
|
|
524
716
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
717
|
+
/** The column a newly picked block should go into, if any. */
|
|
718
|
+
const [addTarget, setAddTarget] = useState<{ rowId: string; target: DropTarget } | null>(null);
|
|
719
|
+
useEffect(() => {
|
|
720
|
+
// The column may have gone away (undo, deletion); do not keep a stale form.
|
|
721
|
+
if (addTarget && !entries.some((row) => row.id === addTarget.rowId)) setAddTarget(null);
|
|
722
|
+
}, [entries, addTarget]);
|
|
723
|
+
|
|
724
|
+
const selected = useMemo(() => findBlockDeep(blocks, selectedId ?? ""), [blocks, selectedId]);
|
|
529
725
|
const selectedLabel = useMemo(() => {
|
|
530
726
|
if (!selected) return "Nothing selected";
|
|
531
727
|
if (selected.name?.trim()) return selected.name.trim();
|
|
@@ -605,17 +801,13 @@ export function PageEditorView({
|
|
|
605
801
|
});
|
|
606
802
|
// Replace the block on this page with a reference to the new global entry.
|
|
607
803
|
commit(
|
|
608
|
-
currentBlocks.
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
visible: b.visible,
|
|
616
|
-
}
|
|
617
|
-
: b,
|
|
618
|
-
),
|
|
804
|
+
patchBlockInTree(currentBlocks, selected.id, (b) => ({
|
|
805
|
+
id: b.id,
|
|
806
|
+
type: "globalRef",
|
|
807
|
+
name: result.name,
|
|
808
|
+
props: { sectionKey: result.key },
|
|
809
|
+
visible: b.visible,
|
|
810
|
+
})),
|
|
619
811
|
);
|
|
620
812
|
notify?.success?.(
|
|
621
813
|
result.kind === "section"
|
|
@@ -817,7 +1009,7 @@ export function PageEditorView({
|
|
|
817
1009
|
<ChevronDown className="size-3" /> {singleBlock ? "Block" : "Page outline"}
|
|
818
1010
|
</div>
|
|
819
1011
|
<div className="text-[0.65rem] text-ink/40">
|
|
820
|
-
{singleBlock ? "one block only" : `${blocks
|
|
1012
|
+
{singleBlock ? "one block only" : `${countBlocksDeep(blocks)} blocks`}
|
|
821
1013
|
</div>
|
|
822
1014
|
</div>
|
|
823
1015
|
{(!singleBlock || blocks.length === 0) && (
|
|
@@ -830,27 +1022,71 @@ export function PageEditorView({
|
|
|
830
1022
|
/>
|
|
831
1023
|
)}
|
|
832
1024
|
|
|
833
|
-
<DndContext
|
|
1025
|
+
<DndContext
|
|
1026
|
+
sensors={sensors}
|
|
1027
|
+
collisionDetection={closestCenter}
|
|
1028
|
+
onDragEnd={onDragEnd}
|
|
1029
|
+
accessibility={{
|
|
1030
|
+
screenReaderInstructions: {
|
|
1031
|
+
draggable:
|
|
1032
|
+
"Press space to pick up a block. Use the arrow keys to move it — a row labelled Column is a drop target inside a container. Press space to drop, or escape to cancel.",
|
|
1033
|
+
},
|
|
1034
|
+
}}
|
|
1035
|
+
>
|
|
834
1036
|
<SortableContext
|
|
835
|
-
items={
|
|
1037
|
+
items={entries.map((row) => row.id)}
|
|
836
1038
|
strategy={verticalListSortingStrategy}
|
|
837
1039
|
>
|
|
838
1040
|
<div className="space-y-1">
|
|
839
|
-
{
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
1041
|
+
{entries.map((row) =>
|
|
1042
|
+
row.kind === "block" ? (
|
|
1043
|
+
<OutlineRow
|
|
1044
|
+
key={row.id}
|
|
1045
|
+
block={row.block}
|
|
1046
|
+
depth={row.depth}
|
|
1047
|
+
selected={selectedId === row.id}
|
|
1048
|
+
onSelect={() => selectAndReveal(row.id)}
|
|
1049
|
+
onDuplicate={() => duplicateBlock(row.id)}
|
|
1050
|
+
onDelete={() => removeBlock(row.id)}
|
|
1051
|
+
onToggleVisible={() => toggleVisible(row.id)}
|
|
1052
|
+
onRename={(name) => renameBlock(row.id, name)}
|
|
1053
|
+
onMoveOut={
|
|
1054
|
+
row.target.parentId ? () => moveOutOfColumn(row.id) : undefined
|
|
1055
|
+
}
|
|
1056
|
+
globalSections={globalSections}
|
|
1057
|
+
labelForType={labelForType}
|
|
1058
|
+
singleBlock={singleBlock}
|
|
1059
|
+
/>
|
|
1060
|
+
) : (
|
|
1061
|
+
<div key={row.id} className="space-y-1">
|
|
1062
|
+
<ColumnRow
|
|
1063
|
+
id={row.id}
|
|
1064
|
+
index={row.column}
|
|
1065
|
+
count={row.count}
|
|
1066
|
+
depth={row.depth}
|
|
1067
|
+
adding={addTarget?.rowId === row.id}
|
|
1068
|
+
onAdd={() =>
|
|
1069
|
+
setAddTarget((cur) =>
|
|
1070
|
+
cur?.rowId === row.id ? null : { rowId: row.id, target: row.target },
|
|
1071
|
+
)
|
|
1072
|
+
}
|
|
1073
|
+
/>
|
|
1074
|
+
{addTarget?.rowId === row.id ? (
|
|
1075
|
+
<div style={{ marginLeft: row.depth * 14 }}>
|
|
1076
|
+
<BlockPickerComponent
|
|
1077
|
+
onAdd={(b) => {
|
|
1078
|
+
addBlockAt(b, addTarget.target);
|
|
1079
|
+
setAddTarget(null);
|
|
1080
|
+
}}
|
|
1081
|
+
globalSections={globalSections}
|
|
1082
|
+
templates={templates}
|
|
1083
|
+
onDeleteTemplate={onDeleteTemplate}
|
|
1084
|
+
/>
|
|
1085
|
+
</div>
|
|
1086
|
+
) : null}
|
|
1087
|
+
</div>
|
|
1088
|
+
),
|
|
1089
|
+
)}
|
|
854
1090
|
</div>
|
|
855
1091
|
</SortableContext>
|
|
856
1092
|
</DndContext>
|