@drawnagency/primitives 0.1.56 → 0.1.58

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 (165) hide show
  1. package/dist/adapter-HH47ZPGM.js +1779 -0
  2. package/dist/auth/index.js +1 -0
  3. package/dist/{chunk-EU6NZ4GS.js → chunk-AN62WPW7.js} +22 -163
  4. package/dist/chunk-ESE5UBQI.js +73 -0
  5. package/dist/{chunk-KGYWQDBB.js → chunk-ICLXLWQ5.js} +9 -72
  6. package/dist/chunk-JSBRDJBE.js +30 -0
  7. package/dist/chunk-NSCT3AMV.js +32 -0
  8. package/dist/chunk-RFZNNCAS.js +160 -0
  9. package/dist/chunk-TG43X7JO.js +123 -0
  10. package/dist/{chunk-7IAWF7LE.js → chunk-V7JN2DDU.js} +2 -19
  11. package/dist/chunk-VKAGMEKE.js +90 -0
  12. package/dist/chunk-ZU2MKPTG.js +29 -0
  13. package/dist/closest-edge-EBOXL3YW.js +72 -0
  14. package/dist/components/editor/ChildBlockWrapper.d.ts +19 -0
  15. package/dist/components/editor/ChildBlockWrapper.d.ts.map +1 -0
  16. package/dist/components/editor/ColSpanControl.d.ts +9 -0
  17. package/dist/components/editor/ColSpanControl.d.ts.map +1 -0
  18. package/dist/components/editor/SectionWrapper.d.ts +1 -1
  19. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  20. package/dist/components/editor/SettingsForm.d.ts +5 -1
  21. package/dist/components/editor/SettingsForm.d.ts.map +1 -1
  22. package/dist/components/primitives/EditableGrid.d.ts.map +1 -1
  23. package/dist/components/primitives/IconPicker.d.ts +7 -1
  24. package/dist/components/primitives/IconPicker.d.ts.map +1 -1
  25. package/dist/components/sections/Container/Container.d.ts +20 -0
  26. package/dist/components/sections/Container/Container.d.ts.map +1 -0
  27. package/dist/components/sections/Container/ContainerSettingsForm.d.ts +17 -0
  28. package/dist/components/sections/Container/ContainerSettingsForm.d.ts.map +1 -0
  29. package/dist/components/sections/Container/index.d.ts +11 -0
  30. package/dist/components/sections/Container/index.d.ts.map +1 -0
  31. package/dist/components/sections/IconList/IconList.d.ts +1 -0
  32. package/dist/components/sections/IconList/IconList.d.ts.map +1 -1
  33. package/dist/components/sections/IconList/IconListSettings.d.ts +3 -4
  34. package/dist/components/sections/IconList/IconListSettings.d.ts.map +1 -1
  35. package/dist/components/sections/IconList/index.d.ts +1 -0
  36. package/dist/components/sections/IconList/index.d.ts.map +1 -1
  37. package/dist/components/sections/Media/MediaBlock.d.ts +19 -0
  38. package/dist/components/sections/Media/MediaBlock.d.ts.map +1 -0
  39. package/dist/components/sections/{MediaGrid → Media}/index.d.ts +15 -25
  40. package/dist/components/sections/Media/index.d.ts.map +1 -0
  41. package/dist/components/sections/Prose/index.d.ts.map +1 -1
  42. package/dist/components/sections/Spacer/Spacer.d.ts +2 -0
  43. package/dist/components/sections/Spacer/Spacer.d.ts.map +1 -0
  44. package/dist/components/sections/Spacer/index.d.ts +6 -0
  45. package/dist/components/sections/Spacer/index.d.ts.map +1 -0
  46. package/dist/components/sections/all-sections.d.ts +29 -103
  47. package/dist/components/sections/all-sections.d.ts.map +1 -1
  48. package/dist/components/sections/register-schemas.d.ts.map +1 -1
  49. package/dist/components/sections/register-schemas.js +4094 -0
  50. package/dist/components/shared/Tabs.d.ts +24 -0
  51. package/dist/components/shared/Tabs.d.ts.map +1 -0
  52. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  53. package/dist/components/shell/SiteSettingsModal.d.ts.map +1 -1
  54. package/dist/components/shell/blockMoveDispatch.d.ts +21 -0
  55. package/dist/components/shell/blockMoveDispatch.d.ts.map +1 -0
  56. package/dist/hooks/useBlockDnd.d.ts +48 -0
  57. package/dist/hooks/useBlockDnd.d.ts.map +1 -0
  58. package/dist/index.js +69 -56
  59. package/dist/lib/block-dnd.d.ts +42 -0
  60. package/dist/lib/block-dnd.d.ts.map +1 -0
  61. package/dist/lib/block-move.d.ts +31 -0
  62. package/dist/lib/block-move.d.ts.map +1 -0
  63. package/dist/lib/container-grid.d.ts +29 -0
  64. package/dist/lib/container-grid.d.ts.map +1 -0
  65. package/dist/lib/container-ops.d.ts +44 -0
  66. package/dist/lib/container-ops.d.ts.map +1 -0
  67. package/dist/lib/dexie.d.ts.map +1 -1
  68. package/dist/lib/dexie.js +15 -0
  69. package/dist/lib/env.js +1 -0
  70. package/dist/lib/index.js +19 -13
  71. package/dist/lib/loader.d.ts.map +1 -1
  72. package/dist/lib/migrate-sections-transform.d.ts +12 -0
  73. package/dist/lib/migrate-sections-transform.d.ts.map +1 -0
  74. package/dist/lib/migrate-sections-transform.js +7 -0
  75. package/dist/lib/registry.d.ts +39 -0
  76. package/dist/lib/registry.d.ts.map +1 -1
  77. package/dist/lib/registry.js +27 -0
  78. package/dist/media/index.js +1 -0
  79. package/dist/schemas/auth.js +1 -0
  80. package/dist/schemas/block.d.ts +20 -0
  81. package/dist/schemas/block.d.ts.map +1 -0
  82. package/dist/schemas/block.js +15 -0
  83. package/dist/schemas/index.js +13 -4
  84. package/dist/schemas/link.d.ts +7 -0
  85. package/dist/schemas/link.d.ts.map +1 -1
  86. package/dist/schemas/rich-text.d.ts +9 -0
  87. package/dist/schemas/rich-text.d.ts.map +1 -0
  88. package/dist/schemas/sections.d.ts +2 -0
  89. package/dist/schemas/sections.d.ts.map +1 -1
  90. package/dist/schemas/shared.d.ts +30 -0
  91. package/dist/schemas/shared.d.ts.map +1 -1
  92. package/dist/types/database.js +2 -0
  93. package/package.json +17 -1
  94. package/src/components/brandguide/Colors.tsx +35 -33
  95. package/src/components/editor/ChildBlockWrapper.tsx +108 -0
  96. package/src/components/editor/ColSpanControl.tsx +56 -0
  97. package/src/components/editor/SectionWrapper.tsx +44 -20
  98. package/src/components/editor/SettingsForm.tsx +100 -73
  99. package/src/components/primitives/EditableGrid.tsx +40 -36
  100. package/src/components/primitives/IconPicker.tsx +116 -26
  101. package/src/components/sections/Container/Container.tsx +354 -0
  102. package/src/components/sections/Container/ContainerSettingsForm.tsx +113 -0
  103. package/src/components/sections/Container/index.tsx +51 -0
  104. package/src/components/sections/IconList/IconList.tsx +113 -43
  105. package/src/components/sections/IconList/IconListSettings.tsx +2 -2
  106. package/src/components/sections/IconList/index.tsx +1 -1
  107. package/src/components/sections/Media/MediaBlock.tsx +103 -0
  108. package/src/components/sections/Media/index.tsx +85 -0
  109. package/src/components/sections/Prose/index.tsx +1 -0
  110. package/src/components/sections/Spacer/Spacer.tsx +6 -0
  111. package/src/components/sections/Spacer/index.tsx +18 -0
  112. package/src/components/sections/all-sections.ts +10 -8
  113. package/src/components/sections/register-schemas.ts +5 -2
  114. package/src/components/shared/Tabs.tsx +63 -0
  115. package/src/components/shell/EditorShell.tsx +105 -13
  116. package/src/components/shell/SiteSettingsModal.tsx +41 -51
  117. package/src/components/shell/blockMoveDispatch.ts +40 -0
  118. package/src/hooks/useBlockDnd.ts +144 -0
  119. package/src/lib/block-dnd.ts +58 -0
  120. package/src/lib/block-move.ts +236 -0
  121. package/src/lib/container-grid.ts +58 -0
  122. package/src/lib/container-ops.ts +159 -0
  123. package/src/lib/dexie.ts +22 -0
  124. package/src/lib/loader.ts +16 -4
  125. package/src/lib/migrate-sections-transform.ts +147 -0
  126. package/src/lib/registry.ts +48 -0
  127. package/src/schemas/block.ts +40 -0
  128. package/src/schemas/link.ts +19 -1
  129. package/src/schemas/rich-text.ts +11 -0
  130. package/src/schemas/sections.ts +5 -1
  131. package/src/schemas/shared.ts +6 -0
  132. package/dist/components/brandguide/DoDontList.d.ts +0 -16
  133. package/dist/components/brandguide/DoDontList.d.ts.map +0 -1
  134. package/dist/components/brandguide/DoDontMediaGrid.d.ts +0 -16
  135. package/dist/components/brandguide/DoDontMediaGrid.d.ts.map +0 -1
  136. package/dist/components/primitives/MediaSettingsForms.d.ts +0 -23
  137. package/dist/components/primitives/MediaSettingsForms.d.ts.map +0 -1
  138. package/dist/components/sections/DoDontList/index.d.ts +0 -21
  139. package/dist/components/sections/DoDontList/index.d.ts.map +0 -1
  140. package/dist/components/sections/DoDontMediaGrid/index.d.ts +0 -55
  141. package/dist/components/sections/DoDontMediaGrid/index.d.ts.map +0 -1
  142. package/dist/components/sections/MediaGrid/MediaGrid.d.ts +0 -17
  143. package/dist/components/sections/MediaGrid/MediaGrid.d.ts.map +0 -1
  144. package/dist/components/sections/MediaGrid/index.d.ts.map +0 -1
  145. package/dist/components/sections/SplitContent/SplitContent.d.ts +0 -14
  146. package/dist/components/sections/SplitContent/SplitContent.d.ts.map +0 -1
  147. package/dist/components/sections/SplitContent/index.d.ts +0 -13
  148. package/dist/components/sections/SplitContent/index.d.ts.map +0 -1
  149. package/src/components/brandguide/DoDontList.d.ts.map +0 -1
  150. package/src/components/brandguide/DoDontList.tsx +0 -67
  151. package/src/components/brandguide/DoDontMediaGrid.d.ts.map +0 -1
  152. package/src/components/brandguide/DoDontMediaGrid.tsx +0 -19
  153. package/src/components/primitives/MediaSettingsForms.tsx +0 -128
  154. package/src/components/sections/DoDontList/index.d.ts.map +0 -1
  155. package/src/components/sections/DoDontList/index.tsx +0 -45
  156. package/src/components/sections/DoDontMediaGrid/index.d.ts.map +0 -1
  157. package/src/components/sections/DoDontMediaGrid/index.tsx +0 -63
  158. package/src/components/sections/MediaGrid/MediaGrid.d.ts.map +0 -1
  159. package/src/components/sections/MediaGrid/MediaGrid.tsx +0 -239
  160. package/src/components/sections/MediaGrid/index.d.ts.map +0 -1
  161. package/src/components/sections/MediaGrid/index.tsx +0 -57
  162. package/src/components/sections/SplitContent/SplitContent.d.ts.map +0 -1
  163. package/src/components/sections/SplitContent/SplitContent.tsx +0 -84
  164. package/src/components/sections/SplitContent/index.d.ts.map +0 -1
  165. package/src/components/sections/SplitContent/index.tsx +0 -55
