@drawnagency/primitives 0.1.48 → 0.1.50

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 (73) hide show
  1. package/dist/{chunk-P3HO76OS.js → chunk-DLXIYIG2.js} +6 -3
  2. package/dist/{chunk-5XYUO4HP.js → chunk-ICRCH3GI.js} +19 -2
  3. package/dist/{chunk-BJ6FYGYP.js → chunk-ONBJG426.js} +95 -9
  4. package/dist/components/editor/MoveSectionModal.d.ts +12 -0
  5. package/dist/components/editor/MoveSectionModal.d.ts.map +1 -0
  6. package/dist/components/editor/PagesModal.d.ts +18 -0
  7. package/dist/components/editor/PagesModal.d.ts.map +1 -0
  8. package/dist/components/editor/SectionWrapper.d.ts +1 -1
  9. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  10. package/dist/components/editor/SettingsForm.d.ts.map +1 -1
  11. package/dist/components/sections/Button/CTAButton.d.ts +3 -3
  12. package/dist/components/sections/Button/CTAButton.d.ts.map +1 -1
  13. package/dist/components/sections/Button/index.d.ts +10 -2
  14. package/dist/components/sections/Button/index.d.ts.map +1 -1
  15. package/dist/components/shared/Input.d.ts +1 -0
  16. package/dist/components/shared/Input.d.ts.map +1 -1
  17. package/dist/components/shared/LinkField.d.ts +9 -0
  18. package/dist/components/shared/LinkField.d.ts.map +1 -0
  19. package/dist/components/shared/Navigation.d.ts +4 -3
  20. package/dist/components/shared/Navigation.d.ts.map +1 -1
  21. package/dist/components/shared/PagesContext.d.ts +13 -0
  22. package/dist/components/shared/PagesContext.d.ts.map +1 -0
  23. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  24. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  25. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  26. package/dist/index.js +17 -3
  27. package/dist/lib/dexie.d.ts +1 -0
  28. package/dist/lib/dexie.d.ts.map +1 -1
  29. package/dist/lib/dexie.js +25 -3
  30. package/dist/lib/events.d.ts +3 -1
  31. package/dist/lib/events.d.ts.map +1 -1
  32. package/dist/lib/index.js +2 -2
  33. package/dist/lib/links.d.ts +14 -0
  34. package/dist/lib/links.d.ts.map +1 -0
  35. package/dist/lib/loader.d.ts +2 -2
  36. package/dist/lib/loader.d.ts.map +1 -1
  37. package/dist/lib/nav.d.ts +23 -0
  38. package/dist/lib/nav.d.ts.map +1 -1
  39. package/dist/lib/pages.d.ts +31 -0
  40. package/dist/lib/pages.d.ts.map +1 -0
  41. package/dist/lib/registry.d.ts +7 -0
  42. package/dist/lib/registry.d.ts.map +1 -1
  43. package/dist/schemas/index.d.ts +1 -0
  44. package/dist/schemas/index.d.ts.map +1 -1
  45. package/dist/schemas/index.js +17 -3
  46. package/dist/schemas/link.d.ts +24 -0
  47. package/dist/schemas/link.d.ts.map +1 -0
  48. package/dist/schemas/site-config.d.ts +128 -3
  49. package/dist/schemas/site-config.d.ts.map +1 -1
  50. package/package.json +5 -1
  51. package/src/components/editor/MoveSectionModal.tsx +38 -0
  52. package/src/components/editor/PagesModal.tsx +392 -0
  53. package/src/components/editor/SectionWrapper.tsx +13 -0
  54. package/src/components/editor/SettingsForm.tsx +12 -0
  55. package/src/components/sections/Button/CTAButton.tsx +10 -11
  56. package/src/components/sections/Button/index.tsx +4 -9
  57. package/src/components/shared/Input.tsx +14 -3
  58. package/src/components/shared/LinkField.tsx +87 -0
  59. package/src/components/shared/Navigation.tsx +131 -136
  60. package/src/components/shared/PagesContext.tsx +12 -0
  61. package/src/components/shell/EditorShell.tsx +291 -84
  62. package/src/hooks/useEditorPublish.ts +17 -9
  63. package/src/hooks/useMediaPipeline.ts +17 -5
  64. package/src/lib/dexie.ts +34 -5
  65. package/src/lib/events.ts +3 -1
  66. package/src/lib/links.ts +41 -0
  67. package/src/lib/loader.ts +5 -4
  68. package/src/lib/nav.ts +59 -0
  69. package/src/lib/pages.ts +209 -0
  70. package/src/lib/registry.ts +8 -0
  71. package/src/schemas/index.ts +1 -0
  72. package/src/schemas/link.ts +17 -0
  73. package/src/schemas/site-config.ts +113 -11
