@drawnagency/primitives 0.1.55 → 0.1.57

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 (173) hide show
  1. package/dist/auth/cookies.d.ts.map +1 -1
  2. package/dist/auth/index.js +1 -1
  3. package/dist/{chunk-24SUF2BC.js → chunk-ICLXLWQ5.js} +13 -74
  4. package/dist/chunk-NSCT3AMV.js +32 -0
  5. package/dist/{chunk-KDGYHU36.js → chunk-PRKUXM7E.js} +35 -10
  6. package/dist/{chunk-PUNXQK4M.js → chunk-PYWS3MOJ.js} +12 -2
  7. package/dist/chunk-TG43X7JO.js +123 -0
  8. package/dist/chunk-VKAGMEKE.js +90 -0
  9. package/dist/{chunk-B5VYSTPB.js → chunk-XTK4BR27.js} +1 -1
  10. package/dist/components/editor/ChildBlockWrapper.d.ts +19 -0
  11. package/dist/components/editor/ChildBlockWrapper.d.ts.map +1 -0
  12. package/dist/components/editor/ColSpanControl.d.ts +9 -0
  13. package/dist/components/editor/ColSpanControl.d.ts.map +1 -0
  14. package/dist/components/editor/SectionWrapper.d.ts +1 -1
  15. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  16. package/dist/components/editor/SettingsForm.d.ts +5 -1
  17. package/dist/components/editor/SettingsForm.d.ts.map +1 -1
  18. package/dist/components/primitives/EditableGrid.d.ts.map +1 -1
  19. package/dist/components/primitives/IconPicker.d.ts +7 -1
  20. package/dist/components/primitives/IconPicker.d.ts.map +1 -1
  21. package/dist/components/sections/Container/Container.d.ts +20 -0
  22. package/dist/components/sections/Container/Container.d.ts.map +1 -0
  23. package/dist/components/sections/Container/ContainerSettingsForm.d.ts +17 -0
  24. package/dist/components/sections/Container/ContainerSettingsForm.d.ts.map +1 -0
  25. package/dist/components/sections/Container/index.d.ts +11 -0
  26. package/dist/components/sections/Container/index.d.ts.map +1 -0
  27. package/dist/components/sections/IconList/IconList.d.ts +1 -0
  28. package/dist/components/sections/IconList/IconList.d.ts.map +1 -1
  29. package/dist/components/sections/IconList/IconListSettings.d.ts +3 -4
  30. package/dist/components/sections/IconList/IconListSettings.d.ts.map +1 -1
  31. package/dist/components/sections/IconList/index.d.ts +1 -0
  32. package/dist/components/sections/IconList/index.d.ts.map +1 -1
  33. package/dist/components/sections/Media/MediaBlock.d.ts +19 -0
  34. package/dist/components/sections/Media/MediaBlock.d.ts.map +1 -0
  35. package/dist/components/sections/{MediaGrid → Media}/index.d.ts +15 -25
  36. package/dist/components/sections/Media/index.d.ts.map +1 -0
  37. package/dist/components/sections/Prose/index.d.ts.map +1 -1
  38. package/dist/components/sections/Spacer/Spacer.d.ts +2 -0
  39. package/dist/components/sections/Spacer/Spacer.d.ts.map +1 -0
  40. package/dist/components/sections/Spacer/index.d.ts +6 -0
  41. package/dist/components/sections/Spacer/index.d.ts.map +1 -0
  42. package/dist/components/sections/all-sections.d.ts +140 -0
  43. package/dist/components/sections/all-sections.d.ts.map +1 -0
  44. package/dist/components/sections/register-schemas.d.ts.map +1 -1
  45. package/dist/components/sections/register.d.ts.map +1 -1
  46. package/dist/components/shared/Tabs.d.ts +24 -0
  47. package/dist/components/shared/Tabs.d.ts.map +1 -0
  48. package/dist/components/shell/EditorShell.d.ts +2 -1
  49. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  50. package/dist/components/shell/SiteSettingsModal.d.ts.map +1 -1
  51. package/dist/components/shell/blockMoveDispatch.d.ts +21 -0
  52. package/dist/components/shell/blockMoveDispatch.d.ts.map +1 -0
  53. package/dist/hooks/useBlockDnd.d.ts +48 -0
  54. package/dist/hooks/useBlockDnd.d.ts.map +1 -0
  55. package/dist/hooks/useEditorPublish.d.ts +2 -1
  56. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  57. package/dist/index.js +69 -48
  58. package/dist/lib/block-dnd.d.ts +42 -0
  59. package/dist/lib/block-dnd.d.ts.map +1 -0
  60. package/dist/lib/block-move.d.ts +31 -0
  61. package/dist/lib/block-move.d.ts.map +1 -0
  62. package/dist/lib/container-grid.d.ts +29 -0
  63. package/dist/lib/container-grid.d.ts.map +1 -0
  64. package/dist/lib/container-ops.d.ts +44 -0
  65. package/dist/lib/container-ops.d.ts.map +1 -0
  66. package/dist/lib/dexie.d.ts +12 -1
  67. package/dist/lib/dexie.d.ts.map +1 -1
  68. package/dist/lib/dexie.js +28 -3
  69. package/dist/lib/index.js +10 -7
  70. package/dist/lib/loader.d.ts.map +1 -1
  71. package/dist/lib/migrate-sections-transform.d.ts +12 -0
  72. package/dist/lib/migrate-sections-transform.d.ts.map +1 -0
  73. package/dist/lib/migrate-sections-transform.js +6 -0
  74. package/dist/lib/registry.d.ts +39 -2
  75. package/dist/lib/registry.d.ts.map +1 -1
  76. package/dist/lib/registry.js +26 -0
  77. package/dist/lib/sanitize.d.ts.map +1 -1
  78. package/dist/schemas/block.d.ts +20 -0
  79. package/dist/schemas/block.d.ts.map +1 -0
  80. package/dist/schemas/block.js +14 -0
  81. package/dist/schemas/index.js +10 -2
  82. package/dist/schemas/link.d.ts +7 -0
  83. package/dist/schemas/link.d.ts.map +1 -1
  84. package/dist/schemas/rich-text.d.ts +9 -0
  85. package/dist/schemas/rich-text.d.ts.map +1 -0
  86. package/dist/schemas/sections.d.ts +2 -0
  87. package/dist/schemas/sections.d.ts.map +1 -1
  88. package/dist/schemas/shared.d.ts +31 -0
  89. package/dist/schemas/shared.d.ts.map +1 -1
  90. package/dist/storage/index.d.ts +1 -0
  91. package/dist/storage/index.d.ts.map +1 -1
  92. package/dist/storage/types.d.ts +13 -1
  93. package/dist/storage/types.d.ts.map +1 -1
  94. package/package.json +13 -1
  95. package/src/auth/cookies.ts +6 -1
  96. package/src/components/brandguide/Colors.tsx +35 -33
  97. package/src/components/editor/ChildBlockWrapper.tsx +108 -0
  98. package/src/components/editor/ColSpanControl.tsx +56 -0
  99. package/src/components/editor/SectionWrapper.tsx +44 -20
  100. package/src/components/editor/SettingsForm.tsx +100 -73
  101. package/src/components/primitives/EditableGrid.tsx +40 -36
  102. package/src/components/primitives/IconPicker.tsx +116 -26
  103. package/src/components/sections/Container/Container.tsx +354 -0
  104. package/src/components/sections/Container/ContainerSettingsForm.tsx +113 -0
  105. package/src/components/sections/Container/index.tsx +51 -0
  106. package/src/components/sections/IconList/IconList.tsx +113 -43
  107. package/src/components/sections/IconList/IconListSettings.tsx +2 -2
  108. package/src/components/sections/IconList/index.tsx +1 -1
  109. package/src/components/sections/Media/MediaBlock.tsx +103 -0
  110. package/src/components/sections/Media/index.tsx +85 -0
  111. package/src/components/sections/Prose/index.tsx +1 -0
  112. package/src/components/sections/Spacer/Spacer.tsx +6 -0
  113. package/src/components/sections/Spacer/index.tsx +18 -0
  114. package/src/components/sections/all-sections.ts +40 -0
  115. package/src/components/sections/register-schemas.ts +13 -18
  116. package/src/components/sections/register.ts +3 -17
  117. package/src/components/shared/Tabs.tsx +63 -0
  118. package/src/components/shell/EditorShell.tsx +147 -18
  119. package/src/components/shell/SiteSettingsModal.tsx +41 -51
  120. package/src/components/shell/blockMoveDispatch.ts +40 -0
  121. package/src/hooks/useBlockDnd.ts +144 -0
  122. package/src/hooks/useEditorPublish.ts +17 -4
  123. package/src/lib/block-dnd.ts +58 -0
  124. package/src/lib/block-move.ts +236 -0
  125. package/src/lib/container-grid.ts +58 -0
  126. package/src/lib/container-ops.ts +159 -0
  127. package/src/lib/dexie.ts +47 -0
  128. package/src/lib/loader.ts +16 -4
  129. package/src/lib/migrate-sections-transform.ts +147 -0
  130. package/src/lib/registry.ts +48 -2
  131. package/src/lib/sanitize.ts +22 -1
  132. package/src/schemas/block.ts +40 -0
  133. package/src/schemas/link.ts +19 -1
  134. package/src/schemas/rich-text.ts +11 -0
  135. package/src/schemas/sections.ts +5 -1
  136. package/src/schemas/shared.ts +16 -0
  137. package/src/schemas/site-config.ts +3 -3
  138. package/src/storage/index.ts +1 -0
  139. package/src/storage/types.ts +17 -0
  140. package/dist/components/brandguide/DoDontList.d.ts +0 -16
  141. package/dist/components/brandguide/DoDontList.d.ts.map +0 -1
  142. package/dist/components/brandguide/DoDontMediaGrid.d.ts +0 -16
  143. package/dist/components/brandguide/DoDontMediaGrid.d.ts.map +0 -1
  144. package/dist/components/primitives/MediaSettingsForms.d.ts +0 -23
  145. package/dist/components/primitives/MediaSettingsForms.d.ts.map +0 -1
  146. package/dist/components/sections/DoDontList/index.d.ts +0 -21
  147. package/dist/components/sections/DoDontList/index.d.ts.map +0 -1
  148. package/dist/components/sections/DoDontMediaGrid/index.d.ts +0 -55
  149. package/dist/components/sections/DoDontMediaGrid/index.d.ts.map +0 -1
  150. package/dist/components/sections/MediaGrid/MediaGrid.d.ts +0 -17
  151. package/dist/components/sections/MediaGrid/MediaGrid.d.ts.map +0 -1
  152. package/dist/components/sections/MediaGrid/index.d.ts.map +0 -1
  153. package/dist/components/sections/SplitContent/SplitContent.d.ts +0 -14
  154. package/dist/components/sections/SplitContent/SplitContent.d.ts.map +0 -1
  155. package/dist/components/sections/SplitContent/index.d.ts +0 -13
  156. package/dist/components/sections/SplitContent/index.d.ts.map +0 -1
  157. package/src/components/brandguide/DoDontList.d.ts.map +0 -1
  158. package/src/components/brandguide/DoDontList.tsx +0 -67
  159. package/src/components/brandguide/DoDontMediaGrid.d.ts.map +0 -1
  160. package/src/components/brandguide/DoDontMediaGrid.tsx +0 -19
  161. package/src/components/primitives/MediaSettingsForms.tsx +0 -128
  162. package/src/components/sections/DoDontList/index.d.ts.map +0 -1
  163. package/src/components/sections/DoDontList/index.tsx +0 -45
  164. package/src/components/sections/DoDontMediaGrid/index.d.ts.map +0 -1
  165. package/src/components/sections/DoDontMediaGrid/index.tsx +0 -63
  166. package/src/components/sections/MediaGrid/MediaGrid.d.ts.map +0 -1
  167. package/src/components/sections/MediaGrid/MediaGrid.tsx +0 -239
  168. package/src/components/sections/MediaGrid/index.d.ts.map +0 -1
  169. package/src/components/sections/MediaGrid/index.tsx +0 -57
  170. package/src/components/sections/SplitContent/SplitContent.d.ts.map +0 -1
  171. package/src/components/sections/SplitContent/SplitContent.tsx +0 -84
  172. package/src/components/sections/SplitContent/index.d.ts.map +0 -1
  173. package/src/components/sections/SplitContent/index.tsx +0 -55