@@ -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";
@@ -145,6 +151,8 @@ export default function EditorShell({
145
151
  // doesn't enqueue a GitHub file deletion for a path that was never committed.
146
152
  const remoteSectionIdsRef = useRef<Set<string>>(new Set());
147
153
  const fontLinkRef = useRef<HTMLLinkElement | null>(null);
154
+ const sectionsRef = useRef<LoadedSection[]>([]);
155
+ useEffect(() => { sectionsRef.current = sections; }, [sections]);
148
156
  useEffect(() => { siteIndexRef.current = siteIndex; }, [siteIndex]);
149
157
  useEffect(() => { void ensureSanitizer(); }, []);
150
158
 
@@ -635,6 +643,24 @@ export default function EditorShell({
635
643
  [persistence, resolvedActivePage],
636
644
  );
637
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
+
638
664
  const handleMoveSection = useCallback((sectionId: string, destPageId: string, position: "top" | "bottom") => {
639
665
  const next = moveSectionReducer(siteIndexRef.current, sectionId, destPageId, position);
640
666
  setSiteIndex(next);
@@ -882,6 +908,7 @@ export default function EditorShell({
882
908
  onAddSection={onAddSection}
883
909
  onDeleteSection={setPendingDeleteSectionId}
884
910
  onReorderSections={onReorderSections}
911
+ onBlockMove={onBlockMove}
885
912
  onMoveSection={siteIndex.pages.length > 1 ? setMovingSectionId : undefined}
886
913
  onAccessChange={onAccessChange}
887
914
  onStatusChange={onStatusChange}
@@ -1140,6 +1167,7 @@ function EditorContent({
1140
1167
  onAddSection,
1141
1168
  onDeleteSection,
1142
1169
  onReorderSections,
1170
+ onBlockMove,
1143
1171
  onMoveSection,
1144
1172
  onAccessChange,
1145
1173
  onStatusChange,
@@ -1156,6 +1184,7 @@ function EditorContent({
1156
1184
  onAddSection: (insertIndex: number, type: string) => void;
1157
1185
  onDeleteSection: (sectionId: string) => void;
1158
1186
  onReorderSections: (fromIndex: number, toIndex: number) => void;
1187
+ onBlockMove: (move: BlockMove) => void;
1159
1188
  onMoveSection?: (sectionId: string) => void;
1160
1189
  onAccessChange: (sectionId: string, access: string[]) => void;
1161
1190
  onStatusChange: (sectionId: string, status: "draft" | "live" | "archived") => void;
@@ -1164,17 +1193,19 @@ function EditorContent({
1164
1193
  viewSections: LoadedSection[] | null;
1165
1194
  }) {
1166
1195
  const { isEditMode, viewBranch } = useEditorContext();
1167
- const { openModal } = useEditorModal();
1196
+ const { openModal, closeModal } = useEditorModal();
1168
1197
  const [pendingInsertIndex, setPendingInsertIndex] = useState<number | null>(null);
1169
1198
  const dismissPendingInsert = useCallback(() => setPendingInsertIndex(null), []);
1170
1199
 
1171
1200
  const typeOptions = useMemo(
1172
1201
  () =>
1173
- getAllSections().map((def) => ({
1174
- type: def.type,
1175
- label: def.label,
1176
- icon: def.icon,
1177
- })),
1202
+ getAllSections()
1203
+ .filter((def) => def.type !== "spacer")
1204
+ .map((def) => ({
1205
+ type: def.type,
1206
+ label: def.label,
1207
+ icon: def.icon,
1208
+ })),
1178
1209
  [],
1179
1210
  );
1180
1211
 
@@ -1192,19 +1223,42 @@ function EditorContent({
1192
1223
  useEffect(() => {
1193
1224
  return combine(
1194
1225
  monitorForElements({
1195
- onDragStart: ({ source }) => {
1196
- if (source.data.dragType === "section") {
1197
- 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;
1198
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
+ });
1199
1253
  },
1200
1254
  }),
1201
- // Gradually scroll the window when a section drag nears the viewport
1255
+ // Gradually scroll the window when a block drag nears the viewport
1202
1256
  // edge, so long reorders don't require repeated drag-and-release
1203
1257
  autoScrollWindowForElements({
1204
- canScroll: ({ source }) => source.data.dragType === "section",
1258
+ canScroll: ({ source }) => source.data.dragType === "block",
1205
1259
  }),
1206
1260
  );
1207
- }, []);
1261
+ }, [onBlockMove]);
1208
1262
 
1209
1263
  return (
1210
1264
  <div>
@@ -1245,6 +1299,8 @@ function EditorContent({
1245
1299
  dirty={dirtySectionIds.has(section.id)}
1246
1300
  index={index}
1247
1301
  isLast={index === displaySections.length - 1}
1302
+ containerId={ROOT_CONTAINER_ID}
1303
+ isContainerBlock={section.type === "container"}
1248
1304
  definition={definition}
1249
1305
  mainStatus={mainIndex?.sections[section.id]?.status ?? null}
1250
1306
  contentDiffersFromMain={changedSectionIds.has(section.id) || dirtySectionIds.has(section.id)}
@@ -1278,6 +1334,42 @@ function EditorContent({
1278
1334
  onMoveSection={editingEnabled && onMoveSection ? () => onMoveSection(section.id) : undefined}
1279
1335
  onRequestInsert={editingEnabled ? (i) => setPendingInsertIndex(i) : undefined}
1280
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
+ }
1281
1373
  >
1282
1374
  <Component
1283
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
+ }
@@ -0,0 +1,144 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
3
+ import { buildBlockDragData, buildBlockDropData, isBlockDragData, canDropBlock } from "../lib/block-dnd";
4
+
5
+ type AllowedEdges = Edge[];
6
+
7
+ export interface UseBlockDndArgs {
8
+ blockId: string;
9
+ containerId: string;
10
+ index: number;
11
+ isContainer: boolean;
12
+ /** ["left","right"] inside a row container, ["top","bottom"] at root */
13
+ allowedEdges: AllowedEdges;
14
+ enabled: boolean;
15
+ }
16
+
17
+ /**
18
+ * Wires a block as a unified drag source + drop target. Manages only this block's
19
+ * local hover edge + dragging flag — the actual move dispatch is centralized in the
20
+ * EditorContent monitor (a later task), so nested targets never double-fire. The edge
21
+ * indicator is shown only when this element is the innermost drop target.
22
+ *
23
+ * Callers must pass a stable `allowedEdges` array (module-level constant or memoized)
24
+ * to avoid re-subscribing on every render.
25
+ */
26
+ export function useBlockDnd({ blockId, containerId, index, isContainer, allowedEdges, enabled }: UseBlockDndArgs) {
27
+ const dragRef = useRef<HTMLDivElement>(null);
28
+ const handleRef = useRef<HTMLButtonElement>(null);
29
+ const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
30
+ const [isDragging, setIsDragging] = useState(false);
31
+
32
+ useEffect(() => {
33
+ const element = dragRef.current;
34
+ const handle = handleRef.current;
35
+ if (!element || !enabled) return;
36
+
37
+ let cleanup: (() => void) | undefined;
38
+ let cancelled = false;
39
+
40
+ Promise.all([
41
+ import("@atlaskit/pragmatic-drag-and-drop/element/adapter"),
42
+ import("@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"),
43
+ ]).then(([{ draggable, dropTargetForElements }, { attachClosestEdge, extractClosestEdge }]) => {
44
+ if (cancelled) return;
45
+
46
+ const cleanupDraggable = draggable({
47
+ element,
48
+ dragHandle: handle ?? undefined,
49
+ getInitialData: () => buildBlockDragData({ blockId, containerId, index, isContainer }),
50
+ onGenerateDragPreview: () => {
51
+ element.style.opacity = "0.4";
52
+ requestAnimationFrame(() => { element.style.opacity = ""; });
53
+ },
54
+ onDragStart: () => setIsDragging(true),
55
+ onDrop: () => setIsDragging(false),
56
+ });
57
+
58
+ const cleanupDrop = dropTargetForElements({
59
+ element,
60
+ canDrop: ({ source }) => isBlockDragData(source.data) && canDropBlock(source.data, containerId),
61
+ getData: ({ input, element: el }) =>
62
+ attachClosestEdge(buildBlockDropData({ dropContainerId: containerId, index }), {
63
+ input, element: el, allowedEdges,
64
+ }),
65
+ onDrag: ({ self, source, location }) => {
66
+ const innermost = location.current.dropTargets[0]?.element === self.element;
67
+ if (!innermost || !isBlockDragData(source.data) || source.data.blockId === blockId) {
68
+ setClosestEdge(null);
69
+ return;
70
+ }
71
+ setClosestEdge(extractClosestEdge(self.data));
72
+ },
73
+ onDragLeave: () => setClosestEdge(null),
74
+ onDrop: () => setClosestEdge(null),
75
+ });
76
+
77
+ cleanup = () => { cleanupDraggable(); cleanupDrop(); };
78
+ });
79
+
80
+ return () => { cancelled = true; cleanup?.(); };
81
+ }, [blockId, containerId, index, isContainer, enabled, allowedEdges]);
82
+
83
+ return { dragRef, handleRef, closestEdge, isDragging };
84
+ }
85
+
86
+ export interface UseBlockDropZoneArgs {
87
+ containerId: string;
88
+ index: number;
89
+ /** When set, the zone is a column-targeted drop (ghost/spacer): place at this 1-based column. */
90
+ toColumn?: number;
91
+ allowedEdges: AllowedEdges;
92
+ enabled: boolean;
93
+ }
94
+
95
+ /**
96
+ * A drop-target-ONLY zone (not draggable) — used for an empty container's first-drop
97
+ * area, where there are no child cells to act as targets. It advertises
98
+ * `{ dropContainerId, index }` and shows an edge indicator only when it is the innermost
99
+ * drop target. The actual move dispatch is centralized in the EditorContent monitor.
100
+ *
101
+ * Callers must pass a stable `allowedEdges` array (module-level constant or memoized).
102
+ */
103
+ export function useBlockDropZone({ containerId, index, toColumn, allowedEdges, enabled }: UseBlockDropZoneArgs) {
104
+ const dropRef = useRef<HTMLDivElement>(null);
105
+ const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
106
+
107
+ useEffect(() => {
108
+ const element = dropRef.current;
109
+ if (!element || !enabled) return;
110
+
111
+ let cleanup: (() => void) | undefined;
112
+ let cancelled = false;
113
+
114
+ Promise.all([
115
+ import("@atlaskit/pragmatic-drag-and-drop/element/adapter"),
116
+ import("@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"),
117
+ ]).then(([{ dropTargetForElements }, { attachClosestEdge, extractClosestEdge }]) => {
118
+ if (cancelled) return;
119
+
120
+ cleanup = dropTargetForElements({
121
+ element,
122
+ canDrop: ({ source }) => isBlockDragData(source.data) && canDropBlock(source.data, containerId),
123
+ getData: ({ input, element: el }) =>
124
+ attachClosestEdge(buildBlockDropData({ dropContainerId: containerId, index, toColumn }), {
125
+ input, element: el, allowedEdges,
126
+ }),
127
+ onDrag: ({ self, source, location }) => {
128
+ const innermost = location.current.dropTargets[0]?.element === self.element;
129
+ if (!innermost || !isBlockDragData(source.data)) {
130
+ setClosestEdge(null);
131
+ return;
132
+ }
133
+ setClosestEdge(extractClosestEdge(self.data));
134
+ },
135
+ onDragLeave: () => setClosestEdge(null),
136
+ onDrop: () => setClosestEdge(null),
137
+ });
138
+ });
139
+
140
+ return () => { cancelled = true; cleanup?.(); };
141
+ }, [containerId, index, toColumn, enabled, allowedEdges]);
142
+
143
+ return { dropRef, closestEdge };
144
+ }
@@ -0,0 +1,58 @@
1
+ import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
2
+
3
+ /** The page root behaves like a single-column container (spec §3). */
4
+ export const ROOT_CONTAINER_ID = "__root__";
5
+
6
+ export interface BlockDragData {
7
+ dragType: "block";
8
+ blockId: string;
9
+ /** Container the block currently lives in: ROOT_CONTAINER_ID or a container section id. */
10
+ containerId: string;
11
+ index: number;
12
+ /** True if the dragged block is itself a container (used for the depth guard). */
13
+ isContainer: boolean;
14
+ [key: string | symbol]: unknown;
15
+ }
16
+
17
+ export interface BlockDropData {
18
+ dropContainerId: string;
19
+ index: number;
20
+ /** When set, a column-targeted drop (ghost/spacer cell): place at this 1-based grid column. */
21
+ toColumn?: number;
22
+ [key: string | symbol]: unknown;
23
+ }
24
+
25
+ export function buildBlockDragData(args: {
26
+ blockId: string;
27
+ containerId: string;
28
+ index: number;
29
+ isContainer: boolean;
30
+ }): BlockDragData {
31
+ return { dragType: "block", ...args };
32
+ }
33
+
34
+ export function buildBlockDropData(args: { dropContainerId: string; index: number; toColumn?: number }): BlockDropData {
35
+ return { ...args };
36
+ }
37
+
38
+ export function isBlockDragData(data: Record<string | symbol, unknown>): data is BlockDragData {
39
+ return data.dragType === "block";
40
+ }
41
+
42
+ /**
43
+ * May `source` drop into `dropContainerId`?
44
+ * - A container block may only land at the page root (v1 one-level nesting → no
45
+ * container-in-container; MAX_BLOCK_DEPTH=2).
46
+ * - Nothing may drop onto itself.
47
+ */
48
+ export function canDropBlock(source: BlockDragData, dropContainerId: string): boolean {
49
+ if (source.blockId === dropContainerId) return false;
50
+ if (source.isContainer && dropContainerId !== ROOT_CONTAINER_ID) return false;
51
+ return true;
52
+ }
53
+
54
+ /** Closest edge → insertion index (before = index, after = index + 1). */
55
+ export function resolveDropIndex(targetIndex: number, edge: Edge | null): number {
56
+ if (edge === "left" || edge === "top") return targetIndex;
57
+ return targetIndex + 1; // right / bottom / null
58
+ }