@drawnagency/primitives 0.1.99 → 0.1.101

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 (120) hide show
  1. package/dist/{chunk-FAZFADJK.js → chunk-3TO4RRHY.js} +3 -1
  2. package/dist/{chunk-HKHEAH5F.js → chunk-6SLGKRLW.js} +8 -60
  3. package/dist/chunk-BS5AQHZG.js +45 -0
  4. package/dist/{chunk-GYCBWGVW.js → chunk-LBHQWIVL.js} +79 -24
  5. package/dist/{chunk-KP4DWTPR.js → chunk-P3NNI3QI.js} +328 -4
  6. package/dist/components/chat/ChatPanel.d.ts.map +1 -1
  7. package/dist/components/docfile/DocFileOverlay.d.ts +12 -0
  8. package/dist/components/docfile/DocFileOverlay.d.ts.map +1 -0
  9. package/dist/components/docfile/DocFilePane.d.ts +17 -0
  10. package/dist/components/docfile/DocFilePane.d.ts.map +1 -0
  11. package/dist/components/docfile/doc-file-state.d.ts +38 -0
  12. package/dist/components/docfile/doc-file-state.d.ts.map +1 -0
  13. package/dist/components/editor/SettingsForm.d.ts.map +1 -1
  14. package/dist/components/editor/index.js +1 -1
  15. package/dist/components/primitives/DocumentDropZone.d.ts +18 -0
  16. package/dist/components/primitives/DocumentDropZone.d.ts.map +1 -0
  17. package/dist/components/sections/Document/index.d.ts +24 -0
  18. package/dist/components/sections/Document/index.d.ts.map +1 -0
  19. package/dist/components/sections/all-sections.d.ts +10 -0
  20. package/dist/components/sections/all-sections.d.ts.map +1 -1
  21. package/dist/components/sections/brand-guide.d.ts +10 -0
  22. package/dist/components/sections/brand-guide.d.ts.map +1 -1
  23. package/dist/components/sections/brand-guide.js +3 -1
  24. package/dist/components/sections/register-schemas.js +5 -3
  25. package/dist/components/sections/register.js +5 -3
  26. package/dist/components/shared/DocumentSourceField.d.ts +13 -0
  27. package/dist/components/shared/DocumentSourceField.d.ts.map +1 -0
  28. package/dist/components/shared/Navigation.d.ts.map +1 -1
  29. package/dist/components/shell/EditorModal.d.ts.map +1 -1
  30. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  31. package/dist/components/shell/MediaBreadcrumb.d.ts.map +1 -1
  32. package/dist/components/shell/MediaFolderTile.d.ts +3 -1
  33. package/dist/components/shell/MediaFolderTile.d.ts.map +1 -1
  34. package/dist/components/shell/MediaFolderTree.d.ts +3 -0
  35. package/dist/components/shell/MediaFolderTree.d.ts.map +1 -1
  36. package/dist/components/shell/MediaLibraryContext.d.ts +24 -1
  37. package/dist/components/shell/MediaLibraryContext.d.ts.map +1 -1
  38. package/dist/components/shell/MediaLibraryModal.d.ts +21 -1
  39. package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
  40. package/dist/hooks/useDocumentUploads.d.ts +32 -0
  41. package/dist/hooks/useDocumentUploads.d.ts.map +1 -0
  42. package/dist/hooks/useEditorPublish.d.ts +18 -1
  43. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  44. package/dist/hooks/useMediaPipeline.d.ts +14 -1
  45. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  46. package/dist/hooks/useResolvedDocument.d.ts +12 -0
  47. package/dist/hooks/useResolvedDocument.d.ts.map +1 -0
  48. package/dist/index.js +56 -32
  49. package/dist/lib/dexie.d.ts +13 -0
  50. package/dist/lib/dexie.d.ts.map +1 -1
  51. package/dist/lib/dexie.js +39 -2
  52. package/dist/lib/document-refs.d.ts +22 -0
  53. package/dist/lib/document-refs.d.ts.map +1 -0
  54. package/dist/lib/document-uploads.d.ts +38 -0
  55. package/dist/lib/document-uploads.d.ts.map +1 -0
  56. package/dist/lib/documents.d.ts +65 -0
  57. package/dist/lib/documents.d.ts.map +1 -0
  58. package/dist/lib/index.js +4 -4
  59. package/dist/lib/media-folders.d.ts +8 -0
  60. package/dist/lib/media-folders.d.ts.map +1 -1
  61. package/dist/lib/media-folders.js +2 -0
  62. package/dist/lib/nav.d.ts +29 -1
  63. package/dist/lib/nav.d.ts.map +1 -1
  64. package/dist/lib/platform-broker.d.ts +12 -0
  65. package/dist/lib/platform-broker.d.ts.map +1 -1
  66. package/dist/lib/platform-broker.js +12 -3
  67. package/dist/lib/registry.d.ts +10 -0
  68. package/dist/lib/registry.d.ts.map +1 -1
  69. package/dist/lib/save-chunks.d.ts +15 -0
  70. package/dist/lib/save-chunks.d.ts.map +1 -1
  71. package/dist/lib/scroll-lock.d.ts +2 -0
  72. package/dist/lib/scroll-lock.d.ts.map +1 -0
  73. package/dist/schemas/documents.d.ts +80 -0
  74. package/dist/schemas/documents.d.ts.map +1 -0
  75. package/dist/schemas/documents.js +25 -0
  76. package/dist/schemas/index.d.ts +1 -0
  77. package/dist/schemas/index.d.ts.map +1 -1
  78. package/dist/schemas/index.js +23 -1
  79. package/dist/storage/index.d.ts +2 -2
  80. package/dist/storage/index.d.ts.map +1 -1
  81. package/dist/storage/index.js +3 -1
  82. package/dist/storage/types.d.ts +59 -3
  83. package/dist/storage/types.d.ts.map +1 -1
  84. package/package.json +5 -1
  85. package/src/components/chat/ChatPanel.tsx +5 -4
  86. package/src/components/docfile/DocFileOverlay.tsx +54 -0
  87. package/src/components/docfile/DocFilePane.tsx +133 -0
  88. package/src/components/docfile/doc-file-state.ts +208 -0
  89. package/src/components/editor/SettingsForm.tsx +10 -0
  90. package/src/components/primitives/DocumentDropZone.tsx +79 -0
  91. package/src/components/sections/Document/index.tsx +194 -0
  92. package/src/components/sections/brand-guide.ts +2 -1
  93. package/src/components/shared/DocumentSourceField.tsx +51 -0
  94. package/src/components/shared/Navigation.tsx +53 -10
  95. package/src/components/shell/EditorModal.tsx +2 -5
  96. package/src/components/shell/EditorShell.tsx +132 -8
  97. package/src/components/shell/MediaBreadcrumb.tsx +14 -1
  98. package/src/components/shell/MediaFolderTile.tsx +4 -2
  99. package/src/components/shell/MediaFolderTree.tsx +20 -3
  100. package/src/components/shell/MediaLibraryContext.tsx +24 -1
  101. package/src/components/shell/MediaLibraryModal.tsx +223 -45
  102. package/src/hooks/useDocumentUploads.ts +336 -0
  103. package/src/hooks/useEditorPublish.ts +180 -8
  104. package/src/hooks/useMediaPipeline.ts +141 -5
  105. package/src/hooks/useResolvedDocument.ts +25 -0
  106. package/src/lib/dexie.ts +66 -1
  107. package/src/lib/document-refs.ts +83 -0
  108. package/src/lib/document-uploads.ts +66 -0
  109. package/src/lib/documents.ts +138 -0
  110. package/src/lib/media-folders.ts +9 -0
  111. package/src/lib/nav.ts +46 -2
  112. package/src/lib/platform-broker.ts +18 -3
  113. package/src/lib/registry.ts +9 -0
  114. package/src/lib/save-chunks.ts +42 -0
  115. package/src/lib/scroll-lock.ts +27 -0
  116. package/src/schemas/documents.ts +82 -0
  117. package/src/schemas/index.ts +1 -0
  118. package/src/storage/index.ts +2 -2
  119. package/src/storage/types.ts +58 -4
  120. /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,6 +90,8 @@ 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 {
@@ -137,6 +140,9 @@ 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;
140
146
  /** Indexed ids whose section file exists on the branch but is not valid JSON.
141
147
  * Their bodies are absent from `sections` while their index slots survive —
142
148
  * the editor must surface them, not silently render around them. */
@@ -196,9 +202,16 @@ export default function EditorShell({
196
202
  const [dirtySectionIds, setDirtySectionIds] = useState<Set<string>>(new Set());
197
203
  const [siteConfig, setSiteConfig] = useState<SiteConfig | null>(null);
198
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);
199
209
  const [showMediaLibrary, setShowMediaLibrary] = useState(false);
200
210
  const [mediaModalMode, setMediaModalMode] = useState<"select" | "manage">("manage");
201
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);
202
215
  const [deletedSections, setDeletedSections] = useState<Set<string>>(new Set());
203
216
  // Ref mirror so the debounced Dexie flush can persist the deletion set. Without
204
217
  // it the snapshot records the index minus the deleted sections but not the
@@ -234,7 +247,8 @@ export default function EditorShell({
234
247
  const [rejectedUploads, setRejectedUploads] = useState<{ name: string; reason: "size" | "type" }[]>([]);
235
248
  // Surfaces useMediaPipeline's onUploadError (oversized/disallowed files,
236
249
  // upload-intent failures, direct-to-bucket PUT failures) in the media
237
- // library modal, alongside its existing client-side rejection banner.
250
+ // library modal, alongside its existing client-side rejection banner — and at
251
+ // shell level while that modal is closed, for uploads started from a section.
238
252
  const [uploadErrorMessage, setUploadErrorMessage] = useState<string | null>(null);
239
253
  // Surfaces useMediaPipeline's onUploadNotice (e.g. "N files already exist
240
254
  // and were left where they are" on dedup skip) in the media library modal.
@@ -410,6 +424,8 @@ export default function EditorShell({
410
424
  siteConfig,
411
425
  mediaManifest,
412
426
  setMediaManifest,
427
+ documentManifest: documentManifest ?? {},
428
+ setDocumentManifest: (next) => setDocumentManifest((prev) => typeof next === "function" ? next(prev ?? {}) : next),
413
429
  sections,
414
430
  setSections,
415
431
  setLocalChangesExist,
@@ -441,6 +457,7 @@ export default function EditorShell({
441
457
  changedSectionIds: (data.changedSectionIds ?? []) as string[],
442
458
  mainIndex: (data.mainIndex ?? data.index) as SiteIndex,
443
459
  mediaManifest: data.mediaManifest as MediaManifest | undefined,
460
+ documentManifest: data.documentManifest as DocumentManifest | undefined,
444
461
  unparseableSectionIds: (data.unparseableSectionIds ?? []) as string[],
445
462
  };
446
463
  }, []);
@@ -821,6 +838,22 @@ export default function EditorShell({
821
838
  },
822
839
  mediaManifest,
823
840
  mediaManifestRef,
841
+ documentManifest: documentManifest ?? {},
842
+ pendingDocuments: mediaPipeline.pendingDocuments,
843
+ onDocumentsPublished: (ids) => { void mediaPipeline.publishDocuments(ids); },
844
+ pendingDocumentDeletions: mediaPipeline.pendingDocumentDeletions,
845
+ // Fires only for ids the commit actually carried — a failed save leaves the
846
+ // pending list intact for the retry.
847
+ onDocumentsDeleted: (ids) => {
848
+ const doomed = new Set(ids);
849
+ setDocumentManifest((prev) => {
850
+ if (prev === null) return prev;
851
+ const next = { ...prev };
852
+ for (const id of doomed) delete next[id];
853
+ return next;
854
+ });
855
+ mediaPipeline.setPendingDocumentDeletions((prev) => prev.filter((id) => !doomed.has(id)));
856
+ },
824
857
  manifestDirty: mediaPipeline.manifestDirty,
825
858
  clearManifestDirty: () => mediaPipeline.setManifestDirty(false),
826
859
  pendingMediaItems: mediaPipeline.pendingMediaItems,
@@ -890,18 +923,61 @@ export default function EditorShell({
890
923
  hasLocalChanges: localChangesExist,
891
924
  });
892
925
 
893
- // Push the current SiteNav (active page + its headings) to the Navigation island.
926
+ // The live draft's documents. Editor mode: no audience filtering — an editor
927
+ // sees every document, including drafts and gated ones.
928
+ //
929
+ // Documents STAGED this session are merged on top of the committed manifest
930
+ // and their gated URL is swapped for the local blob URL. That substitution is
931
+ // the entire pre-save preview mechanism (reconciliation decision 3): the row
932
+ // and the pane render whatever `url` the registry holds, so neither of them
933
+ // needs MediaLibraryContext — which the viewer path does not provide at all.
934
+ // null until /api/content resolves (C3's state contract): publishing a list
935
+ // derived from a not-yet-loaded manifest would overwrite the overlay island's
936
+ // SSR seed with [] and flash every document row to "File missing".
937
+ const siteDocuments = useMemo<SiteDocument[] | null>(() => {
938
+ if (documentManifest === null) return null;
939
+ const pending = mediaPipeline.pendingDocuments;
940
+ const merged: DocumentManifest = {
941
+ ...documentManifest,
942
+ ...Object.fromEntries(pending.map((d) => [d.id, d.entry])),
943
+ };
944
+ // Deletions are pending until a save commits them, so `documentManifest`
945
+ // still holds them — this consumption point is where they disappear.
946
+ for (const id of mediaPipeline.pendingDocumentDeletions) delete merged[id];
947
+ const localUrls = mediaPipeline.pendingDocumentUrls;
948
+ return deriveSiteDocuments({
949
+ index: siteIndex, sections, manifest: merged, mode: "editor", audience: null,
950
+ }).map((doc) => {
951
+ const localUrl = localUrls[doc.documentId];
952
+ return localUrl ? { ...doc, url: localUrl } : doc;
953
+ });
954
+ }, [siteIndex, sections, documentManifest, mediaPipeline.pendingDocuments, mediaPipeline.pendingDocumentUrls, mediaPipeline.pendingDocumentDeletions]);
955
+
956
+ // Publish to the doc-file registry so the DocFileOverlay island (a separate
957
+ // island, mounted by the edit route) and every document row track the draft
958
+ // rather than the build-time SSR list.
959
+ useEffect(() => {
960
+ if (siteDocuments !== null) setSiteDocuments(siteDocuments);
961
+ }, [siteDocuments]);
962
+
963
+ // Push the current SiteNav (active page + its headings + the Documents group)
964
+ // to the Navigation island.
894
965
  useEffect(() => {
895
966
  if (!resolvedActivePage) return;
896
967
  const nav = generateSiteNav({
897
968
  index: siteIndex, activeSections, activePageId: resolvedActivePage.id, mode: "editor", audience: null,
969
+ documents: siteDocuments ?? [],
898
970
  });
899
971
  siteNavChangeEvent.dispatch(nav);
900
- }, [siteIndex, activeSections, resolvedActivePage]);
972
+ }, [siteIndex, activeSections, resolvedActivePage, siteDocuments]);
901
973
 
902
974
  const goToPage = useCallback((pageId: string) => {
903
975
  const page = pageById(siteIndexRef.current, pageId);
904
976
  if (!page) return;
977
+ // Client-side page switching pushes state without a hashchange, so a stale
978
+ // #doc fragment would survive and the pane would hang over the new page.
979
+ // The atom clears the fragment and closes.
980
+ closeDocFile();
905
981
  setActivePageId(pageId);
906
982
  if (typeof window !== "undefined") {
907
983
  const path = page.slug === "" ? "/edit" : `/edit/${page.slug}`;
@@ -1014,6 +1090,10 @@ export default function EditorShell({
1014
1090
  setMainSha(headSha);
1015
1091
  setChangedSectionIds(new Set(cached.changedSectionIds ?? []));
1016
1092
  setMainIndex(cached.mainIndex ?? loadedIndex);
1093
+ // Without this, a cache-hit load left documentManifest null forever —
1094
+ // the siteDocuments memo's null-guard then never lifted and every
1095
+ // document row (committed or staged) read "File missing".
1096
+ setDocumentManifest(cached.documentManifest ?? {});
1017
1097
  } else {
1018
1098
  const data = await fetchSavedContent();
1019
1099
  loadedSections = data.sections;
@@ -1023,11 +1103,13 @@ export default function EditorShell({
1023
1103
  savedBranchSha: data.savedBranchSha,
1024
1104
  changedSectionIds: data.changedSectionIds,
1025
1105
  mainIndex: data.mainIndex,
1106
+ documentManifest: data.documentManifest ?? {},
1026
1107
  });
1027
1108
  if (data.mediaManifest) {
1028
1109
  loadedManifest = data.mediaManifest;
1029
1110
  await persistMediaManifest(loadedManifest);
1030
1111
  }
1112
+ setDocumentManifest(data.documentManifest ?? {});
1031
1113
  setSavedSha(data.savedBranchSha);
1032
1114
  setMainSha(headSha);
1033
1115
  setChangedSectionIds(new Set(data.changedSectionIds));
@@ -1089,6 +1171,11 @@ export default function EditorShell({
1089
1171
  return () => { cancelled = true; };
1090
1172
  }, [headSha, draftHeadSha, siteId, applySiteConfigPreview, fetchSavedContent]);
1091
1173
 
1174
+ useEffect(() => {
1175
+ void mediaPipeline.hydratePendingDocuments();
1176
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot restore of last session's staged documents
1177
+ }, []);
1178
+
1092
1179
  // --- Recovery handlers ---
1093
1180
 
1094
1181
  const handleRestore = useCallback(async () => {
@@ -1625,9 +1712,10 @@ export default function EditorShell({
1625
1712
  <EditorModalProvider>
1626
1713
  <MediaLibraryContext.Provider value={{
1627
1714
  ...mediaPipeline.contextValue,
1628
- openSelectModal: (onSelect) => {
1715
+ openSelectModal: (onSelect, opts) => {
1629
1716
  setMediaModalMode("select");
1630
1717
  setMediaSelectCallback(() => onSelect);
1718
+ setMediaSelectKinds(opts?.kinds ?? null);
1631
1719
  setShowMediaLibrary(true);
1632
1720
  },
1633
1721
  reportRejectedUploads: (files) => setRejectedUploads(files),
@@ -1742,6 +1830,20 @@ export default function EditorShell({
1742
1830
  </div>
1743
1831
  )}
1744
1832
 
1833
+ {/* Same onUploadError message the media modal shows in its own banner,
1834
+ surfaced at shell level when the modal is closed — an upload can now
1835
+ start from a document section's drop zone, where nothing else would
1836
+ ever render the rejection. Gated on the modal being shut so the two
1837
+ never show the same message twice. */}
1838
+ {uploadErrorMessage && !showMediaLibrary && (
1839
+ <div className="sticky top-2 z-30 mx-auto w-full max-w-3xl px-4">
1840
+ <UploadRejectionAlert
1841
+ message={uploadErrorMessage}
1842
+ onDismiss={() => setUploadErrorMessage(null)}
1843
+ />
1844
+ </div>
1845
+ )}
1846
+
1745
1847
  {unparseableSectionIds.length > 0 && (
1746
1848
  <div className="sticky top-2 z-30 mx-auto w-full max-w-3xl px-4">
1747
1849
  <UploadRejectionAlert
@@ -1855,6 +1957,7 @@ export default function EditorShell({
1855
1957
  onClose={() => {
1856
1958
  setShowMediaLibrary(false);
1857
1959
  setMediaSelectCallback(null);
1960
+ setMediaSelectKinds(null);
1858
1961
  }}
1859
1962
  title="Media Library"
1860
1963
  size="large"
@@ -1868,17 +1971,34 @@ export default function EditorShell({
1868
1971
  ...Object.values(mediaManifest.images),
1869
1972
  ...mediaPipeline.pendingMediaItems,
1870
1973
  ].filter((item) => !mediaPipeline.pendingDeletions.includes(item.id) && !item.hidden)}
1974
+ documents={Object.entries({
1975
+ ...documentManifest,
1976
+ ...Object.fromEntries(mediaPipeline.pendingDocuments.map((d) => [d.id, d.entry])),
1977
+ })
1978
+ // Deleted-but-unsaved documents stay in the manifest until the
1979
+ // save commits them; the library must not offer them again.
1980
+ .filter(([id]) => !mediaPipeline.pendingDocumentDeletions.includes(id))
1981
+ .map(([id, entry]) => ({
1982
+ id,
1983
+ filename: entry.filename,
1984
+ size: entry.size,
1985
+ kind: entry.kind,
1986
+ }))}
1987
+ kinds={mediaModalMode === "manage" ? undefined : (mediaSelectKinds ?? undefined)}
1871
1988
  onSelect={(id) => {
1872
1989
  if (mediaSelectCallback) {
1873
1990
  mediaSelectCallback(id);
1874
1991
  }
1875
1992
  setShowMediaLibrary(false);
1876
1993
  setMediaSelectCallback(null);
1994
+ setMediaSelectKinds(null);
1877
1995
  }}
1878
1996
  onUpload={mediaPipeline.handleMediaUpload}
1879
1997
  onDelete={mediaPipeline.handleMediaDelete}
1998
+ onDeleteDocuments={(ids) => { void mediaPipeline.handleDocumentDelete(ids); }}
1880
1999
  onAltChange={mediaPipeline.handleMediaAltChange}
1881
2000
  referenceCountMap={mediaPipeline.referenceCountMap}
2001
+ documentReferenceCountMap={mediaPipeline.documentReferenceCountMap}
1882
2002
  localUrlMap={mediaPipeline.pendingLocalUrls}
1883
2003
  maxFileSize={siteConfig?.media.maxFileSize}
1884
2004
  uploadProgress={mediaPipeline.uploadProgress}
@@ -2023,7 +2143,8 @@ function HistoryWatcher({
2023
2143
  siteConfig: data.siteConfig,
2024
2144
  });
2025
2145
  siteNavChangeEvent.dispatch(
2026
- generateSiteNav({ index: data.index, activeSections: [], activePageId: null, mode: "editor", audience: null }),
2146
+ // History views hold no document manifest for that commit — no group.
2147
+ generateSiteNav({ index: data.index, activeSections: [], activePageId: null, mode: "editor", audience: null, documents: [] }),
2027
2148
  );
2028
2149
  } catch (err) {
2029
2150
  console.error("Failed to load historical content:", err);
@@ -2074,7 +2195,10 @@ function HistoryOrEditorContent({
2074
2195
  wasInHistory.current = false;
2075
2196
  if (index.pages.length > 0) {
2076
2197
  siteNavChangeEvent.dispatch(
2077
- generateSiteNav({ index, activeSections, activePageId, mode: "editor", audience: null }),
2198
+ // Leaving history: the live nav effect re-dispatches with the current
2199
+ // document list on the next render, so this one deliberately omits it
2200
+ // rather than briefly showing a stale group.
2201
+ generateSiteNav({ index, activeSections, activePageId, mode: "editor", audience: null, documents: [] }),
2078
2202
  );
2079
2203
  }
2080
2204
  }
@@ -2086,7 +2210,7 @@ function HistoryOrEditorContent({
2086
2210
  if (!historyState || !historyPage) return;
2087
2211
  if (!historyPageId) setHistoryPageId(historyPage.id);
2088
2212
  siteNavChangeEvent.dispatch(
2089
- generateSiteNav({ index: historyState.index, activeSections: [], activePageId: historyPage.id, mode: "editor", audience: null }),
2213
+ generateSiteNav({ index: historyState.index, activeSections: [], activePageId: historyPage.id, mode: "editor", audience: null, documents: [] }),
2090
2214
  );
2091
2215
  }, [historyState, historyPage?.id]);
2092
2216
 
@@ -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,16 @@ 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[];
31
+ /** Upload a PDF/HTML file through the document pipeline, calling back with the
32
+ * document id once it is staged. Optional for the same reason as the two
33
+ * fields above. Owns every rejection itself (unsupported type, too large, no
34
+ * documents store) — they surface on the pipeline's `onUploadError` channel,
35
+ * so a caller never needs to pre-validate or report. */
36
+ uploadDocument?: (file: File, onComplete?: (documentId: string) => void) => void | Promise<void>;
14
37
  }
15
38
 
16
39
  export const MediaLibraryContext = createContext<MediaLibraryContextValue | null>(null);