@@ -3,7 +3,7 @@ import { Fragment, useState, useCallback, useEffect, useRef, useMemo, type React
3
3
  import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
4
4
  import type { LoadedSection } from "../../lib/loader";
5
5
  import type { SectionContent } from "../../schemas/sections";
6
- import type { SiteIndex, SiteConfig } from "../../schemas/site-config";
6
+ import type { SiteIndex, SiteConfig, Page } from "../../schemas/site-config";
7
7
  import type { Audience } from "../../auth/types";
8
8
  import type { MediaManifest } from "../../media/types";
9
9
  import type { QueueItem } from "../../media/queue";
@@ -24,6 +24,7 @@ ensureSectionsRegistered();
24
24
  import { BugReportFAB } from "./BugReportFAB";
25
25
  import { SectionWrapper } from "../editor/SectionWrapper";
26
26
  import { SectionOrderingModal } from "../editor/SectionOrderingModal";
27
+ import { MoveSectionModal } from "../editor/MoveSectionModal";
27
28
  import { SectionLayout } from "../sections/SectionLayout";
28
29
  import {
29
30
  initEditorStore,
@@ -44,8 +45,16 @@ import { useContentLifecycle } from "../../hooks/useContentLifecycle";
44
45
  import { useBuildStatus } from "../../hooks/useBuildStatus";
45
46
  import { useMediaPipeline } from "../../hooks/useMediaPipeline";
46
47
  import { formatTimestamp } from "../../lib/timestamp";
47
- import { generateNavLinks } from "../../lib/nav";
48
- import { navChangeEvent, darkModeEvent, historySelectEvent } from "../../lib/events";
48
+ import { generateSiteNav } from "../../lib/nav";
49
+ import { siteNavChangeEvent, pageSelectEvent, darkModeEvent, historySelectEvent } from "../../lib/events";
50
+ import {
51
+ homePage, pageById, pageBySlug, pageDisplayTitle, addSectionToPage, removeSectionFromPages,
52
+ reorderSectionInPage,
53
+ setHomePage, setPageArchived, setPageFields, setPageAudience, deletePage, addPage, reorderPages,
54
+ moveSection as moveSectionReducer,
55
+ } from "../../lib/pages";
56
+ import { PagesContext } from "../shared/PagesContext";
57
+ import { PagesModal } from "../editor/PagesModal";
49
58
  import { cn } from "../../lib/cn";
50
59
  import { UploadRejectionAlert, formatRejections } from "../shared/UploadRejectionAlert";
51
60
  import { Button } from "../shared/Button";
@@ -53,7 +62,7 @@ import { SplitButton } from "../shared/SplitButton";
53
62
  import { IconButton } from "../shared/IconButton";
54
63
  import { SegmentedControl } from "../shared/SegmentedControl";
55
64
  import { SettingsIcon } from "../shared/icons";
56
- import { ImageIcon, ListOrderedIcon, X } from "lucide-react";
65
+ import { ImageIcon, ListOrderedIcon, FilesIcon, X } from "lucide-react";
57
66
  import { ErrorBoundary } from "../shared/ErrorBoundary";
58
67
  import { HistoryToolbar } from "./HistoryToolbar";
59
68
  import { RestoreModal } from "./RestoreModal";
@@ -91,7 +100,7 @@ export default function EditorShell({
91
100
  }: Props) {
92
101
  const [shellState, setShellState] = useState<ShellState>({ phase: "loading-content" });
93
102
  const [sections, setSections] = useState<LoadedSection[]>([]);
94
- const [siteIndex, setSiteIndex] = useState<SiteIndex>({ siteId, order: [], sections: {} });
103
+ const [siteIndex, setSiteIndex] = useState<SiteIndex>({ siteId, pages: [], sections: {} });
95
104
  const [audiences, setAudiences] = useState<Audience[]>(initialAudiences);
96
105
  const [localChangesExist, setLocalChangesExist] = useState(false);
97
106
  const [showDiscardConfirm, setShowDiscardConfirm] = useState(false);
@@ -111,10 +120,13 @@ export default function EditorShell({
111
120
  const [viewSections, setViewSections] = useState<LoadedSection[] | null>(null);
112
121
  const [showRestoreModal, setShowRestoreModal] = useState(false);
113
122
  const [showOrderingModal, setShowOrderingModal] = useState(false);
123
+ const [showPagesModal, setShowPagesModal] = useState(false);
114
124
  const [isRestoring, setIsRestoring] = useState(false);
115
125
  const [rejectedUploads, setRejectedUploads] = useState<{ name: string; reason: "size" | "type" }[]>([]);
126
+ const [activePageId, setActivePageId] = useState<string | null>(null);
127
+ const [movingSectionId, setMovingSectionId] = useState<string | null>(null);
116
128
 
117
- const siteIndexRef = useRef<SiteIndex>({ siteId, order: [], sections: {} });
129
+ const siteIndexRef = useRef<SiteIndex>({ siteId, pages: [], sections: {} });
118
130
  // Ids of sections that exist on the remote (loaded from server, or persisted by
119
131
  // a prior save). Used so deleting a section that was only ever added locally
120
132
  // doesn't enqueue a GitHub file deletion for a path that was never committed.
@@ -125,6 +137,51 @@ export default function EditorShell({
125
137
 
126
138
  const persistence = useEditorPersistence(siteIndexRef);
127
139
 
140
+ const sectionsById = useMemo(() => {
141
+ const map: Record<string, LoadedSection> = {};
142
+ for (const s of sections) map[s.section.id] = s;
143
+ return map;
144
+ }, [sections]);
145
+
146
+ const activePage = activePageId ? pageById(siteIndex, activePageId) : null;
147
+ const resolvedActivePage = activePage ?? (siteIndex.pages.length ? homePage(siteIndex) : null);
148
+
149
+ const activeSections = useMemo(() => {
150
+ if (!resolvedActivePage) return [] as LoadedSection[];
151
+ return resolvedActivePage.order.map((id) => sectionsById[id]).filter((s): s is LoadedSection => !!s);
152
+ }, [resolvedActivePage, sectionsById]);
153
+
154
+ const pageAudiences = useMemo(() => {
155
+ if (!resolvedActivePage || resolvedActivePage.access.length === 0) return audiences;
156
+ return audiences.filter((a) => resolvedActivePage.access.includes(a.name));
157
+ }, [resolvedActivePage, audiences]);
158
+
159
+ const getPageHeadings = useCallback(
160
+ (pageId: string) => {
161
+ const page = pageById(siteIndex, pageId);
162
+ if (!page) return [] as { id: string; label: string }[];
163
+ // Offer nav-eligible headings only (spec §6: "the same nav-eligible
164
+ // headings") — skip excludeFromNav, matching generateNavLinks. Note: link
165
+ // *resolution* (headingById in buildViewerPage) is intentionally lenient and
166
+ // resolves any heading's anchor, so a stored link still works if the heading
167
+ // is later marked excludeFromNav.
168
+ return page.order
169
+ .map((id) => sectionsById[id])
170
+ .filter((s): s is LoadedSection => {
171
+ if (!s) return false;
172
+ const content = s.section.content as { heading?: string; excludeFromNav?: boolean };
173
+ return !!getSection(s.section.type)?.navRole && !!content.heading && !content.excludeFromNav;
174
+ })
175
+ .map((s) => ({ id: s.section.id, label: (s.section.content as { heading?: string }).heading! }));
176
+ },
177
+ [siteIndex, sectionsById],
178
+ );
179
+
180
+ const pagesContextValue = useMemo(
181
+ () => ({ pages: siteIndex.pages.map((p) => ({ id: p.id, title: pageDisplayTitle(p.title) })), getPageHeadings }),
182
+ [siteIndex.pages, getPageHeadings],
183
+ );
184
+
128
185
  const mediaPipeline = useMediaPipeline({
129
186
  siteConfig,
130
187
  mediaManifest,
@@ -152,7 +209,7 @@ export default function EditorShell({
152
209
  setDeletedSections(new Set());
153
210
  setLocalChangesExist(false);
154
211
  // Everything in the index now exists remotely (it was just saved).
155
- remoteSectionIdsRef.current = new Set(siteIndexRef.current.order);
212
+ remoteSectionIdsRef.current = new Set(siteIndexRef.current.pages.flatMap((p) => p.order));
156
213
  },
157
214
  mediaManifest,
158
215
  manifestDirty: mediaPipeline.manifestDirty,
@@ -160,12 +217,24 @@ export default function EditorShell({
160
217
  pendingMediaItems: mediaPipeline.pendingMediaItems,
161
218
  pendingMediaDeletions: mediaPipeline.pendingDeletions,
162
219
  onMediaPublished: (publishedItems, publishedDeletions) => {
163
- for (const url of Object.values(mediaPipeline.pendingLocalUrls)) {
164
- URL.revokeObjectURL(url);
165
- }
166
- mediaPipeline.setPendingMediaItems([]);
167
- mediaPipeline.setPendingLocalUrls({});
168
- mediaPipeline.setPendingDeletions([]);
220
+ // Only clear the items that were actually committed. Items skipped during
221
+ // the save (blobs not yet persisted) must keep their pending state and
222
+ // preview URL so the next save retries them — clearing them here while the
223
+ // manifest doesn't have them is what dropped images out and blocked re-adds.
224
+ const publishedIds = new Set(publishedItems.map((i) => i.id));
225
+ const deletionIds = new Set(publishedDeletions);
226
+ mediaPipeline.setPendingLocalUrls((prev) => {
227
+ const next = { ...prev };
228
+ for (const id of publishedIds) {
229
+ if (next[id]) {
230
+ URL.revokeObjectURL(next[id]);
231
+ delete next[id];
232
+ }
233
+ }
234
+ return next;
235
+ });
236
+ mediaPipeline.setPendingMediaItems((prev) => prev.filter((i) => !publishedIds.has(i.id)));
237
+ mediaPipeline.setPendingDeletions((prev) => prev.filter((d) => !deletionIds.has(d)));
169
238
  setMediaManifest((prev) => {
170
239
  const images = { ...prev.images };
171
240
  for (const item of publishedItems) {
@@ -190,11 +259,42 @@ export default function EditorShell({
190
259
  hasLocalChanges: localChangesExist,
191
260
  });
192
261
 
262
+ // Push the current SiteNav (active page + its headings) to the Navigation island.
193
263
  useEffect(() => {
194
- if (sections.length === 0) return;
195
- const navLinks = generateNavLinks(sections);
196
- navChangeEvent.dispatch(navLinks);
197
- }, [sections]);
264
+ if (!resolvedActivePage) return;
265
+ const nav = generateSiteNav({
266
+ index: siteIndex, activeSections, activePageId: resolvedActivePage.id, mode: "editor", audience: null,
267
+ });
268
+ siteNavChangeEvent.dispatch(nav);
269
+ }, [siteIndex, activeSections, resolvedActivePage]);
270
+
271
+ const goToPage = useCallback((pageId: string) => {
272
+ const page = pageById(siteIndexRef.current, pageId);
273
+ if (!page) return;
274
+ setActivePageId(pageId);
275
+ if (typeof window !== "undefined") {
276
+ const path = page.slug === "" ? "/edit" : `/edit/${page.slug}`;
277
+ if (window.location.pathname !== path) window.history.pushState({ pageId }, "", path);
278
+ }
279
+ }, []);
280
+
281
+ // Nav island → shell page selection.
282
+ useEffect(() => pageSelectEvent.listen((id) => goToPage(id)), [goToPage]);
283
+
284
+ // Back/forward maps the URL back to the active page (no reload).
285
+ useEffect(() => {
286
+ const onPop = (e: PopStateEvent) => {
287
+ const statePageId = e.state?.pageId as string | undefined;
288
+ const slug = window.location.pathname.replace(/^\/edit\/?/, "").replace(/\/+$/, "");
289
+ const page =
290
+ (statePageId ? pageById(siteIndexRef.current, statePageId) : undefined) ??
291
+ pageBySlug(siteIndexRef.current, slug) ??
292
+ homePage(siteIndexRef.current);
293
+ setActivePageId(page.id);
294
+ };
295
+ window.addEventListener("popstate", onPop);
296
+ return () => window.removeEventListener("popstate", onPop);
297
+ }, []);
198
298
 
199
299
  const applySiteConfigPreview = useCallback((config: SiteConfig) => {
200
300
  const root = document.documentElement;
@@ -277,7 +377,19 @@ export default function EditorShell({
277
377
  if (cancelled) return;
278
378
  setSections(loadedSections);
279
379
  setSiteIndex(loadedIndex);
280
- remoteSectionIdsRef.current = new Set(loadedIndex.order);
380
+ const statePageId = typeof window !== "undefined" ? (window.history.state?.pageId as string | undefined) : undefined;
381
+ const initialSlug = typeof window !== "undefined"
382
+ ? window.location.pathname.replace(/^\/edit\/?/, "").replace(/\/+$/, "")
383
+ : "";
384
+ // Prefer the page id stashed in history.state (survives in-session reloads
385
+ // and back/forward across unsaved slug renames); fall back to slug→page,
386
+ // then home. A hard reload of an unsaved-renamed slug resolves to home.
387
+ const initialPage =
388
+ (statePageId ? pageById(loadedIndex, statePageId) : undefined) ??
389
+ pageBySlug(loadedIndex, initialSlug) ??
390
+ homePage(loadedIndex);
391
+ setActivePageId(initialPage.id);
392
+ remoteSectionIdsRef.current = new Set(loadedIndex.pages.flatMap((p) => p.order));
281
393
  setSiteConfig(loadedConfig);
282
394
  setMediaManifest(loadedManifest);
283
395
  siteIndexRef.current = loadedIndex;
@@ -324,7 +436,7 @@ export default function EditorShell({
324
436
  if (restored.siteIndex) {
325
437
  const restoredIndex = restored.siteIndex;
326
438
  setSections(() =>
327
- restoredIndex.order.map((id) => {
439
+ restoredIndex.pages.flatMap((p) => p.order).map((id) => {
328
440
  const restoredContent = restored.sections[id];
329
441
  const existing = sections.find((s) => s.section.id === id);
330
442
  if (restoredContent) {
@@ -344,7 +456,7 @@ export default function EditorShell({
344
456
  // restored index was deleted locally before this reload. The deletion intent
345
457
  // isn't persisted directly, so reconstruct it here — otherwise the section's
346
458
  // GitHub file would be orphaned (index omits it, but it's never deleted).
347
- const restoredIds = new Set(restoredIndex.order);
459
+ const restoredIds = new Set(restoredIndex.pages.flatMap((p) => p.order));
348
460
  const rediscoveredDeletions = new Set(
349
461
  [...remoteSectionIdsRef.current].filter((id) => !restoredIds.has(id)),
350
462
  );
@@ -392,7 +504,7 @@ export default function EditorShell({
392
504
  setSections(cached.sections);
393
505
  setSiteIndex(cached.index);
394
506
  siteIndexRef.current = cached.index;
395
- remoteSectionIdsRef.current = new Set(cached.index.order);
507
+ remoteSectionIdsRef.current = new Set(cached.index.pages.flatMap((p) => p.order));
396
508
  const parsed = SiteConfigSchema.safeParse(cached.siteConfig);
397
509
  if (parsed.success) {
398
510
  setSiteConfig(parsed.data);
@@ -431,39 +543,21 @@ export default function EditorShell({
431
543
  const onAddSection = useCallback(
432
544
  (insertIndex: number, type: string) => {
433
545
  const definition = getSection(type);
434
- if (!definition) return;
435
-
546
+ if (!definition || !resolvedActivePage) return;
436
547
  const id = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
437
- ? crypto.randomUUID()
438
- : `_id_${Date.now()}_${Math.random().toString(36).slice(2)}`;
548
+ ? crypto.randomUUID() : `_id_${Date.now()}_${Math.random().toString(36).slice(2)}`;
439
549
  const content = definition.defaults() as object;
440
- const newSection: LoadedSection = {
441
- section: { id, ...content } as LoadedSection["section"],
442
- meta: { type, status: "draft", access: ["internal"] },
443
- };
444
-
445
- setSections((prev) => {
446
- const next = [...prev];
447
- next.splice(insertIndex, 0, newSection);
448
- return next;
449
- });
450
-
451
- setSiteIndex((prev) => {
452
- const newOrder = [...prev.order];
453
- newOrder.splice(insertIndex, 0, id);
454
- return {
455
- ...prev,
456
- order: newOrder,
457
- sections: { ...prev.sections, [id]: { type, status: "draft", access: ["internal"] } },
458
- };
459
- });
550
+ const meta = { type, status: "draft" as const, access: [] as string[] };
551
+ const newSection: LoadedSection = { section: { id, ...content } as LoadedSection["section"], meta };
460
552
 
553
+ setSections((prev) => [...prev, newSection]);
554
+ setSiteIndex((prev) => addSectionToPage(prev, resolvedActivePage.id, id, meta, insertIndex));
461
555
  persistence.markSectionDirty(id, content as SectionContent);
462
556
  persistence.markIndexDirty();
463
557
  setLocalChangesExist(true);
464
558
  setDirtySectionIds((prev) => new Set(prev).add(id));
465
559
  },
466
- [persistence],
560
+ [persistence, resolvedActivePage],
467
561
  );
468
562
 
469
563
  // Runs after the confirmation modal is accepted. Removes the section from the
@@ -472,15 +566,7 @@ export default function EditorShell({
472
566
  const performDeleteSection = useCallback(
473
567
  (sectionId: string) => {
474
568
  setSections((prev) => prev.filter((loaded) => loaded.section.id !== sectionId));
475
- setSiteIndex((prev) => {
476
- const nextSections = { ...prev.sections };
477
- delete nextSections[sectionId];
478
- return {
479
- ...prev,
480
- order: prev.order.filter((id) => id !== sectionId),
481
- sections: nextSections,
482
- };
483
- });
569
+ setSiteIndex((prev) => removeSectionFromPages(prev, sectionId));
484
570
  // Only schedule a GitHub file deletion if the section exists remotely. A
485
571
  // section that was added locally and never saved has no committed file, so
486
572
  // sending a deletion for it would target a non-existent path.
@@ -505,25 +591,77 @@ export default function EditorShell({
505
591
 
506
592
  const onReorderSections = useCallback(
507
593
  (fromIndex: number, toIndex: number) => {
508
- setSections((prev) => {
509
- const next = [...prev];
510
- const [moved] = next.splice(fromIndex, 1);
511
- next.splice(toIndex, 0, moved);
512
- return next;
513
- });
514
- setSiteIndex((prev) => {
515
- const newOrder = [...prev.order];
516
- const [movedId] = newOrder.splice(fromIndex, 1);
517
- newOrder.splice(toIndex, 0, movedId);
518
- return { ...prev, order: newOrder };
519
- });
520
-
594
+ if (!resolvedActivePage) return;
595
+ setSiteIndex((prev) => reorderSectionInPage(prev, resolvedActivePage.id, fromIndex, toIndex));
521
596
  persistence.markIndexDirty();
522
597
  setLocalChangesExist(true);
523
598
  },
524
- [persistence],
599
+ [persistence, resolvedActivePage],
525
600
  );
526
601
 
602
+ const handleMoveSection = useCallback((sectionId: string, destPageId: string, position: "top" | "bottom") => {
603
+ const next = moveSectionReducer(siteIndexRef.current, sectionId, destPageId, position);
604
+ setSiteIndex(next);
605
+ const meta = next.sections[sectionId];
606
+ if (meta) setSections((prev) => prev.map((s) => (s.section.id === sectionId ? { ...s, meta } : s)));
607
+ persistence.markIndexDirty();
608
+ setLocalChangesExist(true);
609
+ setMovingSectionId(null);
610
+ }, [persistence]);
611
+
612
+ const handleAddPage = useCallback(() => {
613
+ const id = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `page_${Date.now()}`;
614
+ setSiteIndex((prev) => addPage(prev, id));
615
+ persistence.markIndexDirty();
616
+ setLocalChangesExist(true);
617
+ return id;
618
+ }, [persistence]);
619
+
620
+ const handleReorderPages = useCallback((from: number, to: number) => {
621
+ setSiteIndex((prev) => reorderPages(prev, from, to));
622
+ persistence.markIndexDirty(); setLocalChangesExist(true);
623
+ }, [persistence]);
624
+
625
+ const handleSetHome = useCallback((id: string) => {
626
+ setSiteIndex((prev) => setHomePage(prev, id));
627
+ persistence.markIndexDirty(); setLocalChangesExist(true);
628
+ }, [persistence]);
629
+
630
+ const handleSetArchived = useCallback((id: string, archived: boolean) => {
631
+ setSiteIndex((prev) => setPageArchived(prev, id, archived));
632
+ persistence.markIndexDirty(); setLocalChangesExist(true);
633
+ }, [persistence]);
634
+
635
+ const handleSetPageFields = useCallback((id: string, patch: Partial<Pick<Page, "title" | "slug" | "showInNav">>) => {
636
+ setSiteIndex((prev) => setPageFields(prev, id, patch));
637
+ persistence.markIndexDirty(); setLocalChangesExist(true);
638
+ }, [persistence]);
639
+
640
+ const handleSetPageAudience = useCallback((id: string, access: string[]) => {
641
+ const { index: next, resetSectionIds } = setPageAudience(siteIndexRef.current, id, access);
642
+ setSiteIndex(next);
643
+ if (resetSectionIds.length > 0) {
644
+ setSections((prev) => prev.map((s) =>
645
+ resetSectionIds.includes(s.section.id) ? { ...s, meta: { ...s.meta, access: [] } } : s));
646
+ }
647
+ persistence.markIndexDirty(); setLocalChangesExist(true);
648
+ }, [persistence]);
649
+
650
+ const handleDeletePage = useCallback((id: string) => {
651
+ const { index: next, removedSectionIds } = deletePage(siteIndexRef.current, id);
652
+ setSiteIndex(next);
653
+ setSections((prev) => prev.filter((s) => !removedSectionIds.includes(s.section.id)));
654
+ for (const sid of removedSectionIds) {
655
+ if (remoteSectionIdsRef.current.has(sid)) setDeletedSections((prev) => new Set(prev).add(sid));
656
+ persistence.removeSection(sid);
657
+ }
658
+ persistence.markIndexDirty(); setLocalChangesExist(true);
659
+ if (resolvedActivePage?.id === id) {
660
+ const home = next.pages.find((p) => p.isHome);
661
+ if (home) goToPage(home.id);
662
+ }
663
+ }, [persistence, resolvedActivePage, goToPage]);
664
+
527
665
  const onAccessChange = useCallback(
528
666
  (sectionId: string, access: string[]) => {
529
667
  setSections((prev) =>
@@ -639,6 +777,7 @@ export default function EditorShell({
639
777
  }
640
778
 
641
779
  return (
780
+ <PagesContext.Provider value={pagesContextValue}>
642
781
  <EditorProvider>
643
782
  <ViewBranchWatcher
644
783
  setViewSections={setViewSections}
@@ -675,6 +814,7 @@ export default function EditorShell({
675
814
  onBuildDismiss={buildStatus.dismiss}
676
815
  onRestoreClick={() => setShowRestoreModal(true)}
677
816
  onOrderingClick={() => setShowOrderingModal(true)}
817
+ onPagesClick={() => setShowPagesModal(true)}
678
818
  />
679
819
 
680
820
  <BugReportFAB />
@@ -688,16 +828,22 @@ export default function EditorShell({
688
828
  </div>
689
829
  )}
690
830
 
691
- <HistoryOrEditorContent sections={sections}>
831
+ <HistoryOrEditorContent
832
+ index={siteIndex}
833
+ activeSections={activeSections}
834
+ activePageId={resolvedActivePage?.id ?? null}
835
+ >
692
836
  <EditorContent
693
- sections={sections}
694
- audiences={audiences}
837
+ sections={activeSections}
838
+ activePageSectionIds={resolvedActivePage?.order ?? []}
839
+ audiences={pageAudiences}
695
840
  dirtySectionIds={dirtySectionIds}
696
841
  isPublishing={publishAction !== "idle"}
697
842
  onSectionChange={onSectionChange}
698
843
  onAddSection={onAddSection}
699
844
  onDeleteSection={setPendingDeleteSectionId}
700
845
  onReorderSections={onReorderSections}
846
+ onMoveSection={siteIndex.pages.length > 1 ? setMovingSectionId : undefined}
701
847
  onAccessChange={onAccessChange}
702
848
  onStatusChange={onStatusChange}
703
849
  mainIndex={mainIndex}
@@ -786,11 +932,38 @@ export default function EditorShell({
786
932
  size="settings"
787
933
  >
788
934
  <SectionOrderingModal
789
- sections={sections}
935
+ sections={activeSections}
790
936
  mediaManifest={mediaManifest}
791
937
  onReorder={onReorderSections}
792
938
  />
793
939
  </EditorModal>
940
+ <EditorModal isOpen={showPagesModal} onClose={() => setShowPagesModal(false)} title="Pages" size="settings">
941
+ <PagesModal
942
+ index={siteIndex}
943
+ audiences={audiences}
944
+ onReorder={handleReorderPages}
945
+ onAddPage={handleAddPage}
946
+ onSetHome={handleSetHome}
947
+ onSetArchived={handleSetArchived}
948
+ onSetFields={handleSetPageFields}
949
+ onSetAudience={handleSetPageAudience}
950
+ onConfirmDelete={handleDeletePage}
951
+ onNavigate={(id) => {
952
+ goToPage(id);
953
+ setShowPagesModal(false);
954
+ }}
955
+ />
956
+ </EditorModal>
957
+ <EditorModal isOpen={movingSectionId !== null} onClose={() => setMovingSectionId(null)} title="Move to page">
958
+ {movingSectionId && resolvedActivePage && (
959
+ <MoveSectionModal
960
+ pages={siteIndex.pages.map((p) => ({ id: p.id, title: pageDisplayTitle(p.title) }))}
961
+ currentPageId={resolvedActivePage.id}
962
+ onMove={(destId, position) => handleMoveSection(movingSectionId, destId, position)}
963
+ onCancel={() => setMovingSectionId(null)}
964
+ />
965
+ )}
966
+ </EditorModal>
794
967
  <RestoreHandler
795
968
  showRestoreModal={showRestoreModal}
796
969
  setShowRestoreModal={setShowRestoreModal}
@@ -801,6 +974,7 @@ export default function EditorShell({
801
974
  </MediaLibraryContext.Provider>
802
975
  </EditorModalProvider>
803
976
  </EditorProvider>
977
+ </PagesContext.Provider>
804
978
  );
805
979
  }
806
980
 
@@ -846,8 +1020,9 @@ function HistoryWatcher() {
846
1020
  index: data.index,
847
1021
  siteConfig: data.siteConfig,
848
1022
  });
849
- const navLinks = generateNavLinks(data.sections);
850
- navChangeEvent.dispatch(navLinks);
1023
+ siteNavChangeEvent.dispatch(
1024
+ generateSiteNav({ index: data.index, activeSections: [], activePageId: null, mode: "editor", audience: null }),
1025
+ );
851
1026
  } catch (err) {
852
1027
  console.error("Failed to load historical content:", err);
853
1028
  }
@@ -858,7 +1033,17 @@ function HistoryWatcher() {
858
1033
  return null;
859
1034
  }
860
1035
 
861
- function HistoryOrEditorContent({ children, sections }: { children: ReactNode; sections: LoadedSection[] }) {
1036
+ function HistoryOrEditorContent({
1037
+ children,
1038
+ index,
1039
+ activeSections,
1040
+ activePageId,
1041
+ }: {
1042
+ children: ReactNode;
1043
+ index: SiteIndex;
1044
+ activeSections: LoadedSection[];
1045
+ activePageId: string | null;
1046
+ }) {
862
1047
  const { historyState } = useEditorContext();
863
1048
  const wasInHistory = useRef(false);
864
1049
 
@@ -867,12 +1052,13 @@ function HistoryOrEditorContent({ children, sections }: { children: ReactNode; s
867
1052
  wasInHistory.current = true;
868
1053
  } else if (wasInHistory.current) {
869
1054
  wasInHistory.current = false;
870
- if (sections.length > 0) {
871
- const navLinks = generateNavLinks(sections);
872
- navChangeEvent.dispatch(navLinks);
1055
+ if (index.pages.length > 0) {
1056
+ siteNavChangeEvent.dispatch(
1057
+ generateSiteNav({ index, activeSections, activePageId, mode: "editor", audience: null }),
1058
+ );
873
1059
  }
874
1060
  }
875
- }, [historyState, sections]);
1061
+ }, [historyState, index, activeSections, activePageId]);
876
1062
 
877
1063
  if (historyState) {
878
1064
  return <ViewRenderer sections={historyState.sections} />;
@@ -911,6 +1097,7 @@ function RestoreHandler({
911
1097
 
912
1098
  function EditorContent({
913
1099
  sections,
1100
+ activePageSectionIds,
914
1101
  audiences,
915
1102
  dirtySectionIds,
916
1103
  isPublishing,
@@ -918,6 +1105,7 @@ function EditorContent({
918
1105
  onAddSection,
919
1106
  onDeleteSection,
920
1107
  onReorderSections,
1108
+ onMoveSection,
921
1109
  onAccessChange,
922
1110
  onStatusChange,
923
1111
  mainIndex,
@@ -925,6 +1113,7 @@ function EditorContent({
925
1113
  viewSections,
926
1114
  }: {
927
1115
  sections: LoadedSection[];
1116
+ activePageSectionIds: string[];
928
1117
  audiences: Audience[];
929
1118
  dirtySectionIds: Set<string>;
930
1119
  isPublishing: boolean;
@@ -932,6 +1121,7 @@ function EditorContent({
932
1121
  onAddSection: (insertIndex: number, type: string) => void;
933
1122
  onDeleteSection: (sectionId: string) => void;
934
1123
  onReorderSections: (fromIndex: number, toIndex: number) => void;
1124
+ onMoveSection?: (sectionId: string) => void;
935
1125
  onAccessChange: (sectionId: string, access: string[]) => void;
936
1126
  onStatusChange: (sectionId: string, status: "draft" | "live" | "archived") => void;
937
1127
  mainIndex: SiteIndex | null;
@@ -955,10 +1145,13 @@ function EditorContent({
955
1145
 
956
1146
  const editingEnabled = isEditMode && !isPublishing;
957
1147
 
958
- // In view mode, render viewSections (fetched from the selected branch).
959
- // When viewBranch is "live", additionally filter to only live sections.
1148
+ // View mode: render the active page's sections from the fetched branch, in page
1149
+ // order (Live additionally filters to status === "live"). Edit mode: the active
1150
+ // page's in-memory sections (the `sections` prop is already `activeSections`).
960
1151
  const displaySections = !isEditMode && viewSections !== null
961
- ? (viewBranch === "live" ? viewSections.filter((s) => s.meta.status === "live") : viewSections)
1152
+ ? activePageSectionIds
1153
+ .map((id) => viewSections.find((s) => s.section.id === id))
1154
+ .filter((s): s is LoadedSection => !!s && (viewBranch !== "live" || s.meta.status === "live"))
962
1155
  : sections;
963
1156
 
964
1157
  useEffect(() => {
@@ -1040,6 +1233,7 @@ function EditorContent({
1040
1233
  }
1041
1234
  } : undefined}
1042
1235
  onReorder={editingEnabled ? onReorderSections : undefined}
1236
+ onMoveSection={editingEnabled && onMoveSection ? () => onMoveSection(section.id) : undefined}
1043
1237
  onRequestInsert={editingEnabled ? (i) => setPendingInsertIndex(i) : undefined}
1044
1238
  onDelete={editingEnabled ? () => onDeleteSection(section.id) : undefined}
1045
1239
  >
@@ -1057,7 +1251,11 @@ function EditorContent({
1057
1251
  );
1058
1252
  })}
1059
1253
 
1060
- {editingEnabled && pendingInsertIndex === displaySections.length && (
1254
+ {/* Trailing selector: shown when an insert was requested after the last
1255
+ section, or always on an empty page — an empty page has no section
1256
+ chrome to insert from, so the selector IS its default state (not
1257
+ dismissible; there is nothing behind it). */}
1258
+ {editingEnabled && (displaySections.length === 0 || pendingInsertIndex === displaySections.length) && (
1061
1259
  <SectionSkeleton
1062
1260
  types={typeOptions}
1063
1261
  onSelect={(type) => {
@@ -1185,6 +1383,7 @@ function EditorToolbar({
1185
1383
  onBuildDismiss,
1186
1384
  onRestoreClick,
1187
1385
  onOrderingClick,
1386
+ onPagesClick,
1188
1387
  }: {
1189
1388
  buttonState: "synced" | "publish" | "saveAndPublish";
1190
1389
  localChangesExist: boolean;
@@ -1202,6 +1401,7 @@ function EditorToolbar({
1202
1401
  onBuildDismiss: () => void;
1203
1402
  onRestoreClick: () => void;
1204
1403
  onOrderingClick: () => void;
1404
+ onPagesClick: () => void;
1205
1405
  }) {
1206
1406
  const { isEditMode, viewBranch, setViewBranch, toggleEditMode, historyState, setHistoryState } = useEditorContext();
1207
1407
 
@@ -1266,6 +1466,13 @@ function EditorToolbar({
1266
1466
  </div>
1267
1467
  <div className="flex items-center justify-end gap-2">
1268
1468
  <ProcessingIndicator items={processingItems} />
1469
+ <IconButton
1470
+ icon={<FilesIcon size={16} />}
1471
+ label="Pages"
1472
+ size="md"
1473
+ onClick={onPagesClick}
1474
+ className="border border-base-200 bg-base-accent"
1475
+ />
1269
1476
  <IconButton
1270
1477
  icon={<ListOrderedIcon size={16} />}
1271
1478
  label="Reorder sections"