@drawnagency/primitives 0.1.49 → 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 (69) 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/index.js +17 -3
  25. package/dist/lib/dexie.d.ts.map +1 -1
  26. package/dist/lib/dexie.js +16 -3
  27. package/dist/lib/events.d.ts +3 -1
  28. package/dist/lib/events.d.ts.map +1 -1
  29. package/dist/lib/index.js +2 -2
  30. package/dist/lib/links.d.ts +14 -0
  31. package/dist/lib/links.d.ts.map +1 -0
  32. package/dist/lib/loader.d.ts +2 -2
  33. package/dist/lib/loader.d.ts.map +1 -1
  34. package/dist/lib/nav.d.ts +23 -0
  35. package/dist/lib/nav.d.ts.map +1 -1
  36. package/dist/lib/pages.d.ts +31 -0
  37. package/dist/lib/pages.d.ts.map +1 -0
  38. package/dist/lib/registry.d.ts +7 -0
  39. package/dist/lib/registry.d.ts.map +1 -1
  40. package/dist/schemas/index.d.ts +1 -0
  41. package/dist/schemas/index.d.ts.map +1 -1
  42. package/dist/schemas/index.js +17 -3
  43. package/dist/schemas/link.d.ts +24 -0
  44. package/dist/schemas/link.d.ts.map +1 -0
  45. package/dist/schemas/site-config.d.ts +128 -3
  46. package/dist/schemas/site-config.d.ts.map +1 -1
  47. package/package.json +5 -1
  48. package/src/components/editor/MoveSectionModal.tsx +38 -0
  49. package/src/components/editor/PagesModal.tsx +392 -0
  50. package/src/components/editor/SectionWrapper.tsx +13 -0
  51. package/src/components/editor/SettingsForm.tsx +12 -0
  52. package/src/components/sections/Button/CTAButton.tsx +10 -11
  53. package/src/components/sections/Button/index.tsx +4 -9
  54. package/src/components/shared/Input.tsx +14 -3
  55. package/src/components/shared/LinkField.tsx +87 -0
  56. package/src/components/shared/Navigation.tsx +131 -136
  57. package/src/components/shared/PagesContext.tsx +12 -0
  58. package/src/components/shell/EditorShell.tsx +273 -78
  59. package/src/hooks/useEditorPublish.ts +1 -1
  60. package/src/lib/dexie.ts +18 -5
  61. package/src/lib/events.ts +3 -1
  62. package/src/lib/links.ts +41 -0
  63. package/src/lib/loader.ts +5 -4
  64. package/src/lib/nav.ts +59 -0
  65. package/src/lib/pages.ts +209 -0
  66. package/src/lib/registry.ts +8 -0
  67. package/src/schemas/index.ts +1 -0
  68. package/src/schemas/link.ts +17 -0
  69. 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,
@@ -202,11 +259,42 @@ export default function EditorShell({
202
259
  hasLocalChanges: localChangesExist,
203
260
  });
204
261
 
262
+ // Push the current SiteNav (active page + its headings) to the Navigation island.
205
263
  useEffect(() => {
206
- if (sections.length === 0) return;
207
- const navLinks = generateNavLinks(sections);
208
- navChangeEvent.dispatch(navLinks);
209
- }, [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
+ }, []);
210
298
 
