@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.
- 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/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/index.js +17 -3
- package/dist/lib/dexie.d.ts +1 -0
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +25 -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 +291 -84
- package/src/hooks/useEditorPublish.ts +17 -9
- package/src/hooks/useMediaPipeline.ts +17 -5
- package/src/lib/dexie.ts +34 -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,
|
|
@@ -160,12 +217,24 @@ export default function EditorShell({
|
|
|
160
217
|
pendingMediaItems: mediaPipeline.pendingMediaItems,
|
|
161
218
|
pendingMediaDeletions: mediaPipeline.pendingDeletions,
|
|
162
219
|
onMediaPublished: (publishedItems, publishedDeletions) => {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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 (
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
|
441
|
-
|
|
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
|
-
|
|
509
|
-
|
|
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
|
|
831
|
+
<HistoryOrEditorContent
|
|
832
|
+
index={siteIndex}
|
|
833
|
+
activeSections={activeSections}
|
|
834
|
+
activePageId={resolvedActivePage?.id ?? null}
|
|
835
|
+
>
|
|
692
836
|
<EditorContent
|
|
693
|
-
sections={
|
|
694
|
-
|
|
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={
|
|
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
|
-
|
|
850
|
-
|
|
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({
|
|
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 (
|
|
871
|
-
|
|
872
|
-
|
|
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,
|
|
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
|
-
//
|
|
959
|
-
//
|
|
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
|
-
?
|
|
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
|
-
{
|
|
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"
|