@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.
Files changed (88) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/README.md +23 -1
  3. package/docs/GOOGLE-SHEETS.md +145 -0
  4. package/docs/LAYOUT-BLOCKS.md +125 -0
  5. package/docs/MEDIA-FOLDERS.md +90 -0
  6. package/docs/NEW-SITE.md +35 -0
  7. package/docs/PAGES.md +452 -0
  8. package/docs/SCHEDULING.md +130 -0
  9. package/docs/TYPOGRAPHY.md +86 -0
  10. package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
  11. package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
  12. package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
  13. package/package.json +5 -1
  14. package/src/backoffice/DomainsView.tsx +152 -25
  15. package/src/backoffice/FormEditorView.tsx +10 -0
  16. package/src/backoffice/FormSettingsView.tsx +8 -0
  17. package/src/backoffice/MediaLibraryView.tsx +239 -21
  18. package/src/backoffice/NavigationView.tsx +43 -0
  19. package/src/backoffice/PageBuilderView.tsx +193 -36
  20. package/src/backoffice/PageEditorView.tsx +293 -57
  21. package/src/backoffice/PagesListView.tsx +294 -19
  22. package/src/backoffice/ScheduleFields.tsx +151 -0
  23. package/src/backoffice/SectionFormView.tsx +58 -0
  24. package/src/backoffice/StatusBadge.tsx +174 -0
  25. package/src/backoffice/TrackingView.tsx +247 -208
  26. package/src/backoffice/TypographyPanel.tsx +308 -0
  27. package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
  28. package/src/backoffice/inspectors/blocks.tsx +149 -0
  29. package/src/backoffice/inspectors/fields.tsx +81 -0
  30. package/src/backoffice/inspectors/index.tsx +3 -0
  31. package/src/backoffice/media/ConfirmDialog.tsx +64 -4
  32. package/src/backoffice/routes/host.ts +31 -0
  33. package/src/backoffice/routes/index.tsx +140 -15
  34. package/src/backoffice/routes/media-forms.tsx +5 -0
  35. package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
  36. package/src/backoffice/seo/page-seo-form.ts +11 -1
  37. package/src/backoffice/shell/domain-guard.ts +27 -14
  38. package/src/backoffice/shell/index.ts +5 -0
  39. package/src/backoffice/shell/use-resolved-config.tsx +67 -0
  40. package/src/backoffice/tracking-drafts.ts +150 -0
  41. package/src/blocks/block-children.tsx +40 -0
  42. package/src/blocks/block-tree.ts +378 -0
  43. package/src/blocks/components/IconGridBlock.tsx +76 -51
  44. package/src/blocks/components/LayoutBlock.tsx +100 -0
  45. package/src/blocks/countdown-time.ts +17 -87
  46. package/src/blocks/registry.tsx +21 -0
  47. package/src/blocks/types.ts +40 -0
  48. package/src/cms/page-publication.ts +377 -0
  49. package/src/cms/page-status.ts +152 -0
  50. package/src/cms/timezone.ts +181 -0
  51. package/src/cms/use-publication-boundary.ts +84 -0
  52. package/src/cms/use-schedule-clock.ts +43 -0
  53. package/src/cms/visibility-schedule.ts +267 -0
  54. package/src/config/modules.ts +34 -10
  55. package/src/config/site-config.ts +32 -0
  56. package/src/config/typography.ts +143 -0
  57. package/src/http/cache-policy.ts +49 -2
  58. package/src/media/folders.ts +183 -0
  59. package/src/media/use-media-upload.ts +21 -4
  60. package/src/navigation/index.ts +34 -2
  61. package/src/platform/contract.ts +52 -1
  62. package/src/runtime/server-entry.ts +8 -2
  63. package/src/server/content-sections-core.ts +16 -0
  64. package/src/server/domains-core.ts +124 -107
  65. package/src/server/forms-delivery-core.ts +61 -10
  66. package/src/server/forms-submissions-core.ts +47 -0
  67. package/src/server/google-service-account.ts +325 -0
  68. package/src/server/google-sheets-core.ts +463 -0
  69. package/src/server/http/sitemap.ts +32 -8
  70. package/src/server/injections-core.ts +230 -49
  71. package/src/server/media-library-core.ts +27 -6
  72. package/src/server/pages-core.ts +680 -184
  73. package/src/server/published-cache.ts +25 -4
  74. package/src/server/resolve-globals.ts +6 -1
  75. package/src/server/schema-drift.ts +4 -0
  76. package/src/server/schemas.ts +15 -0
  77. package/src/site/PageRenderer.tsx +45 -12
  78. package/src/site/SiteFooter.tsx +44 -3
  79. package/src/site/SiteHeader.tsx +23 -2
  80. package/src/site/google-fonts.ts +191 -0
  81. package/src/site/host.ts +172 -0
  82. package/src/site/locale-domains.ts +500 -0
  83. package/src/site/routes/public-page.tsx +12 -1
  84. package/src/site/routes.ts +206 -38
  85. package/src/site/site-url.ts +80 -17
  86. package/src/site/typography.tsx +75 -0
  87. package/src/tracking/injections.ts +52 -9
  88. 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.some((b) => b.id === d.blockId)) setSelectedId(d.blockId);
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
- const addBlock = (b: BlockInstance) => {
476
- commit([...blocks, b]);
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.map((b) => (b.id === id ? { ...b, props } : b)));
651
+ commit(patchBlockInTree(blocks, id, (b) => ({ ...b, props })));
481
652
  };
482
653
  const removeBlock = (id: string) => {
483
- commit(blocks.filter((b) => b.id !== id));
654
+ commit(removeBlockFromTree(blocks, id).blocks);
484
655
  if (selectedId === id) setSelectedId(null);
485
656
  };
486
657
  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);
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.map((b) => (b.id === id ? { ...b, ...(name ? { name } : { name: undefined }) } : b)),
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.map((b) => (b.id === id ? { ...b, visible: b.visible === false ? true : false } : b)),
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 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));
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
- const selected = useMemo(
526
- () => blocks.find((b) => b.id === selectedId) ?? null,
527
- [blocks, selectedId],
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.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
- ),
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.length} 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 sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
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={blocks.map((b) => b.id)}
1037
+ items={entries.map((row) => row.id)}
836
1038
  strategy={verticalListSortingStrategy}
837
1039
  >
838
1040
  <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
- ))}
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>