@@ -0,0 +1,63 @@
1
+ import { useState, type ReactNode } from "react";
2
+ import { cn } from "../../lib/cn";
3
+
4
+ export interface TabItem {
5
+ id: string;
6
+ label: string;
7
+ content: ReactNode;
8
+ }
9
+
10
+ interface TabsProps {
11
+ tabs: TabItem[];
12
+ /** Initial active tab when uncontrolled. Defaults to the first tab. */
13
+ defaultTabId?: string;
14
+ /** When provided, the component is controlled by the parent. */
15
+ activeTabId?: string;
16
+ onTabChange?: (id: string) => void;
17
+ /**
18
+ * When true, the tab-bar row uses -mx-6 to break out of the parent's px-6 padding
19
+ * (e.g. EditorModal's content wrapper) and re-pads with px-6 so buttons stay aligned.
20
+ * The tab panel inherits the parent's padding unchanged.
21
+ * Use only when the parent container has px-6; leave false for noPadding contexts.
22
+ */
23
+ fullBleedTabBar?: boolean;
24
+ }
25
+
26
+ export function Tabs({ tabs, defaultTabId, activeTabId, onTabChange, fullBleedTabBar }: TabsProps) {
27
+ const [uncontrolledId, setUncontrolledId] = useState(defaultTabId ?? tabs[0]?.id);
28
+ const isControlled = activeTabId !== undefined;
29
+ const activeId = isControlled ? activeTabId : uncontrolledId;
30
+ const activeTab = tabs.find((t) => t.id === activeId) ?? tabs[0];
31
+
32
+ function handleSelect(id: string) {
33
+ if (!isControlled) setUncontrolledId(id);
34
+ onTabChange?.(id);
35
+ }
36
+
37
+ return (
38
+ <div className="flex flex-1 flex-col overflow-hidden">
39
+ <div className={cn("border-b border-base-200", fullBleedTabBar && "-mx-6 px-6")}>
40
+ <div className="flex" role="tablist">
41
+ {tabs.map((tab) => (
42
+ <button
43
+ key={tab.id}
44
+ type="button"
45
+ role="tab"
46
+ aria-selected={activeTab?.id === tab.id}
47
+ onClick={() => handleSelect(tab.id)}
48
+ className={cn(
49
+ "cursor-pointer px-4 py-2 text-sm font-medium border-b-2 -mb-px",
50
+ activeTab?.id === tab.id
51
+ ? "border-brand text-brand"
52
+ : "border-transparent text-base-contrast-light hover:text-base-contrast",
53
+ )}
54
+ >
55
+ {tab.label}
56
+ </button>
57
+ ))}
58
+ </div>
59
+ </div>
60
+ <div role="tabpanel">{activeTab?.content}</div>
61
+ </div>
62
+ );
63
+ }
@@ -3,8 +3,9 @@ import { Fragment, useState, useCallback, useEffect, useRef, useMemo, type React
3
3
  import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
4
4
  import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
5
5
  import { autoScrollWindowForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
6
+ import { extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
6
7
  import type { LoadedSection } from "../../lib/loader";
7
- import type { SectionContent } from "../../schemas/sections";
8
+ import type { Section, SectionContent } from "../../schemas/sections";
8
9
  import type { SiteIndex, SiteConfig, Page } from "../../schemas/site-config";
9
10
  import type { Audience } from "../../auth/types";
10
11
  import type { MediaManifest } from "../../media/types";
@@ -12,6 +13,9 @@ import type { QueueItem } from "../../media/queue";
12
13
  import { SiteConfigSchema } from "../../schemas/site-config";
13
14
  import { ensureSanitizer } from "../../lib/sanitize";
14
15
  import { EditorProvider, useEditorContext } from "./EditorContext";
16
+ import { makeBlockMoveDispatch } from "./blockMoveDispatch";
17
+ import { ROOT_CONTAINER_ID, resolveDropIndex } from "../../lib/block-dnd";
18
+ import type { BlockMove } from "../../lib/block-move";
15
19
  import { EditorModalProvider, useEditorModal } from "./EditorModalContext";
16
20
  import { EditorModal } from "./EditorModal";
17
21
  import { SiteSettingsModal } from "./SiteSettingsModal";
@@ -19,8 +23,10 @@ import { MediaLibraryModal } from "./MediaLibraryModal";
19
23
  import { MediaLibraryContext } from "./MediaLibraryContext";
20
24
  import { ProcessingIndicator } from "./ProcessingIndicator";
21
25
  import { SectionSkeleton } from "./SectionSkeleton";
26
+ import { SectionTypePicker } from "./SectionTypePicker";
22
27
  import { ensureSectionsRegistered } from "../sections/register";
23
28
  import { getSection, getAllSections } from "../../lib/registry";
29
+ import { childInsertableTypes, insertChildAt } from "../../lib/container-ops";
24
30
 
25
31
  ensureSectionsRegistered();
26
32
  import { BugReportFAB } from "./BugReportFAB";
@@ -80,6 +86,10 @@ type ShellState =
80
86
 
81
87
  interface Props {
82
88
  headSha: string;
89
+ // Head of the branch the editor actually loads (draft `saved` if it exists,
90
+ // else main). The content cache is keyed on this, so a load with drafts can
91
+ // hit the cache instead of refetching every section. Falls back to headSha.
92
+ draftHeadSha?: string;
83
93
  siteId: string;
84
94
  audiences: Audience[];
85
95
  capabilities: {
@@ -95,6 +105,7 @@ interface Props {
95
105
 
96
106
  export default function EditorShell({
97
107
  headSha,
108
+ draftHeadSha,
98
109
  siteId,
99
110
  audiences: initialAudiences,
100
111
  capabilities,
@@ -117,6 +128,12 @@ export default function EditorShell({
117
128
  const [pendingDeleteSectionId, setPendingDeleteSectionId] = useState<string | null>(null);
118
129
  const [savedSha, setSavedSha] = useState<string | null>(null);
119
130
  const [mainSha, setMainSha] = useState<string | null>(null);
131
+ // Mirror savedSha into a ref so the save handler reads the latest draft
132
+ // version (the optimistic-concurrency base) without a stale closure.
133
+ const savedShaRef = useRef<string | null>(null);
134
+ useEffect(() => {
135
+ savedShaRef.current = savedSha;
136
+ }, [savedSha]);
120
137
  const [changedSectionIds, setChangedSectionIds] = useState<Set<string>>(new Set());
121
138
  const [mainIndex, setMainIndex] = useState<SiteIndex | null>(null);
122
139
  const [viewSections, setViewSections] = useState<LoadedSection[] | null>(null);
@@ -134,6 +151,8 @@ export default function EditorShell({
134
151
  // doesn't enqueue a GitHub file deletion for a path that was never committed.
135
152
  const remoteSectionIdsRef = useRef<Set<string>>(new Set());
136
153
  const fontLinkRef = useRef<HTMLLinkElement | null>(null);
154
+ const sectionsRef = useRef<LoadedSection[]>([]);
155
+ useEffect(() => { sectionsRef.current = sections; }, [sections]);
137
156
  useEffect(() => { siteIndexRef.current = siteIndex; }, [siteIndex]);
138
157
  useEffect(() => { void ensureSanitizer(); }, []);
139
158
 
@@ -253,6 +272,7 @@ export default function EditorShell({
253
272
  setMainSha((prev) => newMainSha ?? prev);
254
273
  },
255
274
  onPublishComplete: buildStatus.startTracking,
275
+ getBaseVersion: () => savedShaRef.current,
256
276
  });
257
277
 
258
278
  const { buttonState } = useContentLifecycle({
@@ -350,14 +370,32 @@ export default function EditorShell({
350
370
  let loadedConfig: SiteConfig;
351
371
  let loadedManifest: MediaManifest = { images: {} };
352
372
 
373
+ // Key the content cache on the head of the branch we actually load (the
374
+ // draft `saved` branch if it exists, else main), provided by the SSR edit
375
+ // page. Previously this compared against main's head while the cache
376
+ // stored the saved-branch SHA, so any load with a draft missed and
377
+ // refetched ~2N+ files from GitHub every time.
378
+ //
379
+ // draftHeadSha is the server's view at page-load time (fresher than the
380
+ // client could get without a blocking round-trip). It can only be stale for
381
+ // the SSR->hydration window, and only if another writer moves the draft in
382
+ // that window AND this browser already cached the old SHA; the result is at
383
+ // worst momentarily-stale read content, self-corrected on reload and guarded
384
+ // against lost writes by the save-time 409 (optimistic concurrency). We
385
+ // deliberately don't block every load on a freshness fetch to validate this.
386
+ const cacheKeySha = draftHeadSha ?? headSha;
387
+
353
388
  const cached = await getCachedContent();
354
- if (cached && cached.sha === headSha) {
389
+ if (cached && cached.sha === cacheKeySha) {
355
390
  loadedSections = cached.sections;
356
391
  loadedIndex = cached.index;
357
392
  loadedConfig = SiteConfigSchema.parse(cached.siteConfig);
358
393
  const savedManifest = await getMediaManifest();
359
394
  if (savedManifest) loadedManifest = savedManifest;
360
- setMainIndex(loadedIndex);
395
+ setSavedSha(cached.savedBranchSha ?? null);
396
+ setMainSha(headSha);
397
+ setChangedSectionIds(new Set(cached.changedSectionIds ?? []));
398
+ setMainIndex(cached.mainIndex ?? loadedIndex);
361
399
  } else {
362
400
  const response = await fetch("/api/content?branch=saved");
363
401
  if (!response.ok) throw new Error(`Failed to load content: ${response.status}`);
@@ -365,7 +403,11 @@ export default function EditorShell({
365
403
  loadedSections = data.sections;
366
404
  loadedIndex = data.index;
367
405
  loadedConfig = SiteConfigSchema.parse(data.siteConfig);
368
- await cacheContent(data.sha, data.sections, data.index, data.siteConfig);
406
+ await cacheContent(data.sha, data.sections, data.index, data.siteConfig, {
407
+ savedBranchSha: data.savedBranchSha ?? null,
408
+ changedSectionIds: data.changedSectionIds ?? [],
409
+ mainIndex: data.mainIndex ?? data.index,
410
+ });
369
411
  if (data.mediaManifest) {
370
412
  loadedManifest = data.mediaManifest as MediaManifest;
371
413
  await persistMediaManifest(loadedManifest);
@@ -428,7 +470,7 @@ export default function EditorShell({
428
470
  setShellState({ phase: "error", message: err instanceof Error ? err.message : "Failed to load content" });
429
471
  });
430
472
  return () => { cancelled = true; };
431
- }, [headSha, siteId, applySiteConfigPreview]);
473
+ }, [headSha, draftHeadSha, siteId, applySiteConfigPreview]);
432
474
 
433
475
  // --- Recovery handlers ---
434
476
 
@@ -601,6 +643,24 @@ export default function EditorShell({
601
643
  [persistence, resolvedActivePage],
602
644
  );
603
645
 
646
+ const onBlockMove = useMemo(
647
+ () => makeBlockMoveDispatch({
648
+ getState: () => ({ sections: sectionsRef.current, index: siteIndexRef.current, rootPageId: resolvedActivePage?.id ?? homePage(siteIndexRef.current).id }),
649
+ setSections,
650
+ setSiteIndex,
651
+ persistence: { markSectionDirty: persistence.markSectionDirty, markIndexDirty: persistence.markIndexDirty, removeSection: persistence.removeSection },
652
+ isRemote: (id) => remoteSectionIdsRef.current.has(id),
653
+ scheduleDelete: (id) => setDeletedSections((prev) => new Set(prev).add(id)),
654
+ markLocalChanges: () => setLocalChangesExist(true),
655
+ newSectionContent: (id, secs) => {
656
+ const s = secs.find((x) => x.section.id === id)?.section;
657
+ const { id: _omit, ...content } = (s ?? { id }) as { id: string } & Record<string, unknown>;
658
+ return content as unknown as SectionContent;
659
+ },
660
+ }),
661
+ [persistence, resolvedActivePage],
662
+ );
663
+
604
664
  const handleMoveSection = useCallback((sectionId: string, destPageId: string, position: "top" | "bottom") => {
605
665
  const next = moveSectionReducer(siteIndexRef.current, sectionId, destPageId, position);
606
666
  setSiteIndex(next);
@@ -819,7 +879,10 @@ export default function EditorShell({
819
879
  onPagesClick={() => setShowPagesModal(true)}
820
880
  />
821
881
 
822
- <BugReportFAB />
882
+ {/* Bug reports persist via Supabase. On the password-only (zero-Supabase)
883
+ path there's no backend and the editor session has userId: null, so
884
+ the button would always 401 — hide it there. */}
885
+ {!capabilities.passwordOnly && <BugReportFAB />}
823
886
 
824
887
  {rejectedUploads.length > 0 && (
825
888
  <div className="sticky top-2 z-30 mx-auto w-full max-w-3xl px-4">
@@ -845,6 +908,7 @@ export default function EditorShell({
845
908
  onAddSection={onAddSection}
846
909
  onDeleteSection={setPendingDeleteSectionId}
847
910
  onReorderSections={onReorderSections}
911
+ onBlockMove={onBlockMove}
848
912
  onMoveSection={siteIndex.pages.length > 1 ? setMovingSectionId : undefined}
849
913
  onAccessChange={onAccessChange}
850
914
  onStatusChange={onStatusChange}
@@ -1103,6 +1167,7 @@ function EditorContent({
1103
1167
  onAddSection,
1104
1168
  onDeleteSection,
1105
1169
  onReorderSections,
1170
+ onBlockMove,
1106
1171
  onMoveSection,
1107
1172
  onAccessChange,
1108
1173
  onStatusChange,
@@ -1119,6 +1184,7 @@ function EditorContent({
1119
1184
  onAddSection: (insertIndex: number, type: string) => void;
1120
1185
  onDeleteSection: (sectionId: string) => void;
1121
1186
  onReorderSections: (fromIndex: number, toIndex: number) => void;
1187
+ onBlockMove: (move: BlockMove) => void;
1122
1188
  onMoveSection?: (sectionId: string) => void;
1123
1189
  onAccessChange: (sectionId: string, access: string[]) => void;
1124
1190
  onStatusChange: (sectionId: string, status: "draft" | "live" | "archived") => void;
@@ -1127,17 +1193,19 @@ function EditorContent({
1127
1193
  viewSections: LoadedSection[] | null;
1128
1194
  }) {
1129
1195
  const { isEditMode, viewBranch } = useEditorContext();
1130
- const { openModal } = useEditorModal();
1196
+ const { openModal, closeModal } = useEditorModal();
1131
1197
  const [pendingInsertIndex, setPendingInsertIndex] = useState<number | null>(null);
1132
1198
  const dismissPendingInsert = useCallback(() => setPendingInsertIndex(null), []);
1133
1199
 
1134
1200
  const typeOptions = useMemo(
1135
1201
  () =>
1136
- getAllSections().map((def) => ({
1137
- type: def.type,
1138
- label: def.label,
1139
- icon: def.icon,
1140
- })),
1202
+ getAllSections()
1203
+ .filter((def) => def.type !== "spacer")
1204
+ .map((def) => ({
1205
+ type: def.type,
1206
+ label: def.label,
1207
+ icon: def.icon,
1208
+ })),
1141
1209
  [],
1142
1210
  );
1143
1211
 
@@ -1155,19 +1223,42 @@ function EditorContent({
1155
1223
  useEffect(() => {
1156
1224
  return combine(
1157
1225
  monitorForElements({
1158
- onDragStart: ({ source }) => {
1159
- if (source.data.dragType === "section") {
1160
- setPendingInsertIndex(null);
1226
+ canMonitor: ({ source }) => source.data.dragType === "block",
1227
+ onDragStart: () => setPendingInsertIndex(null),
1228
+ onDrop: ({ source, location }) => {
1229
+ const target = location.current.dropTargets[0];
1230
+ if (!target) return;
1231
+ const s = source.data as { blockId: string; containerId: string; index: number };
1232
+ const d = target.data as { dropContainerId: string; index: number; toColumn?: number };
1233
+ if (d.toColumn != null) {
1234
+ onBlockMove({
1235
+ blockId: s.blockId,
1236
+ fromContainerId: s.containerId,
1237
+ fromIndex: s.index,
1238
+ toContainerId: d.dropContainerId,
1239
+ toIndex: 0,
1240
+ toColumn: d.toColumn,
1241
+ });
1242
+ return;
1161
1243
  }
1244
+ const edge = extractClosestEdge(target.data);
1245
+ const toIndex = resolveDropIndex(d.index, edge);
1246
+ onBlockMove({
1247
+ blockId: s.blockId,
1248
+ fromContainerId: s.containerId,
1249
+ fromIndex: s.index,
1250
+ toContainerId: d.dropContainerId,
1251
+ toIndex,
1252
+ });
1162
1253
  },
1163
1254
  }),
1164
- // Gradually scroll the window when a section drag nears the viewport
1255
+ // Gradually scroll the window when a block drag nears the viewport
1165
1256
  // edge, so long reorders don't require repeated drag-and-release
1166
1257
  autoScrollWindowForElements({
1167
- canScroll: ({ source }) => source.data.dragType === "section",
1258
+ canScroll: ({ source }) => source.data.dragType === "block",
1168
1259
  }),
1169
1260
  );
1170
- }, []);
1261
+ }, [onBlockMove]);
1171
1262
 
1172
1263
  return (
1173
1264
  <div>
@@ -1208,6 +1299,8 @@ function EditorContent({
1208
1299
  dirty={dirtySectionIds.has(section.id)}
1209
1300
  index={index}
1210
1301
  isLast={index === displaySections.length - 1}
1302
+ containerId={ROOT_CONTAINER_ID}
1303
+ isContainerBlock={section.type === "container"}
1211
1304
  definition={definition}
1212
1305
  mainStatus={mainIndex?.sections[section.id]?.status ?? null}
1213
1306
  contentDiffersFromMain={changedSectionIds.has(section.id) || dirtySectionIds.has(section.id)}
@@ -1241,6 +1334,42 @@ function EditorContent({
1241
1334
  onMoveSection={editingEnabled && onMoveSection ? () => onMoveSection(section.id) : undefined}
1242
1335
  onRequestInsert={editingEnabled ? (i) => setPendingInsertIndex(i) : undefined}
1243
1336
  onDelete={editingEnabled ? () => onDeleteSection(section.id) : undefined}
1337
+ onAddChild={
1338
+ editingEnabled && section.type === "container"
1339
+ ? () => {
1340
+ openModal(
1341
+ "Add block",
1342
+ <SectionTypePicker
1343
+ types={childInsertableTypes()}
1344
+ onClose={closeModal}
1345
+ onSelect={(type) => {
1346
+ const def = getSection(type);
1347
+ if (def) {
1348
+ const id =
1349
+ typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
1350
+ ? crypto.randomUUID()
1351
+ : `child-${Date.now()}`;
1352
+ const child = { id, ...(def.defaults() as object) } as Section;
1353
+ const containerSection: Section = {
1354
+ id: section.id,
1355
+ type: "container",
1356
+ content: section.content as Record<string, unknown>,
1357
+ };
1358
+ const childCount =
1359
+ (section.content as { children?: unknown[] }).children?.length ?? 0;
1360
+ const next = insertChildAt(containerSection, child, childCount);
1361
+ onSectionChange(section.id, {
1362
+ ...section,
1363
+ content: next.content,
1364
+ } as SectionContent);
1365
+ }
1366
+ closeModal();
1367
+ }}
1368
+ />,
1369
+ );
1370
+ }
1371
+ : undefined
1372
+ }
1244
1373
  >
1245
1374
  <Component
1246
1375
  content={section}
@@ -1,10 +1,10 @@
1
1
  import { useState } from "react";
2
2
  import { EditorModal } from "./EditorModal";
3
3
  import { Button } from "../shared/Button";
4
+ import { Tabs } from "../shared/Tabs";
4
5
  import { SiteSettingsViewerAccess } from "./SiteSettingsViewerAccess";
5
6
  import { SiteSettingsDisplay } from "./SiteSettingsDisplay";
6
7
  import { SiteSettingsUsers } from "./SiteSettingsUsers";
7
- import { cn } from "../../lib/cn";
8
8
  import type { SiteConfig } from "../../schemas/site-config";
9
9
  import type { Audience } from "../../auth/types";
10
10
 
@@ -25,17 +25,7 @@ interface Props {
25
25
  currentUser: { email: string; role: "owner" | "editor" } | null;
26
26
  }
27
27
 
28
- type TabId = "users" | "viewer-access" | "display";
29
-
30
28
  export function SiteSettingsModal({ isOpen, onClose, siteConfig, onSiteConfigChange, onAudiencesChange, capabilities, currentUser }: Props) {
31
- const tabs: { id: TabId; label: string; show: boolean }[] = [
32
- { id: "users", label: "Users", show: capabilities.userManagement && currentUser?.role === "owner" },
33
- { id: "viewer-access", label: "Viewer Access", show: true },
34
- { id: "display", label: "Display", show: true },
35
- ];
36
-
37
- const visibleTabs = tabs.filter((t) => t.show);
38
- const [activeTab, setActiveTab] = useState<TabId>(visibleTabs[0]?.id ?? "display");
39
29
  const [signOutError, setSignOutError] = useState<string | null>(null);
40
30
 
41
31
  async function handleSignOut() {
@@ -58,49 +48,49 @@ export function SiteSettingsModal({ isOpen, onClose, siteConfig, onSiteConfigCha
58
48
  ? (currentUser.role === "owner" ? "Owner" : "Editor")
59
49
  : null;
60
50
 
51
+ const allTabs = [
52
+ {
53
+ id: "users",
54
+ label: "Users",
55
+ show: capabilities.userManagement && currentUser?.role === "owner",
56
+ content: (
57
+ <div data-testid="site-settings-tab-panel" className="flex flex-1 flex-col overflow-y-auto px-6 py-4">
58
+ <SiteSettingsUsers currentUser={currentUser} />
59
+ </div>
60
+ ),
61
+ },
62
+ {
63
+ id: "viewer-access",
64
+ label: "Viewer Access",
65
+ show: true,
66
+ content: (
67
+ <div data-testid="site-settings-tab-panel" className="flex flex-1 flex-col overflow-y-auto px-6 py-4">
68
+ <SiteSettingsViewerAccess
69
+ audienceManagement={capabilities.audienceManagement}
70
+ passwordToggle={capabilities.passwordToggle}
71
+ onAudiencesChange={onAudiencesChange}
72
+ />
73
+ </div>
74
+ ),
75
+ },
76
+ {
77
+ id: "display",
78
+ label: "Display",
79
+ show: true,
80
+ content: (
81
+ <div data-testid="site-settings-tab-panel" className="flex flex-1 flex-col overflow-y-auto px-6 py-4">
82
+ <SiteSettingsDisplay siteConfig={siteConfig} onChange={onSiteConfigChange} />
83
+ </div>
84
+ ),
85
+ },
86
+ ];
87
+
88
+ const tabItems = allTabs.filter((t) => t.show).map(({ id, label, content }) => ({ id, label, content }));
89
+
61
90
  return (
62
91
  <EditorModal isOpen={isOpen} onClose={onClose} title="Site Settings" size="settings" noPadding>
63
92
  <div className="flex flex-1 flex-col overflow-hidden">
64
- <div className="border-b border-base-200">
65
- <div className="flex px-6" role="tablist">
66
- {visibleTabs.map((tab) => (
67
- <button
68
- key={tab.id}
69
- role="tab"
70
- aria-selected={activeTab === tab.id}
71
- onClick={() => setActiveTab(tab.id)}
72
- className={cn(
73
- "cursor-pointer px-4 py-2 text-sm font-medium border-b-2 -mb-px",
74
- activeTab === tab.id
75
- ? "border-brand text-brand"
76
- : "border-transparent text-base-contrast-light hover:text-base-contrast",
77
- )}
78
- >
79
- {tab.label}
80
- </button>
81
- ))}
82
- </div>
83
- </div>
84
-
85
- <div
86
- data-testid="site-settings-tab-panel"
87
- className="flex flex-1 flex-col overflow-y-auto px-6 py-4"
88
- >
89
- {activeTab === "users" && <SiteSettingsUsers currentUser={currentUser} />}
90
- {activeTab === "viewer-access" && (
91
- <SiteSettingsViewerAccess
92
- audienceManagement={capabilities.audienceManagement}
93
- passwordToggle={capabilities.passwordToggle}
94
- onAudiencesChange={onAudiencesChange}
95
- />
96
- )}
97
- {activeTab === "display" && (
98
- <SiteSettingsDisplay
99
- siteConfig={siteConfig}
100
- onChange={onSiteConfigChange}
101
- />
102
- )}
103
- </div>
93
+ <Tabs tabs={tabItems} />
104
94
 
105
95
  {currentUser && (
106
96
  <div className="flex items-center justify-between gap-3 border-t border-base-200 px-6 py-3">
@@ -0,0 +1,40 @@
1
+ import { applyBlockMove, type BlockMove, type BlockMoveState } from "../../lib/block-move";
2
+ import type { LoadedSection } from "../../lib/loader";
3
+ import type { SiteIndex } from "../../schemas/site-config";
4
+ import type { SectionContent } from "../../schemas/sections";
5
+
6
+ export interface BlockMoveDispatchDeps {
7
+ getState: () => BlockMoveState;
8
+ setSections: (updater: (prev: LoadedSection[]) => LoadedSection[]) => void;
9
+ setSiteIndex: (updater: (prev: SiteIndex) => SiteIndex) => void;
10
+ persistence: {
11
+ markSectionDirty: (id: string, content: SectionContent) => void;
12
+ markIndexDirty: () => void;
13
+ removeSection: (id: string) => void;
14
+ };
15
+ isRemote: (id: string) => boolean;
16
+ scheduleDelete: (id: string) => void;
17
+ markLocalChanges: () => void;
18
+ /** Latest content for a section id, for markSectionDirty after the move. */
19
+ newSectionContent: (id: string, sections: LoadedSection[]) => SectionContent;
20
+ }
21
+
22
+ export function makeBlockMoveDispatch(deps: BlockMoveDispatchDeps) {
23
+ return function onBlockMove(move: BlockMove) {
24
+ const result = applyBlockMove(deps.getState(), move);
25
+ if (!result.changed) return;
26
+
27
+ deps.setSections(() => result.sections);
28
+ deps.setSiteIndex(() => result.index);
29
+
30
+ for (const id of result.effects.deleteSectionIds) {
31
+ if (deps.isRemote(id)) deps.scheduleDelete(id);
32
+ deps.persistence.removeSection(id);
33
+ }
34
+ for (const id of result.effects.markDirtySectionIds) {
35
+ deps.persistence.markSectionDirty(id, deps.newSectionContent(id, result.sections));
36
+ }
37
+ if (result.effects.indexDirty) deps.persistence.markIndexDirty();
38
+ deps.markLocalChanges();
39
+ };
40
+ }