@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.
Files changed (138) hide show
  1. package/dist/{chunk-EUSWEVOA.js → chunk-3TO4RRHY.js} +12 -1
  2. package/dist/{chunk-KP4DWTPR.js → chunk-72FMQQIA.js} +246 -3
  3. package/dist/chunk-BS5AQHZG.js +45 -0
  4. package/dist/{chunk-NA5EJM6Z.js → chunk-CZLYECHC.js} +19 -8
  5. package/dist/{chunk-HKHEAH5F.js → chunk-GOCFTXR3.js} +8 -60
  6. package/dist/{chunk-GYCBWGVW.js → chunk-LBHQWIVL.js} +79 -24
  7. package/dist/{chunk-HJSE6SK5.js → chunk-REDDTTGJ.js} +3 -3
  8. package/dist/collab/rebase.d.ts.map +1 -1
  9. package/dist/collab/rebase.js +3 -1
  10. package/dist/components/chat/ChatPanel.d.ts.map +1 -1
  11. package/dist/components/docfile/DocFileOverlay.d.ts +12 -0
  12. package/dist/components/docfile/DocFileOverlay.d.ts.map +1 -0
  13. package/dist/components/docfile/DocFilePane.d.ts +17 -0
  14. package/dist/components/docfile/DocFilePane.d.ts.map +1 -0
  15. package/dist/components/docfile/doc-file-state.d.ts +33 -0
  16. package/dist/components/docfile/doc-file-state.d.ts.map +1 -0
  17. package/dist/components/editor/SettingsForm.d.ts.map +1 -1
  18. package/dist/components/editor/index.js +1 -1
  19. package/dist/components/sections/Document/index.d.ts +18 -0
  20. package/dist/components/sections/Document/index.d.ts.map +1 -0
  21. package/dist/components/sections/all-sections.d.ts +10 -0
  22. package/dist/components/sections/all-sections.d.ts.map +1 -1
  23. package/dist/components/sections/brand-guide.d.ts +10 -0
  24. package/dist/components/sections/brand-guide.d.ts.map +1 -1
  25. package/dist/components/sections/brand-guide.js +3 -1
  26. package/dist/components/sections/register-schemas.js +5 -3
  27. package/dist/components/sections/register.js +5 -3
  28. package/dist/components/shared/DocumentSourceField.d.ts +13 -0
  29. package/dist/components/shared/DocumentSourceField.d.ts.map +1 -0
  30. package/dist/components/shared/Navigation.d.ts.map +1 -1
  31. package/dist/components/shell/EditorModal.d.ts.map +1 -1
  32. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  33. package/dist/components/shell/MediaBreadcrumb.d.ts.map +1 -1
  34. package/dist/components/shell/MediaFolderTile.d.ts +3 -1
  35. package/dist/components/shell/MediaFolderTile.d.ts.map +1 -1
  36. package/dist/components/shell/MediaFolderTree.d.ts +3 -0
  37. package/dist/components/shell/MediaFolderTree.d.ts.map +1 -1
  38. package/dist/components/shell/MediaLibraryContext.d.ts +18 -1
  39. package/dist/components/shell/MediaLibraryContext.d.ts.map +1 -1
  40. package/dist/components/shell/MediaLibraryModal.d.ts +21 -1
  41. package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
  42. package/dist/hooks/useDocumentUploads.d.ts +32 -0
  43. package/dist/hooks/useDocumentUploads.d.ts.map +1 -0
  44. package/dist/hooks/useEditorPublish.d.ts +23 -1
  45. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  46. package/dist/hooks/useMediaPipeline.d.ts +14 -1
  47. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  48. package/dist/hooks/useResolvedDocument.d.ts +12 -0
  49. package/dist/hooks/useResolvedDocument.d.ts.map +1 -0
  50. package/dist/index.js +60 -34
  51. package/dist/lib/block-move.d.ts.map +1 -1
  52. package/dist/lib/dexie.d.ts +11 -0
  53. package/dist/lib/dexie.d.ts.map +1 -1
  54. package/dist/lib/dexie.js +32 -0
  55. package/dist/lib/document-refs.d.ts +22 -0
  56. package/dist/lib/document-refs.d.ts.map +1 -0
  57. package/dist/lib/document-uploads.d.ts +38 -0
  58. package/dist/lib/document-uploads.d.ts.map +1 -0
  59. package/dist/lib/documents.d.ts +65 -0
  60. package/dist/lib/documents.d.ts.map +1 -0
  61. package/dist/lib/index-ops.d.ts +1 -0
  62. package/dist/lib/index-ops.d.ts.map +1 -1
  63. package/dist/lib/index-ops.js +13 -1
  64. package/dist/lib/index.js +6 -6
  65. package/dist/lib/loader.d.ts +6 -0
  66. package/dist/lib/loader.d.ts.map +1 -1
  67. package/dist/lib/media-folders.d.ts +8 -0
  68. package/dist/lib/media-folders.d.ts.map +1 -1
  69. package/dist/lib/media-folders.js +2 -0
  70. package/dist/lib/nav.d.ts +29 -1
  71. package/dist/lib/nav.d.ts.map +1 -1
  72. package/dist/lib/pages.d.ts +18 -0
  73. package/dist/lib/pages.d.ts.map +1 -1
  74. package/dist/lib/platform-broker.d.ts +12 -0
  75. package/dist/lib/platform-broker.d.ts.map +1 -1
  76. package/dist/lib/platform-broker.js +12 -3
  77. package/dist/lib/registry.d.ts +10 -0
  78. package/dist/lib/registry.d.ts.map +1 -1
  79. package/dist/lib/save-chunks.d.ts +15 -0
  80. package/dist/lib/save-chunks.d.ts.map +1 -1
  81. package/dist/lib/scroll-lock.d.ts +2 -0
  82. package/dist/lib/scroll-lock.d.ts.map +1 -0
  83. package/dist/schemas/documents.d.ts +80 -0
  84. package/dist/schemas/documents.d.ts.map +1 -0
  85. package/dist/schemas/documents.js +25 -0
  86. package/dist/schemas/index.d.ts +1 -0
  87. package/dist/schemas/index.d.ts.map +1 -1
  88. package/dist/schemas/index.js +24 -2
  89. package/dist/storage/index.d.ts +2 -2
  90. package/dist/storage/index.d.ts.map +1 -1
  91. package/dist/storage/index.js +5 -1
  92. package/dist/storage/types.d.ts +69 -5
  93. package/dist/storage/types.d.ts.map +1 -1
  94. package/dist/types/database.d.ts +14 -0
  95. package/dist/types/database.d.ts.map +1 -1
  96. package/package.json +5 -1
  97. package/src/collab/rebase.ts +15 -0
  98. package/src/components/chat/ChatPanel.tsx +5 -4
  99. package/src/components/docfile/DocFileOverlay.tsx +54 -0
  100. package/src/components/docfile/DocFilePane.tsx +129 -0
  101. package/src/components/docfile/doc-file-state.ts +180 -0
  102. package/src/components/editor/SettingsForm.tsx +10 -0
  103. package/src/components/sections/Document/index.tsx +163 -0
  104. package/src/components/sections/brand-guide.ts +2 -1
  105. package/src/components/shared/DocumentSourceField.tsx +51 -0
  106. package/src/components/shared/Navigation.tsx +53 -10
  107. package/src/components/shell/EditorModal.tsx +2 -5
  108. package/src/components/shell/EditorShell.tsx +154 -10
  109. package/src/components/shell/MediaBreadcrumb.tsx +14 -1
  110. package/src/components/shell/MediaFolderTile.tsx +4 -2
  111. package/src/components/shell/MediaFolderTree.tsx +20 -3
  112. package/src/components/shell/MediaLibraryContext.tsx +18 -1
  113. package/src/components/shell/MediaLibraryModal.tsx +223 -45
  114. package/src/hooks/useDocumentUploads.ts +330 -0
  115. package/src/hooks/useEditorPublish.ts +197 -11
  116. package/src/hooks/useMediaPipeline.ts +136 -5
  117. package/src/hooks/useResolvedDocument.ts +25 -0
  118. package/src/lib/block-move.ts +15 -4
  119. package/src/lib/dexie.ts +54 -0
  120. package/src/lib/document-refs.ts +83 -0
  121. package/src/lib/document-uploads.ts +66 -0
  122. package/src/lib/documents.ts +138 -0
  123. package/src/lib/index-ops.ts +16 -0
  124. package/src/lib/loader.ts +32 -11
  125. package/src/lib/media-folders.ts +9 -0
  126. package/src/lib/nav.ts +46 -2
  127. package/src/lib/pages.ts +48 -0
  128. package/src/lib/platform-broker.ts +18 -3
  129. package/src/lib/registry.ts +9 -0
  130. package/src/lib/save-chunks.ts +42 -0
  131. package/src/lib/scroll-lock.ts +27 -0
  132. package/src/schemas/documents.ts +82 -0
  133. package/src/schemas/index.ts +1 -0
  134. package/src/schemas/nav.ts +3 -3
  135. package/src/storage/index.ts +2 -2
  136. package/src/storage/types.ts +71 -5
  137. package/src/types/database.ts +14 -0
  138. /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
- reorderSectionInPage, audiencePasses,
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
- // Push the current SiteNav (active page + its headings) to the Navigation island.
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
- setSiteIndex((prev) => reorderSectionInPage(prev, resolvedActivePage.id, fromIndex, toIndex));
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
- generateSiteNav({ index: data.index, activeSections: [], activePageId: null, mode: "editor", audience: null }),
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
- generateSiteNav({ index, activeSections, activePageId, mode: "editor", audience: null }),
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
- openSelectModal: (onSelect: (imageId: string) => void) => void;
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);