211
299
  const applySiteConfigPreview = useCallback((config: SiteConfig) => {
212
300
  const root = document.documentElement;
@@ -289,7 +377,19 @@ export default function EditorShell({
289
377
  if (cancelled) return;
290
378
  setSections(loadedSections);
291
379
  setSiteIndex(loadedIndex);
292
- 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));
293
393
  setSiteConfig(loadedConfig);
294
394
  setMediaManifest(loadedManifest);
295
395
  siteIndexRef.current = loadedIndex;
@@ -336,7 +436,7 @@ export default function EditorShell({
336
436
  if (restored.siteIndex) {
337
437
  const restoredIndex = restored.siteIndex;
338
438
  setSections(() =>
339
- restoredIndex.order.map((id) => {
439
+ restoredIndex.pages.flatMap((p) => p.order).map((id) => {
340
440
  const restoredContent = restored.sections[id];
341
441
  const existing = sections.find((s) => s.section.id === id);
342
442
  if (restoredContent) {
@@ -356,7 +456,7 @@ export default function EditorShell({
356
456
  // restored index was deleted locally before this reload. The deletion intent
357
457
  // isn't persisted directly, so reconstruct it here — otherwise the section's
358
458
  // GitHub file would be orphaned (index omits it, but it's never deleted).
359
- const restoredIds = new Set(restoredIndex.order);
459
+ const restoredIds = new Set(restoredIndex.pages.flatMap((p) => p.order));
360
460
  const rediscoveredDeletions = new Set(
361
461
  [...remoteSectionIdsRef.current].filter((id) => !restoredIds.has(id)),
362
462
  );
@@ -404,7 +504,7 @@ export default function EditorShell({
404
504
  setSections(cached.sections);
405
505
  setSiteIndex(cached.index);
406
506
  siteIndexRef.current = cached.index;
407
- remoteSectionIdsRef.current = new Set(cached.index.order);
507
+ remoteSectionIdsRef.current = new Set(cached.index.pages.flatMap((p) => p.order));
408
508
  const parsed = SiteConfigSchema.safeParse(cached.siteConfig);
409
509
  if (parsed.success) {
410
510
  setSiteConfig(parsed.data);
@@ -443,39 +543,21 @@ export default function EditorShell({
443
543
  const onAddSection = useCallback(
444
544
  (insertIndex: number, type: string) => {
445
545
  const definition = getSection(type);
446
- if (!definition) return;
447
-
546
+ if (!definition || !resolvedActivePage) return;
448
547
  const id = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
449
- ? crypto.randomUUID()
450
- : `_id_${Date.now()}_${Math.random().toString(36).slice(2)}`;
548
+ ? crypto.randomUUID() : `_id_${Date.now()}_${Math.random().toString(36).slice(2)}`;
451
549
  const content = definition.defaults() as object;
452
- const newSection: LoadedSection = {
453
- section: { id, ...content } as LoadedSection["section"],
454
- meta: { type, status: "draft", access: ["internal"] },
455
- };
456
-
457
- setSections((prev) => {
458
- const next = [...prev];
459
- next.splice(insertIndex, 0, newSection);
460
- return next;
461
- });
462
-
463
- setSiteIndex((prev) => {
464
- const newOrder = [...prev.order];
465
- newOrder.splice(insertIndex, 0, id);
466
- return {
467
- ...prev,
468
- order: newOrder,
469
- sections: { ...prev.sections, [id]: { type, status: "draft", access: ["internal"] } },
470
- };
471
- });
550
+ const meta = { type, status: "draft" as const, access: [] as string[] };
551
+ const newSection: LoadedSection = { section: { id, ...content } as LoadedSection["section"], meta };
472
552
 
553
+ setSections((prev) => [...prev, newSection]);
554
+ setSiteIndex((prev) => addSectionToPage(prev, resolvedActivePage.id, id, meta, insertIndex));
473
555
  persistence.markSectionDirty(id, content as SectionContent);
474
556
  persistence.markIndexDirty();
475
557
  setLocalChangesExist(true);
476
558
  setDirtySectionIds((prev) => new Set(prev).add(id));
477
559
  },
478
- [persistence],
560
+ [persistence, resolvedActivePage],
479
561
  );
480
562
 
481
563
  // Runs after the confirmation modal is accepted. Removes the section from the
@@ -484,15 +566,7 @@ export default function EditorShell({
484
566
  const performDeleteSection = useCallback(
485
567
  (sectionId: string) => {
486
568
  setSections((prev) => prev.filter((loaded) => loaded.section.id !== sectionId));
487
- setSiteIndex((prev) => {
488
- const nextSections = { ...prev.sections };
489
- delete nextSections[sectionId];
490
- return {
491
- ...prev,
492
- order: prev.order.filter((id) => id !== sectionId),
493
- sections: nextSections,
494
- };
495
- });
569
+ setSiteIndex((prev) => removeSectionFromPages(prev, sectionId));
496
570
  // Only schedule a GitHub file deletion if the section exists remotely. A
497
571
  // section that was added locally and never saved has no committed file, so
498
572
  // sending a deletion for it would target a non-existent path.
@@ -517,25 +591,77 @@ export default function EditorShell({
517
591
 
518
592
  const onReorderSections = useCallback(
519
593
  (fromIndex: number, toIndex: number) => {
520
- setSections((prev) => {
521
- const next = [...prev];
522
- const [moved] = next.splice(fromIndex, 1);
523
- next.splice(toIndex, 0, moved);
524
- return next;
525
- });
526
- setSiteIndex((prev) => {
527
- const newOrder = [...prev.order];
528
- const [movedId] = newOrder.splice(fromIndex, 1);
529
- newOrder.splice(toIndex, 0, movedId);
530
- return { ...prev, order: newOrder };
531
- });
532
-
594
+ if (!resolvedActivePage) return;
595
+ setSiteIndex((prev) => reorderSectionInPage(prev, resolvedActivePage.id, fromIndex, toIndex));
533
596
  persistence.markIndexDirty();
534
597
  setLocalChangesExist(true);
535
598
  },
536
- [persistence],
599
+ [persistence, resolvedActivePage],
537
600
  );
538
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
+
539
665
  const onAccessChange = useCallback(
540
666
  (sectionId: string, access: string[]) => {
541
667
  setSections((prev) =>
@@ -651,6 +777,7 @@ export default function EditorShell({
651
777
  }
652
778
 
653
779
  return (
780
+ <PagesContext.Provider value={pagesContextValue}>
654
781
  <EditorProvider>
655
782
  <ViewBranchWatcher
656
783
  setViewSections={setViewSections}
@@ -687,6 +814,7 @@ export default function EditorShell({
687
814
  onBuildDismiss={buildStatus.dismiss}
688
815
  onRestoreClick={() => setShowRestoreModal(true)}
689
816
  onOrderingClick={() => setShowOrderingModal(true)}
817
+ onPagesClick={() => setShowPagesModal(true)}
690
818
  />
691
819
 
692
820
  <BugReportFAB />
@@ -700,16 +828,22 @@ export default function EditorShell({
700
828
  </div>
701
829
  )}
702
830
 
703
- <HistoryOrEditorContent sections={sections}>
831
+ <HistoryOrEditorContent
832
+ index={siteIndex}
833
+ activeSections={activeSections}
834
+ activePageId={resolvedActivePage?.id ?? null}
835
+ >
704
836
  <EditorContent
705
- sections={sections}
706
- audiences={audiences}
837
+ sections={activeSections}
838
+ activePageSectionIds={resolvedActivePage?.order ?? []}
839
+ audiences={pageAudiences}
707
840
  dirtySectionIds={dirtySectionIds}
708
841
  isPublishing={publishAction !== "idle"}
709
842
  onSectionChange={onSectionChange}
710
843
  onAddSection={onAddSection}
711
844
  onDeleteSection={setPendingDeleteSectionId}
712
845
  onReorderSections={onReorderSections}
846
+ onMoveSection={siteIndex.pages.length > 1 ? setMovingSectionId : undefined}
713
847
  onAccessChange={onAccessChange}
714
848
  onStatusChange={onStatusChange}
715
849
  mainIndex={mainIndex}
@@ -798,11 +932,38 @@ export default function EditorShell({
798
932
  size="settings"
799
933
  >
800
934
  <SectionOrderingModal
801
- sections={sections}
935
+ sections={activeSections}
802
936
  mediaManifest={mediaManifest}
803
937
  onReorder={onReorderSections}
804
938
  />
805
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>
806
967
  <RestoreHandler
807
968
  showRestoreModal={showRestoreModal}
808
969
  setShowRestoreModal={setShowRestoreModal}
@@ -813,6 +974,7 @@ export default function EditorShell({
813
974
  </MediaLibraryContext.Provider>
814
975
  </EditorModalProvider>
815
976
  </EditorProvider>
977
+ </PagesContext.Provider>
816
978
  );
817
979
  }
818
980
 
@@ -858,8 +1020,9 @@ function HistoryWatcher() {
858
1020
  index: data.index,
859
1021
  siteConfig: data.siteConfig,
860
1022
  });
861
- const navLinks = generateNavLinks(data.sections);
862
- navChangeEvent.dispatch(navLinks);
1023
+ siteNavChangeEvent.dispatch(
1024
+ generateSiteNav({ index: data.index, activeSections: [], activePageId: null, mode: "editor", audience: null }),
1025
+ );
863
1026
  } catch (err) {
864
1027
  console.error("Failed to load historical content:", err);
865
1028
  }
@@ -870,7 +1033,17 @@ function HistoryWatcher() {
870
1033
  return null;
871
1034
  }
872
1035
 
873
- 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
+ }) {
874
1047
  const { historyState } = useEditorContext();
875
1048
  const wasInHistory = useRef(false);
876
1049
 
@@ -879,12 +1052,13 @@ function HistoryOrEditorContent({ children, sections }: { children: ReactNode; s
879
1052
  wasInHistory.current = true;
880
1053
  } else if (wasInHistory.current) {
881
1054
  wasInHistory.current = false;
882
- if (sections.length > 0) {
883
- const navLinks = generateNavLinks(sections);
884
- navChangeEvent.dispatch(navLinks);
1055
+ if (index.pages.length > 0) {
1056
+ siteNavChangeEvent.dispatch(
1057
+ generateSiteNav({ index, activeSections, activePageId, mode: "editor", audience: null }),
1058
+ );
885
1059
  }
886
1060
  }
887
- }, [historyState, sections]);
1061
+ }, [historyState, index, activeSections, activePageId]);
888
1062
 
889
1063
  if (historyState) {
890
1064
  return <ViewRenderer sections={historyState.sections} />;
@@ -923,6 +1097,7 @@ function RestoreHandler({
923
1097
 
924
1098
  function EditorContent({
925
1099
  sections,
1100
+ activePageSectionIds,
926
1101
  audiences,
927
1102
  dirtySectionIds,
928
1103
  isPublishing,
@@ -930,6 +1105,7 @@ function EditorContent({
930
1105
  onAddSection,
931
1106
  onDeleteSection,
932
1107
  onReorderSections,
1108
+ onMoveSection,
933
1109
  onAccessChange,
934
1110
  onStatusChange,
935
1111
  mainIndex,
@@ -937,6 +1113,7 @@ function EditorContent({
937
1113
  viewSections,
938
1114
  }: {
939
1115
  sections: LoadedSection[];
1116
+ activePageSectionIds: string[];
940
1117
  audiences: Audience[];
941
1118
  dirtySectionIds: Set<string>;
942
1119
  isPublishing: boolean;
@@ -944,6 +1121,7 @@ function EditorContent({
944
1121
  onAddSection: (insertIndex: number, type: string) => void;
945
1122
  onDeleteSection: (sectionId: string) => void;
946
1123
  onReorderSections: (fromIndex: number, toIndex: number) => void;
1124
+ onMoveSection?: (sectionId: string) => void;
947
1125
  onAccessChange: (sectionId: string, access: string[]) => void;
948
1126
  onStatusChange: (sectionId: string, status: "draft" | "live" | "archived") => void;
949
1127
  mainIndex: SiteIndex | null;
@@ -967,10 +1145,13 @@ function EditorContent({
967
1145
 
968
1146
  const editingEnabled = isEditMode && !isPublishing;
969
1147
 
970
- // In view mode, render viewSections (fetched from the selected branch).
971
- // 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`).
972
1151
  const displaySections = !isEditMode && viewSections !== null
973
- ? (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"))
974
1155
  : sections;
975
1156
 
976
1157
  useEffect(() => {
@@ -1052,6 +1233,7 @@ function EditorContent({
1052
1233
  }
1053
1234
  } : undefined}
1054
1235
  onReorder={editingEnabled ? onReorderSections : undefined}
1236
+ onMoveSection={editingEnabled && onMoveSection ? () => onMoveSection(section.id) : undefined}
1055
1237
  onRequestInsert={editingEnabled ? (i) => setPendingInsertIndex(i) : undefined}
1056
1238
  onDelete={editingEnabled ? () => onDeleteSection(section.id) : undefined}
1057
1239
  >
@@ -1069,7 +1251,11 @@ function EditorContent({
1069
1251
  );
1070
1252
  })}
1071
1253
 
1072
- {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) && (
1073
1259
  <SectionSkeleton
1074
1260
  types={typeOptions}
1075
1261
  onSelect={(type) => {
@@ -1197,6 +1383,7 @@ function EditorToolbar({
1197
1383
  onBuildDismiss,
1198
1384
  onRestoreClick,
1199
1385
  onOrderingClick,
1386
+ onPagesClick,
1200
1387
  }: {
1201
1388
  buttonState: "synced" | "publish" | "saveAndPublish";
1202
1389
  localChangesExist: boolean;
@@ -1214,6 +1401,7 @@ function EditorToolbar({
1214
1401
  onBuildDismiss: () => void;
1215
1402
  onRestoreClick: () => void;
1216
1403
  onOrderingClick: () => void;
1404
+ onPagesClick: () => void;
1217
1405
  }) {
1218
1406
  const { isEditMode, viewBranch, setViewBranch, toggleEditMode, historyState, setHistoryState } = useEditorContext();
1219
1407
 
@@ -1278,6 +1466,13 @@ function EditorToolbar({
1278
1466
  </div>
1279
1467
  <div className="flex items-center justify-end gap-2">
1280
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
+ />
1281
1476
  <IconButton
1282
1477
  icon={<ListOrderedIcon size={16} />}
1283
1478
  label="Reorder sections"
@@ -158,7 +158,7 @@ export function useEditorPublish({
158
158
  }
159
159
  siteIndex = {
160
160
  ...siteIndex,
161
- order: siteIndex.order.filter((id) => !deleteSet.has(id)),
161
+ pages: siteIndex.pages.map((p) => ({ ...p, order: p.order.filter((id) => !deleteSet.has(id)) })),
162
162
  sections: filteredSections,
163
163
  };
164
164
  }
package/src/lib/dexie.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import Dexie from "dexie";
2
2
  import type { SectionContent } from "../schemas/sections";
3
- import type { SiteIndex, SectionMeta, SiteConfig } from "../schemas/site-config";
3
+ import type { SiteIndex, SectionMeta, SiteConfig, Page } from "../schemas/site-config";
4
4
  import type { LoadedSection } from "./loader";
5
5
  import type { MediaManifest, MediaItem } from "../media/types";
6
6
 
@@ -13,7 +13,7 @@ interface SectionRow {
13
13
 
14
14
  interface SiteIndexRow {
15
15
  key: string;
16
- order: string[];
16
+ pages: Page[];
17
17
  sections: Record<string, SectionMeta>;
18
18
  deletedSections: string[];
19
19
  updatedAt: string;
@@ -123,6 +123,19 @@ class EditorDatabase extends Dexie {
123
123
  }).upgrade((tx) => {
124
124
  return tx.table("pendingMedia").clear();
125
125
  });
126
+ this.version(7).stores({
127
+ sections: "sectionId",
128
+ siteIndex: "key",
129
+ meta: "key",
130
+ siteConfig: "key",
131
+ contentCache: "key",
132
+ mediaManifest: "key",
133
+ pendingMedia: "id",
134
+ pendingMediaDeletions: "id",
135
+ }).upgrade(async (tx) => {
136
+ await tx.table("siteIndex").clear();
137
+ await tx.table("contentCache").clear();
138
+ });
126
139
  }
127
140
  }
128
141
 
@@ -173,7 +186,7 @@ export async function restoreLocalChanges(): Promise<{
173
186
  const siteId = metaRow?.siteId ?? "";
174
187
  return {
175
188
  sections,
176
- siteIndex: { siteId, order: indexRow.order, sections: indexRow.sections },
189
+ siteIndex: { siteId, pages: indexRow.pages, sections: indexRow.sections },
177
190
  siteConfig: configRow?.config,
178
191
  deletedSections: indexRow.deletedSections ?? [],
179
192
  };
@@ -200,7 +213,7 @@ export async function persistSiteIndex(index: SiteIndex, deletedSections: string
200
213
  await database.transaction("rw", [database.siteIndex, database.meta], async () => {
201
214
  await database.siteIndex.put({
202
215
  key: "current",
203
- order: index.order,
216
+ pages: index.pages,
204
217
  sections: index.sections,
205
218
  deletedSections,
206
219
  updatedAt: now,
@@ -323,7 +336,7 @@ export async function persistAll(
323
336
  if (siteIndex) {
324
337
  await database.siteIndex.put({
325
338
  key: "current",
326
- order: siteIndex.order,
339
+ pages: siteIndex.pages,
327
340
  sections: siteIndex.sections,
328
341
  deletedSections: [],
329
342
  updatedAt: now,