@drawnagency/primitives 0.1.98 → 0.1.100
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-EUSWEVOA.js → chunk-3TO4RRHY.js} +12 -1
- package/dist/{chunk-KP4DWTPR.js → chunk-72FMQQIA.js} +246 -3
- package/dist/chunk-BS5AQHZG.js +45 -0
- package/dist/{chunk-NA5EJM6Z.js → chunk-CZLYECHC.js} +19 -8
- package/dist/{chunk-HKHEAH5F.js → chunk-GOCFTXR3.js} +8 -60
- package/dist/{chunk-GYCBWGVW.js → chunk-LBHQWIVL.js} +79 -24
- package/dist/{chunk-HJSE6SK5.js → chunk-REDDTTGJ.js} +3 -3
- package/dist/collab/rebase.d.ts.map +1 -1
- package/dist/collab/rebase.js +3 -1
- package/dist/components/chat/ChatPanel.d.ts.map +1 -1
- package/dist/components/docfile/DocFileOverlay.d.ts +12 -0
- package/dist/components/docfile/DocFileOverlay.d.ts.map +1 -0
- package/dist/components/docfile/DocFilePane.d.ts +17 -0
- package/dist/components/docfile/DocFilePane.d.ts.map +1 -0
- package/dist/components/docfile/doc-file-state.d.ts +33 -0
- package/dist/components/docfile/doc-file-state.d.ts.map +1 -0
- package/dist/components/editor/SettingsForm.d.ts.map +1 -1
- package/dist/components/editor/index.js +1 -1
- package/dist/components/sections/Document/index.d.ts +18 -0
- package/dist/components/sections/Document/index.d.ts.map +1 -0
- package/dist/components/sections/all-sections.d.ts +10 -0
- package/dist/components/sections/all-sections.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.d.ts +10 -0
- package/dist/components/sections/brand-guide.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.js +3 -1
- package/dist/components/sections/register-schemas.js +5 -3
- package/dist/components/sections/register.js +5 -3
- package/dist/components/shared/DocumentSourceField.d.ts +13 -0
- package/dist/components/shared/DocumentSourceField.d.ts.map +1 -0
- package/dist/components/shared/Navigation.d.ts.map +1 -1
- package/dist/components/shell/EditorModal.d.ts.map +1 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/MediaBreadcrumb.d.ts.map +1 -1
- package/dist/components/shell/MediaFolderTile.d.ts +3 -1
- package/dist/components/shell/MediaFolderTile.d.ts.map +1 -1
- package/dist/components/shell/MediaFolderTree.d.ts +3 -0
- package/dist/components/shell/MediaFolderTree.d.ts.map +1 -1
- package/dist/components/shell/MediaLibraryContext.d.ts +18 -1
- package/dist/components/shell/MediaLibraryContext.d.ts.map +1 -1
- package/dist/components/shell/MediaLibraryModal.d.ts +21 -1
- package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
- package/dist/hooks/useDocumentUploads.d.ts +32 -0
- package/dist/hooks/useDocumentUploads.d.ts.map +1 -0
- package/dist/hooks/useEditorPublish.d.ts +23 -1
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/hooks/useMediaPipeline.d.ts +14 -1
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/hooks/useResolvedDocument.d.ts +12 -0
- package/dist/hooks/useResolvedDocument.d.ts.map +1 -0
- package/dist/index.js +60 -34
- package/dist/lib/block-move.d.ts.map +1 -1
- package/dist/lib/dexie.d.ts +11 -0
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +32 -0
- package/dist/lib/document-refs.d.ts +22 -0
- package/dist/lib/document-refs.d.ts.map +1 -0
- package/dist/lib/document-uploads.d.ts +38 -0
- package/dist/lib/document-uploads.d.ts.map +1 -0
- package/dist/lib/documents.d.ts +65 -0
- package/dist/lib/documents.d.ts.map +1 -0
- package/dist/lib/index-ops.d.ts +1 -0
- package/dist/lib/index-ops.d.ts.map +1 -1
- package/dist/lib/index-ops.js +13 -1
- package/dist/lib/index.js +6 -6
- package/dist/lib/loader.d.ts +6 -0
- package/dist/lib/loader.d.ts.map +1 -1
- package/dist/lib/media-folders.d.ts +8 -0
- package/dist/lib/media-folders.d.ts.map +1 -1
- package/dist/lib/media-folders.js +2 -0
- package/dist/lib/nav.d.ts +29 -1
- package/dist/lib/nav.d.ts.map +1 -1
- package/dist/lib/pages.d.ts +18 -0
- package/dist/lib/pages.d.ts.map +1 -1
- package/dist/lib/platform-broker.d.ts +12 -0
- package/dist/lib/platform-broker.d.ts.map +1 -1
- package/dist/lib/platform-broker.js +12 -3
- package/dist/lib/registry.d.ts +10 -0
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/lib/save-chunks.d.ts +15 -0
- package/dist/lib/save-chunks.d.ts.map +1 -1
- package/dist/lib/scroll-lock.d.ts +2 -0
- package/dist/lib/scroll-lock.d.ts.map +1 -0
- package/dist/schemas/documents.d.ts +80 -0
- package/dist/schemas/documents.d.ts.map +1 -0
- package/dist/schemas/documents.js +25 -0
- package/dist/schemas/index.d.ts +1 -0
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +24 -2
- package/dist/storage/index.d.ts +2 -2
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +5 -1
- package/dist/storage/types.d.ts +69 -5
- package/dist/storage/types.d.ts.map +1 -1
- package/dist/types/database.d.ts +14 -0
- package/dist/types/database.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/collab/rebase.ts +15 -0
- package/src/components/chat/ChatPanel.tsx +5 -4
- package/src/components/docfile/DocFileOverlay.tsx +54 -0
- package/src/components/docfile/DocFilePane.tsx +129 -0
- package/src/components/docfile/doc-file-state.ts +180 -0
- package/src/components/editor/SettingsForm.tsx +10 -0
- package/src/components/sections/Document/index.tsx +163 -0
- package/src/components/sections/brand-guide.ts +2 -1
- package/src/components/shared/DocumentSourceField.tsx +51 -0
- package/src/components/shared/Navigation.tsx +53 -10
- package/src/components/shell/EditorModal.tsx +2 -5
- package/src/components/shell/EditorShell.tsx +154 -10
- package/src/components/shell/MediaBreadcrumb.tsx +14 -1
- package/src/components/shell/MediaFolderTile.tsx +4 -2
- package/src/components/shell/MediaFolderTree.tsx +20 -3
- package/src/components/shell/MediaLibraryContext.tsx +18 -1
- package/src/components/shell/MediaLibraryModal.tsx +223 -45
- package/src/hooks/useDocumentUploads.ts +330 -0
- package/src/hooks/useEditorPublish.ts +197 -11
- package/src/hooks/useMediaPipeline.ts +136 -5
- package/src/hooks/useResolvedDocument.ts +25 -0
- package/src/lib/block-move.ts +15 -4
- package/src/lib/dexie.ts +54 -0
- package/src/lib/document-refs.ts +83 -0
- package/src/lib/document-uploads.ts +66 -0
- package/src/lib/documents.ts +138 -0
- package/src/lib/index-ops.ts +16 -0
- package/src/lib/loader.ts +32 -11
- package/src/lib/media-folders.ts +9 -0
- package/src/lib/nav.ts +46 -2
- package/src/lib/pages.ts +48 -0
- package/src/lib/platform-broker.ts +18 -3
- package/src/lib/registry.ts +9 -0
- package/src/lib/save-chunks.ts +42 -0
- package/src/lib/scroll-lock.ts +27 -0
- package/src/schemas/documents.ts +82 -0
- package/src/schemas/index.ts +1 -0
- package/src/schemas/nav.ts +3 -3
- package/src/storage/index.ts +2 -2
- package/src/storage/types.ts +71 -5
- package/src/types/database.ts +14 -0
- /package/dist/{chunk-FOY5TF7D.js → chunk-ZDQ3TT47.js} +0 -0
|
@@ -9,6 +9,7 @@ import type { Section, SectionContent } from "../../schemas/sections";
|
|
|
9
9
|
import type { SiteIndex, SiteConfig, Page } from "../../schemas/site-config";
|
|
10
10
|
import type { Audience } from "../../auth/types";
|
|
11
11
|
import type { MediaManifest } from "../../media/types";
|
|
12
|
+
import type { DocumentManifest } from "../../schemas/documents";
|
|
12
13
|
import type { QueueItem } from "../../media/queue";
|
|
13
14
|
import { composePreviewImage, PREVIEW_COMPOSE_VERSION } from "../../media/compose-preview";
|
|
14
15
|
import { derivePreviewInputs, servedPreviewSize } from "../../media/preview-inputs";
|
|
@@ -30,7 +31,7 @@ import { EditorModal } from "./EditorModal";
|
|
|
30
31
|
import { SiteSettingsModal } from "./SiteSettingsModal";
|
|
31
32
|
import ChatWidget from "../chat/ChatWidget";
|
|
32
33
|
import { MediaLibraryModal } from "./MediaLibraryModal";
|
|
33
|
-
import { MediaLibraryContext } from "./MediaLibraryContext";
|
|
34
|
+
import { MediaLibraryContext, type LibraryKind } from "./MediaLibraryContext";
|
|
34
35
|
import { ProcessingIndicator } from "./ProcessingIndicator";
|
|
35
36
|
import { SectionSkeleton } from "./SectionSkeleton";
|
|
36
37
|
import { SectionTypePicker } from "./SectionTypePicker";
|
|
@@ -89,11 +90,13 @@ import { useBuildStatus } from "../../hooks/useBuildStatus";
|
|
|
89
90
|
import { useMediaPipeline } from "../../hooks/useMediaPipeline";
|
|
90
91
|
import { formatTimestamp } from "../../lib/timestamp";
|
|
91
92
|
import { generateSiteNav } from "../../lib/nav";
|
|
93
|
+
import { deriveSiteDocuments, type SiteDocument } from "../../lib/documents";
|
|
94
|
+
import { setSiteDocuments, closeDocFile } from "../docfile/doc-file-state";
|
|
92
95
|
import { pagesInNavOrder, moveNavEntry, addGroup, renameGroup, setGroupStartCollapsed, deleteGroup, type NavLocation } from "../../lib/nav-tree";
|
|
93
96
|
import { siteNavChangeEvent, pageSelectEvent, darkModeEvent, siteNameChangeEvent, historySelectEvent, historyRestoreEvent, historyRestoreFailedEvent, editorDirtyEvent } from "../../lib/events";
|
|
94
97
|
import {
|
|
95
98
|
homePage, pageById, pageBySlug, pageDisplayTitle, addSectionToPage, removeSectionFromPages,
|
|
96
|
-
|
|
99
|
+
reorderVisibleSectionInPage, audiencePasses,
|
|
97
100
|
setHomePage, setPageArchived, setPageFields, setPageAudience, setPageDocument, deletePage, addPage,
|
|
98
101
|
moveSection as moveSectionReducer,
|
|
99
102
|
} from "../../lib/pages";
|
|
@@ -137,6 +140,13 @@ interface SavedContent {
|
|
|
137
140
|
changedSectionIds: string[];
|
|
138
141
|
mainIndex: SiteIndex;
|
|
139
142
|
mediaManifest?: MediaManifest;
|
|
143
|
+
/** The document sidecar. Optional: absent until the storage track adds it to
|
|
144
|
+
* /api/content, and absent on any site with no documents. */
|
|
145
|
+
documentManifest?: DocumentManifest;
|
|
146
|
+
/** Indexed ids whose section file exists on the branch but is not valid JSON.
|
|
147
|
+
* Their bodies are absent from `sections` while their index slots survive —
|
|
148
|
+
* the editor must surface them, not silently render around them. */
|
|
149
|
+
unparseableSectionIds: string[];
|
|
140
150
|
}
|
|
141
151
|
|
|
142
152
|
interface Props {
|
|
@@ -192,9 +202,16 @@ export default function EditorShell({
|
|
|
192
202
|
const [dirtySectionIds, setDirtySectionIds] = useState<Set<string>>(new Set());
|
|
193
203
|
const [siteConfig, setSiteConfig] = useState<SiteConfig | null>(null);
|
|
194
204
|
const [mediaManifest, setMediaManifest] = useState<MediaManifest>({ images: {}, folders: {} });
|
|
205
|
+
// null = /api/content has not resolved yet. Distinct from {} (loaded, no
|
|
206
|
+
// documents): U6's registry publish skips null so document rows keep the
|
|
207
|
+
// overlay island's SSR seed instead of flashing "File missing" on mount.
|
|
208
|
+
const [documentManifest, setDocumentManifest] = useState<DocumentManifest | null>(null);
|
|
195
209
|
const [showMediaLibrary, setShowMediaLibrary] = useState(false);
|
|
196
210
|
const [mediaModalMode, setMediaModalMode] = useState<"select" | "manage">("manage");
|
|
197
211
|
const [mediaSelectCallback, setMediaSelectCallback] = useState<((id: string) => void) | null>(null);
|
|
212
|
+
// Kind filter for the current select-mode open (null = the historical
|
|
213
|
+
// image/animated/video picker). Consumed by MediaLibraryModal.
|
|
214
|
+
const [mediaSelectKinds, setMediaSelectKinds] = useState<ReadonlyArray<LibraryKind> | null>(null);
|
|
198
215
|
const [deletedSections, setDeletedSections] = useState<Set<string>>(new Set());
|
|
199
216
|
// Ref mirror so the debounced Dexie flush can persist the deletion set. Without
|
|
200
217
|
// it the snapshot records the index minus the deleted sections but not the
|
|
@@ -235,6 +252,15 @@ export default function EditorShell({
|
|
|
235
252
|
// Surfaces useMediaPipeline's onUploadNotice (e.g. "N files already exist
|
|
236
253
|
// and were left where they are" on dedup skip) in the media library modal.
|
|
237
254
|
const [uploadNotice, setUploadNotice] = useState<string | null>(null);
|
|
255
|
+
// Indexed ids whose file on the branch is not valid JSON (from /api/content).
|
|
256
|
+
// Their bodies can't render or be edited, but they hold their page-order slot
|
|
257
|
+
// — surfaced as a persistent banner so a "missing" section isn't mistaken for
|
|
258
|
+
// a deleted one and repaired by data loss.
|
|
259
|
+
const [unparseableSectionIds, setUnparseableSectionIds] = useState<string[]>([]);
|
|
260
|
+
// Non-fatal warnings from the last successful save (e.g. nav.json replacement
|
|
261
|
+
// declined by the degraded-nav guard) — without a surface, the save reads as
|
|
262
|
+
// fully landed when part of it was refused.
|
|
263
|
+
const [saveWarnings, setSaveWarnings] = useState<string[]>([]);
|
|
238
264
|
const [activePageId, setActivePageId] = useState<string | null>(null);
|
|
239
265
|
const [movingSectionId, setMovingSectionId] = useState<string | null>(null);
|
|
240
266
|
// Set by recoverConflict when a rebase surfaces overlapping edits; drives the
|
|
@@ -397,6 +423,8 @@ export default function EditorShell({
|
|
|
397
423
|
siteConfig,
|
|
398
424
|
mediaManifest,
|
|
399
425
|
setMediaManifest,
|
|
426
|
+
documentManifest: documentManifest ?? {},
|
|
427
|
+
setDocumentManifest: (next) => setDocumentManifest((prev) => typeof next === "function" ? next(prev ?? {}) : next),
|
|
400
428
|
sections,
|
|
401
429
|
setSections,
|
|
402
430
|
setLocalChangesExist,
|
|
@@ -428,6 +456,8 @@ export default function EditorShell({
|
|
|
428
456
|
changedSectionIds: (data.changedSectionIds ?? []) as string[],
|
|
429
457
|
mainIndex: (data.mainIndex ?? data.index) as SiteIndex,
|
|
430
458
|
mediaManifest: data.mediaManifest as MediaManifest | undefined,
|
|
459
|
+
documentManifest: data.documentManifest as DocumentManifest | undefined,
|
|
460
|
+
unparseableSectionIds: (data.unparseableSectionIds ?? []) as string[],
|
|
431
461
|
};
|
|
432
462
|
}, []);
|
|
433
463
|
|
|
@@ -440,6 +470,7 @@ export default function EditorShell({
|
|
|
440
470
|
const recoverConflict = useCallback(async (): Promise<boolean> => {
|
|
441
471
|
try {
|
|
442
472
|
const remote = await fetchSavedContent();
|
|
473
|
+
setUnparseableSectionIds(remote.unparseableSectionIds);
|
|
443
474
|
const dirtyRows = await getDirtySectionRows();
|
|
444
475
|
const dirtyIds = new Set(dirtyRows.map((r) => r.sectionId));
|
|
445
476
|
|
|
@@ -771,6 +802,7 @@ export default function EditorShell({
|
|
|
771
802
|
siteConfig,
|
|
772
803
|
sections,
|
|
773
804
|
deletedSectionIds: Array.from(deletedSections),
|
|
805
|
+
onSaveWarnings: setSaveWarnings,
|
|
774
806
|
onSuccess: (savedSectionIds: string[], { editToken, sentDeletedIds }) => {
|
|
775
807
|
// Generation-guarded post-save clears: any edit made WHILE the save was
|
|
776
808
|
// in flight (generation > the token captured at save begin) was NOT
|
|
@@ -805,6 +837,22 @@ export default function EditorShell({
|
|
|
805
837
|
},
|
|
806
838
|
mediaManifest,
|
|
807
839
|
mediaManifestRef,
|
|
840
|
+
documentManifest: documentManifest ?? {},
|
|
841
|
+
pendingDocuments: mediaPipeline.pendingDocuments,
|
|
842
|
+
onDocumentsPublished: (ids) => { void mediaPipeline.publishDocuments(ids); },
|
|
843
|
+
pendingDocumentDeletions: mediaPipeline.pendingDocumentDeletions,
|
|
844
|
+
// Fires only for ids the commit actually carried — a failed save leaves the
|
|
845
|
+
// pending list intact for the retry.
|
|
846
|
+
onDocumentsDeleted: (ids) => {
|
|
847
|
+
const doomed = new Set(ids);
|
|
848
|
+
setDocumentManifest((prev) => {
|
|
849
|
+
if (prev === null) return prev;
|
|
850
|
+
const next = { ...prev };
|
|
851
|
+
for (const id of doomed) delete next[id];
|
|
852
|
+
return next;
|
|
853
|
+
});
|
|
854
|
+
mediaPipeline.setPendingDocumentDeletions((prev) => prev.filter((id) => !doomed.has(id)));
|
|
855
|
+
},
|
|
808
856
|
manifestDirty: mediaPipeline.manifestDirty,
|
|
809
857
|
clearManifestDirty: () => mediaPipeline.setManifestDirty(false),
|
|
810
858
|
pendingMediaItems: mediaPipeline.pendingMediaItems,
|
|
@@ -874,18 +922,61 @@ export default function EditorShell({
|
|
|
874
922
|
hasLocalChanges: localChangesExist,
|
|
875
923
|
});
|
|
876
924
|
|
|
877
|
-
//
|
|
925
|
+
// The live draft's documents. Editor mode: no audience filtering — an editor
|
|
926
|
+
// sees every document, including drafts and gated ones.
|
|
927
|
+
//
|
|
928
|
+
// Documents STAGED this session are merged on top of the committed manifest
|
|
929
|
+
// and their gated URL is swapped for the local blob URL. That substitution is
|
|
930
|
+
// the entire pre-save preview mechanism (reconciliation decision 3): the row
|
|
931
|
+
// and the pane render whatever `url` the registry holds, so neither of them
|
|
932
|
+
// needs MediaLibraryContext — which the viewer path does not provide at all.
|
|
933
|
+
// null until /api/content resolves (C3's state contract): publishing a list
|
|
934
|
+
// derived from a not-yet-loaded manifest would overwrite the overlay island's
|
|
935
|
+
// SSR seed with [] and flash every document row to "File missing".
|
|
936
|
+
const siteDocuments = useMemo<SiteDocument[] | null>(() => {
|
|
937
|
+
if (documentManifest === null) return null;
|
|
938
|
+
const pending = mediaPipeline.pendingDocuments;
|
|
939
|
+
const merged: DocumentManifest = {
|
|
940
|
+
...documentManifest,
|
|
941
|
+
...Object.fromEntries(pending.map((d) => [d.id, d.entry])),
|
|
942
|
+
};
|
|
943
|
+
// Deletions are pending until a save commits them, so `documentManifest`
|
|
944
|
+
// still holds them — this consumption point is where they disappear.
|
|
945
|
+
for (const id of mediaPipeline.pendingDocumentDeletions) delete merged[id];
|
|
946
|
+
const localUrls = mediaPipeline.pendingDocumentUrls;
|
|
947
|
+
return deriveSiteDocuments({
|
|
948
|
+
index: siteIndex, sections, manifest: merged, mode: "editor", audience: null,
|
|
949
|
+
}).map((doc) => {
|
|
950
|
+
const localUrl = localUrls[doc.documentId];
|
|
951
|
+
return localUrl ? { ...doc, url: localUrl } : doc;
|
|
952
|
+
});
|
|
953
|
+
}, [siteIndex, sections, documentManifest, mediaPipeline.pendingDocuments, mediaPipeline.pendingDocumentUrls, mediaPipeline.pendingDocumentDeletions]);
|
|
954
|
+
|
|
955
|
+
// Publish to the doc-file registry so the DocFileOverlay island (a separate
|
|
956
|
+
// island, mounted by the edit route) and every document row track the draft
|
|
957
|
+
// rather than the build-time SSR list.
|
|
958
|
+
useEffect(() => {
|
|
959
|
+
if (siteDocuments !== null) setSiteDocuments(siteDocuments);
|
|
960
|
+
}, [siteDocuments]);
|
|
961
|
+
|
|
962
|
+
// Push the current SiteNav (active page + its headings + the Documents group)
|
|
963
|
+
// to the Navigation island.
|
|
878
964
|
useEffect(() => {
|
|
879
965
|
if (!resolvedActivePage) return;
|
|
880
966
|
const nav = generateSiteNav({
|
|
881
967
|
index: siteIndex, activeSections, activePageId: resolvedActivePage.id, mode: "editor", audience: null,
|
|
968
|
+
documents: siteDocuments ?? [],
|
|
882
969
|
});
|
|
883
970
|
siteNavChangeEvent.dispatch(nav);
|
|
884
|
-
}, [siteIndex, activeSections, resolvedActivePage]);
|
|
971
|
+
}, [siteIndex, activeSections, resolvedActivePage, siteDocuments]);
|
|
885
972
|
|
|
886
973
|
const goToPage = useCallback((pageId: string) => {
|
|
887
974
|
const page = pageById(siteIndexRef.current, pageId);
|
|
888
975
|
if (!page) return;
|
|
976
|
+
// Client-side page switching pushes state without a hashchange, so a stale
|
|
977
|
+
// #doc fragment would survive and the pane would hang over the new page.
|
|
978
|
+
// The atom clears the fragment and closes.
|
|
979
|
+
closeDocFile();
|
|
889
980
|
setActivePageId(pageId);
|
|
890
981
|
if (typeof window !== "undefined") {
|
|
891
982
|
const path = page.slug === "" ? "/edit" : `/edit/${page.slug}`;
|
|
@@ -1012,10 +1103,12 @@ export default function EditorShell({
|
|
|
1012
1103
|
loadedManifest = data.mediaManifest;
|
|
1013
1104
|
await persistMediaManifest(loadedManifest);
|
|
1014
1105
|
}
|
|
1106
|
+
setDocumentManifest(data.documentManifest ?? {});
|
|
1015
1107
|
setSavedSha(data.savedBranchSha);
|
|
1016
1108
|
setMainSha(headSha);
|
|
1017
1109
|
setChangedSectionIds(new Set(data.changedSectionIds));
|
|
1018
1110
|
setMainIndex(data.mainIndex);
|
|
1111
|
+
setUnparseableSectionIds(data.unparseableSectionIds);
|
|
1019
1112
|
}
|
|
1020
1113
|
|
|
1021
1114
|
if (cancelled) return;
|
|
@@ -1072,6 +1165,11 @@ export default function EditorShell({
|
|
|
1072
1165
|
return () => { cancelled = true; };
|
|
1073
1166
|
}, [headSha, draftHeadSha, siteId, applySiteConfigPreview, fetchSavedContent]);
|
|
1074
1167
|
|
|
1168
|
+
useEffect(() => {
|
|
1169
|
+
void mediaPipeline.hydratePendingDocuments();
|
|
1170
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot restore of last session's staged documents
|
|
1171
|
+
}, []);
|
|
1172
|
+
|
|
1075
1173
|
// --- Recovery handlers ---
|
|
1076
1174
|
|
|
1077
1175
|
const handleRestore = useCallback(async () => {
|
|
@@ -1271,11 +1369,16 @@ export default function EditorShell({
|
|
|
1271
1369
|
const onReorderSections = useCallback(
|
|
1272
1370
|
(fromIndex: number, toIndex: number) => {
|
|
1273
1371
|
if (!resolvedActivePage) return;
|
|
1274
|
-
|
|
1372
|
+
// Consumers emit indices into activeSections — page.order FILTERED to
|
|
1373
|
+
// loadable bodies. Resolve through ids so a filtered-out section
|
|
1374
|
+
// (unparseable file, schema miss) can't shift the splice onto the wrong
|
|
1375
|
+
// entry and move a different section than the one dragged.
|
|
1376
|
+
const visibleIds = activeSections.map((s) => s.section.id);
|
|
1377
|
+
setSiteIndex((prev) => reorderVisibleSectionInPage(prev, resolvedActivePage.id, visibleIds, fromIndex, toIndex));
|
|
1275
1378
|
persistence.markIndexDirty();
|
|
1276
1379
|
setLocalChangesExist(true);
|
|
1277
1380
|
},
|
|
1278
|
-
[persistence, resolvedActivePage],
|
|
1381
|
+
[persistence, resolvedActivePage, activeSections],
|
|
1279
1382
|
);
|
|
1280
1383
|
|
|
1281
1384
|
const onBlockMove = useMemo(
|
|
@@ -1603,9 +1706,10 @@ export default function EditorShell({
|
|
|
1603
1706
|
<EditorModalProvider>
|
|
1604
1707
|
<MediaLibraryContext.Provider value={{
|
|
1605
1708
|
...mediaPipeline.contextValue,
|
|
1606
|
-
openSelectModal: (onSelect) => {
|
|
1709
|
+
openSelectModal: (onSelect, opts) => {
|
|
1607
1710
|
setMediaModalMode("select");
|
|
1608
1711
|
setMediaSelectCallback(() => onSelect);
|
|
1712
|
+
setMediaSelectKinds(opts?.kinds ?? null);
|
|
1609
1713
|
setShowMediaLibrary(true);
|
|
1610
1714
|
},
|
|
1611
1715
|
reportRejectedUploads: (files) => setRejectedUploads(files),
|
|
@@ -1720,6 +1824,24 @@ export default function EditorShell({
|
|
|
1720
1824
|
</div>
|
|
1721
1825
|
)}
|
|
1722
1826
|
|
|
1827
|
+
{unparseableSectionIds.length > 0 && (
|
|
1828
|
+
<div className="sticky top-2 z-30 mx-auto w-full max-w-3xl px-4">
|
|
1829
|
+
<UploadRejectionAlert
|
|
1830
|
+
message={`${unparseableSectionIds.length} section file${unparseableSectionIds.length === 1 ? "" : "s"} on this site cannot be read (${unparseableSectionIds.join(", ")}). Damaged sections keep their place on the page and are not deleted by saving — repair the file to restore them.`}
|
|
1831
|
+
onDismiss={() => setUnparseableSectionIds([])}
|
|
1832
|
+
/>
|
|
1833
|
+
</div>
|
|
1834
|
+
)}
|
|
1835
|
+
|
|
1836
|
+
{saveWarnings.length > 0 && (
|
|
1837
|
+
<div className="sticky top-2 z-30 mx-auto w-full max-w-3xl px-4">
|
|
1838
|
+
<UploadRejectionAlert
|
|
1839
|
+
message={saveWarnings.join(" ")}
|
|
1840
|
+
onDismiss={() => setSaveWarnings([])}
|
|
1841
|
+
/>
|
|
1842
|
+
</div>
|
|
1843
|
+
)}
|
|
1844
|
+
|
|
1723
1845
|
{remoteNotice && (
|
|
1724
1846
|
<div
|
|
1725
1847
|
role="status"
|
|
@@ -1815,6 +1937,7 @@ export default function EditorShell({
|
|
|
1815
1937
|
onClose={() => {
|
|
1816
1938
|
setShowMediaLibrary(false);
|
|
1817
1939
|
setMediaSelectCallback(null);
|
|
1940
|
+
setMediaSelectKinds(null);
|
|
1818
1941
|
}}
|
|
1819
1942
|
title="Media Library"
|
|
1820
1943
|
size="large"
|
|
@@ -1828,17 +1951,34 @@ export default function EditorShell({
|
|
|
1828
1951
|
...Object.values(mediaManifest.images),
|
|
1829
1952
|
...mediaPipeline.pendingMediaItems,
|
|
1830
1953
|
].filter((item) => !mediaPipeline.pendingDeletions.includes(item.id) && !item.hidden)}
|
|
1954
|
+
documents={Object.entries({
|
|
1955
|
+
...documentManifest,
|
|
1956
|
+
...Object.fromEntries(mediaPipeline.pendingDocuments.map((d) => [d.id, d.entry])),
|
|
1957
|
+
})
|
|
1958
|
+
// Deleted-but-unsaved documents stay in the manifest until the
|
|
1959
|
+
// save commits them; the library must not offer them again.
|
|
1960
|
+
.filter(([id]) => !mediaPipeline.pendingDocumentDeletions.includes(id))
|
|
1961
|
+
.map(([id, entry]) => ({
|
|
1962
|
+
id,
|
|
1963
|
+
filename: entry.filename,
|
|
1964
|
+
size: entry.size,
|
|
1965
|
+
kind: entry.kind,
|
|
1966
|
+
}))}
|
|
1967
|
+
kinds={mediaModalMode === "manage" ? undefined : (mediaSelectKinds ?? undefined)}
|
|
1831
1968
|
onSelect={(id) => {
|
|
1832
1969
|
if (mediaSelectCallback) {
|
|
1833
1970
|
mediaSelectCallback(id);
|
|
1834
1971
|
}
|
|
1835
1972
|
setShowMediaLibrary(false);
|
|
1836
1973
|
setMediaSelectCallback(null);
|
|
1974
|
+
setMediaSelectKinds(null);
|
|
1837
1975
|
}}
|
|
1838
1976
|
onUpload={mediaPipeline.handleMediaUpload}
|
|
1839
1977
|
onDelete={mediaPipeline.handleMediaDelete}
|
|
1978
|
+
onDeleteDocuments={(ids) => { void mediaPipeline.handleDocumentDelete(ids); }}
|
|
1840
1979
|
onAltChange={mediaPipeline.handleMediaAltChange}
|
|
1841
1980
|
referenceCountMap={mediaPipeline.referenceCountMap}
|
|
1981
|
+
documentReferenceCountMap={mediaPipeline.documentReferenceCountMap}
|
|
1842
1982
|
localUrlMap={mediaPipeline.pendingLocalUrls}
|
|
1843
1983
|
maxFileSize={siteConfig?.media.maxFileSize}
|
|
1844
1984
|
uploadProgress={mediaPipeline.uploadProgress}
|
|
@@ -1983,7 +2123,8 @@ function HistoryWatcher({
|
|
|
1983
2123
|
siteConfig: data.siteConfig,
|
|
1984
2124
|
});
|
|
1985
2125
|
siteNavChangeEvent.dispatch(
|
|
1986
|
-
|
|
2126
|
+
// History views hold no document manifest for that commit — no group.
|
|
2127
|
+
generateSiteNav({ index: data.index, activeSections: [], activePageId: null, mode: "editor", audience: null, documents: [] }),
|
|
1987
2128
|
);
|
|
1988
2129
|
} catch (err) {
|
|
1989
2130
|
console.error("Failed to load historical content:", err);
|
|
@@ -2034,7 +2175,10 @@ function HistoryOrEditorContent({
|
|
|
2034
2175
|
wasInHistory.current = false;
|
|
2035
2176
|
if (index.pages.length > 0) {
|
|
2036
2177
|
siteNavChangeEvent.dispatch(
|
|
2037
|
-
|
|
2178
|
+
// Leaving history: the live nav effect re-dispatches with the current
|
|
2179
|
+
// document list on the next render, so this one deliberately omits it
|
|
2180
|
+
// rather than briefly showing a stale group.
|
|
2181
|
+
generateSiteNav({ index, activeSections, activePageId, mode: "editor", audience: null, documents: [] }),
|
|
2038
2182
|
);
|
|
2039
2183
|
}
|
|
2040
2184
|
}
|
|
@@ -2046,7 +2190,7 @@ function HistoryOrEditorContent({
|
|
|
2046
2190
|
if (!historyState || !historyPage) return;
|
|
2047
2191
|
if (!historyPageId) setHistoryPageId(historyPage.id);
|
|
2048
2192
|
siteNavChangeEvent.dispatch(
|
|
2049
|
-
generateSiteNav({ index: historyState.index, activeSections: [], activePageId: historyPage.id, mode: "editor", audience: null }),
|
|
2193
|
+
generateSiteNav({ index: historyState.index, activeSections: [], activePageId: historyPage.id, mode: "editor", audience: null, documents: [] }),
|
|
2050
2194
|
);
|
|
2051
2195
|
}, [historyState, historyPage?.id]);
|
|
2052
2196
|
|
|
@@ -2,7 +2,7 @@ import { Fragment, useRef } from "react";
|
|
|
2
2
|
import { ChevronRight } from "lucide-react";
|
|
3
3
|
import { cn } from "../../lib/cn";
|
|
4
4
|
import type { MediaFolder, MediaItem } from "../../media/types";
|
|
5
|
-
import { folderPathIds } from "../../lib/media-folders";
|
|
5
|
+
import { DOCUMENTS_FOLDER_ID, folderPathIds } from "../../lib/media-folders";
|
|
6
6
|
import { useFolderDropTarget } from "../../hooks/useMediaFolderDnd";
|
|
7
7
|
import type { MediaFolderDndBindings } from "./MediaFolderTree";
|
|
8
8
|
|
|
@@ -55,6 +55,19 @@ function Segment({
|
|
|
55
55
|
|
|
56
56
|
export function MediaBreadcrumb({ folders, currentFolderId, onNavigate, dnd }: MediaBreadcrumbProps) {
|
|
57
57
|
if (currentFolderId === null) return null;
|
|
58
|
+
|
|
59
|
+
// The Documents pseudo-folder has no `folders` entry, so the path helpers
|
|
60
|
+
// return nothing for it — its crumb is static, and always a leaf.
|
|
61
|
+
if (currentFolderId === DOCUMENTS_FOLDER_ID) {
|
|
62
|
+
return (
|
|
63
|
+
<div className="flex flex-wrap items-center gap-0.5">
|
|
64
|
+
<Segment label="All media" target={null} onNavigate={onNavigate} dnd={dnd} isCurrent={false} />
|
|
65
|
+
<ChevronRight size={12} className="shrink-0 text-base-contrast-light" />
|
|
66
|
+
<Segment label="Documents" target={DOCUMENTS_FOLDER_ID} onNavigate={onNavigate} dnd={dnd} isCurrent />
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
58
71
|
const pathIds = folderPathIds(folders, currentFolderId);
|
|
59
72
|
|
|
60
73
|
return (
|
|
@@ -17,11 +17,13 @@ export interface MediaFolderTileProps {
|
|
|
17
17
|
dnd?: MediaFolderDndBindings;
|
|
18
18
|
/** Optional accessible label override for the root element (e.g. "Open folder Logos"). */
|
|
19
19
|
ariaLabel?: string;
|
|
20
|
+
/** Glyph override — the Documents pseudo-folder uses a file icon. */
|
|
21
|
+
icon?: React.ReactNode;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
|
|
23
25
|
export function MediaFolderTile({
|
|
24
|
-
id, name, count, onOpen, manage, onRename, onDeleteRequest, dnd, ariaLabel,
|
|
26
|
+
id, name, count, onOpen, manage, onRename, onDeleteRequest, dnd, ariaLabel, icon,
|
|
25
27
|
}: MediaFolderTileProps) {
|
|
26
28
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
27
29
|
const [renaming, setRenaming] = useState(false);
|
|
@@ -80,7 +82,7 @@ export function MediaFolderTile({
|
|
|
80
82
|
drop.isDropTarget && "border-primary ring-2 ring-primary ring-inset",
|
|
81
83
|
)}
|
|
82
84
|
>
|
|
83
|
-
<Folder size={16} className="shrink-0 text-base-contrast-light" />
|
|
85
|
+
{icon ?? <Folder size={16} className="shrink-0 text-base-contrast-light" />}
|
|
84
86
|
{renaming ? (
|
|
85
87
|
<input
|
|
86
88
|
autoFocus
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { useRef } from "react";
|
|
2
|
-
import { ChevronRight, Folder, LayoutGrid } from "lucide-react";
|
|
2
|
+
import { ChevronRight, FileText, Folder, LayoutGrid } from "lucide-react";
|
|
3
3
|
import { cn } from "../../lib/cn";
|
|
4
4
|
import type { MediaFolder, MediaItem } from "../../media/types";
|
|
5
|
-
import { buildFolderTree, type FolderTreeNode } from "../../lib/media-folders";
|
|
5
|
+
import { buildFolderTree, DOCUMENTS_FOLDER_ID, type FolderTreeNode } from "../../lib/media-folders";
|
|
6
6
|
import { useFolderDropTarget } from "../../hooks/useMediaFolderDnd";
|
|
7
7
|
|
|
8
8
|
export interface MediaFolderDndBindings {
|
|
@@ -22,6 +22,9 @@ export interface MediaFolderTreeProps {
|
|
|
22
22
|
onExpand: (id: string) => void;
|
|
23
23
|
onToggleExpand: (id: string) => void;
|
|
24
24
|
dnd?: MediaFolderDndBindings;
|
|
25
|
+
/** Documents pseudo-folder row. Omitted (or 0) renders no row; it is never a
|
|
26
|
+
* drop target — nothing can be moved into it. */
|
|
27
|
+
documentCount?: number;
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
|
|
@@ -99,7 +102,7 @@ function TreeRow({
|
|
|
99
102
|
}
|
|
100
103
|
|
|
101
104
|
export function MediaFolderTree(props: MediaFolderTreeProps) {
|
|
102
|
-
const { folders, totalCount, currentFolderId, onNavigate, dnd } = props;
|
|
105
|
+
const { folders, totalCount, currentFolderId, onNavigate, dnd, documentCount = 0 } = props;
|
|
103
106
|
const tree = buildFolderTree(folders);
|
|
104
107
|
const root = useRowDropTarget(null, dnd);
|
|
105
108
|
|
|
@@ -119,6 +122,20 @@ export function MediaFolderTree(props: MediaFolderTreeProps) {
|
|
|
119
122
|
<span className="shrink-0 text-xs text-base-contrast-light">{totalCount}</span>
|
|
120
123
|
)}
|
|
121
124
|
</div>
|
|
125
|
+
{documentCount > 0 && (
|
|
126
|
+
<div
|
|
127
|
+
role="button"
|
|
128
|
+
tabIndex={0}
|
|
129
|
+
aria-label="Documents"
|
|
130
|
+
onClick={() => onNavigate(DOCUMENTS_FOLDER_ID)}
|
|
131
|
+
onKeyDown={(e) => { if (e.key === "Enter") onNavigate(DOCUMENTS_FOLDER_ID); }}
|
|
132
|
+
className={rowClass(currentFolderId === DOCUMENTS_FOLDER_ID, false)}
|
|
133
|
+
>
|
|
134
|
+
<FileText size={14} className="shrink-0 text-base-contrast-light" />
|
|
135
|
+
<span className="min-w-0 flex-1 truncate">Documents</span>
|
|
136
|
+
<span className="shrink-0 text-xs text-base-contrast-light">{documentCount}</span>
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
122
139
|
{tree.map((node) => (
|
|
123
140
|
<TreeRow key={node.id} node={node} depth={0} props={props} />
|
|
124
141
|
))}
|
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
import { createContext, useContext } from "react";
|
|
2
2
|
import type { MediaManifest, MediaItem } from "../../media/types";
|
|
3
3
|
import type { SiteConfig } from "../../schemas/site-config";
|
|
4
|
+
import type { DocumentManifest, DocumentManifestEntry } from "../../schemas/documents";
|
|
5
|
+
|
|
6
|
+
/** Kinds the media library can be filtered to when opened as a picker. */
|
|
7
|
+
export type LibraryKind = "image" | "animated" | "video" | "document";
|
|
8
|
+
|
|
9
|
+
/** A document staged locally this session — bytes (git tier) or bucket object
|
|
10
|
+
* already PUT — that is not yet in the committed document manifest. */
|
|
11
|
+
export interface PendingDocument {
|
|
12
|
+
id: string;
|
|
13
|
+
entry: DocumentManifestEntry;
|
|
14
|
+
}
|
|
4
15
|
|
|
5
16
|
export interface MediaLibraryContextValue {
|
|
6
|
-
|
|
17
|
+
/** `opts.kinds` restricts the picker; omitted keeps the historical
|
|
18
|
+
* image/animated/video behavior for every pre-existing call site. */
|
|
19
|
+
openSelectModal: (onSelect: (imageId: string) => void, opts?: { kinds?: ReadonlyArray<LibraryKind> }) => void;
|
|
7
20
|
uploadFile: (file: File) => void;
|
|
8
21
|
uploadFileWithCallback: (file: File, onComplete: (imageId: string) => void, opts?: { hidden?: boolean }) => string;
|
|
9
22
|
reportRejectedUploads: (files: { name: string; reason: "size" | "type" }[]) => void;
|
|
@@ -11,6 +24,10 @@ export interface MediaLibraryContextValue {
|
|
|
11
24
|
pendingItems: MediaItem[];
|
|
12
25
|
pendingLocalUrls: Record<string, string>;
|
|
13
26
|
siteConfig: SiteConfig | null;
|
|
27
|
+
/** Sidecar document manifest. Optional: viewer-side and test contexts
|
|
28
|
+
* predate documents and must keep compiling. */
|
|
29
|
+
documentManifest?: DocumentManifest;
|
|
30
|
+
pendingDocuments?: PendingDocument[];
|
|
14
31
|
}
|
|
15
32
|
|
|
16
33
|
export const MediaLibraryContext = createContext<MediaLibraryContextValue | null>(null);
|