@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.
- package/dist/{chunk-P3HO76OS.js → chunk-DLXIYIG2.js} +6 -3
- package/dist/{chunk-5XYUO4HP.js → chunk-ICRCH3GI.js} +19 -2
- package/dist/{chunk-BJ6FYGYP.js → chunk-ONBJG426.js} +95 -9
- package/dist/components/editor/MoveSectionModal.d.ts +12 -0
- package/dist/components/editor/MoveSectionModal.d.ts.map +1 -0
- package/dist/components/editor/PagesModal.d.ts +18 -0
- package/dist/components/editor/PagesModal.d.ts.map +1 -0
- package/dist/components/editor/SectionWrapper.d.ts +1 -1
- package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
- package/dist/components/editor/SettingsForm.d.ts.map +1 -1
- package/dist/components/sections/Button/CTAButton.d.ts +3 -3
- package/dist/components/sections/Button/CTAButton.d.ts.map +1 -1
- package/dist/components/sections/Button/index.d.ts +10 -2
- package/dist/components/sections/Button/index.d.ts.map +1 -1
- package/dist/components/shared/Input.d.ts +1 -0
- package/dist/components/shared/Input.d.ts.map +1 -1
- package/dist/components/shared/LinkField.d.ts +9 -0
- package/dist/components/shared/LinkField.d.ts.map +1 -0
- package/dist/components/shared/Navigation.d.ts +4 -3
- package/dist/components/shared/Navigation.d.ts.map +1 -1
- package/dist/components/shared/PagesContext.d.ts +13 -0
- package/dist/components/shared/PagesContext.d.ts.map +1 -0
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/index.js +17 -3
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +16 -3
- package/dist/lib/events.d.ts +3 -1
- package/dist/lib/events.d.ts.map +1 -1
- package/dist/lib/index.js +2 -2
- package/dist/lib/links.d.ts +14 -0
- package/dist/lib/links.d.ts.map +1 -0
- package/dist/lib/loader.d.ts +2 -2
- package/dist/lib/loader.d.ts.map +1 -1
- package/dist/lib/nav.d.ts +23 -0
- package/dist/lib/nav.d.ts.map +1 -1
- package/dist/lib/pages.d.ts +31 -0
- package/dist/lib/pages.d.ts.map +1 -0
- package/dist/lib/registry.d.ts +7 -0
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/schemas/index.d.ts +1 -0
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +17 -3
- package/dist/schemas/link.d.ts +24 -0
- package/dist/schemas/link.d.ts.map +1 -0
- package/dist/schemas/site-config.d.ts +128 -3
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/components/editor/MoveSectionModal.tsx +38 -0
- package/src/components/editor/PagesModal.tsx +392 -0
- package/src/components/editor/SectionWrapper.tsx +13 -0
- package/src/components/editor/SettingsForm.tsx +12 -0
- package/src/components/sections/Button/CTAButton.tsx +10 -11
- package/src/components/sections/Button/index.tsx +4 -9
- package/src/components/shared/Input.tsx +14 -3
- package/src/components/shared/LinkField.tsx +87 -0
- package/src/components/shared/Navigation.tsx +131 -136
- package/src/components/shared/PagesContext.tsx +12 -0
- package/src/components/shell/EditorShell.tsx +273 -78
- package/src/hooks/useEditorPublish.ts +1 -1
- package/src/lib/dexie.ts +18 -5
- package/src/lib/events.ts +3 -1
- package/src/lib/links.ts +41 -0
- package/src/lib/loader.ts +5 -4
- package/src/lib/nav.ts +59 -0
- package/src/lib/pages.ts +209 -0
- package/src/lib/registry.ts +8 -0
- package/src/schemas/index.ts +1 -0
- package/src/schemas/link.ts +17 -0
- 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 {
|
|
48
|
-
import {
|
|
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,
|
|
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,
|
|
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 (
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
|
453
|
-
|
|
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
|
-
|
|
521
|
-
|
|
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
|
|
831
|
+
<HistoryOrEditorContent
|
|
832
|
+
index={siteIndex}
|
|
833
|
+
activeSections={activeSections}
|
|
834
|
+
activePageId={resolvedActivePage?.id ?? null}
|
|
835
|
+
>
|
|
704
836
|
<EditorContent
|
|
705
|
-
sections={
|
|
706
|
-
|
|
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={
|
|
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
|
-
|
|
862
|
-
|
|
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({
|
|
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 (
|
|
883
|
-
|
|
884
|
-
|
|
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,
|
|
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
|
-
//
|
|
971
|
-
//
|
|
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
|
-
?
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
339
|
+
pages: siteIndex.pages,
|
|
327
340
|
sections: siteIndex.sections,
|
|
328
341
|
deletedSections: [],
|
|
329
342
|
updatedAt: now,
|