@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
@@ -15,16 +15,21 @@ import {
15
15
  import { generateVideoPoster } from "../media/videoPoster";
16
16
  import { ASSET_CACHE_CONTROL } from "../storage/types";
17
17
  import { tallyImageIds, collectImageIds, clearImageIds, applyLogoDeletion } from "../lib/image-refs";
18
+ import { clearDocumentRefs, tallyDocumentIds } from "../lib/document-refs";
18
19
  import {
19
20
  createFolderId, uniqueFolderName, effectiveParentId, canMoveFolder,
20
21
  descendantFolderIds, itemFolderId, planFolderUpload,
21
22
  } from "../lib/media-folders";
22
23
  import type { MediaFolder } from "../media/types";
24
+ import { useDocumentUploads } from "./useDocumentUploads";
25
+ import { DOCUMENT_MIME_TO_EXT, type DocumentManifest } from "../schemas/documents";
23
26
 
24
27
  interface UseMediaPipelineInput {
25
28
  siteConfig: SiteConfig | null;
26
29
  mediaManifest: MediaManifest;
27
30
  setMediaManifest: React.Dispatch<React.SetStateAction<MediaManifest>>;
31
+ documentManifest: DocumentManifest;
32
+ setDocumentManifest: React.Dispatch<React.SetStateAction<DocumentManifest>>;
28
33
  sections: LoadedSection[];
29
34
  setSections: React.Dispatch<React.SetStateAction<LoadedSection[]>>;
30
35
  setLocalChangesExist: React.Dispatch<React.SetStateAction<boolean>>;
@@ -47,6 +52,8 @@ export function useMediaPipeline({
47
52
  siteConfig,
48
53
  mediaManifest,
49
54
  setMediaManifest,
55
+ documentManifest,
56
+ setDocumentManifest,
50
57
  sections,
51
58
  setSections,
52
59
  setLocalChangesExist,
@@ -60,6 +67,10 @@ export function useMediaPipeline({
60
67
  const [pendingMediaItems, setPendingMediaItems] = useState<LibraryMediaItem[]>([]);
61
68
  const [pendingLocalUrls, setPendingLocalUrls] = useState<Record<string, string>>({});
62
69
  const [pendingDeletions, setPendingDeletions] = useState<string[]>([]);
70
+ // Committed documents deleted locally, awaiting the save that carries them as
71
+ // `deletedDocumentIds`. Staged-but-uncommitted documents never land here —
72
+ // they are simply unstaged.
73
+ const [pendingDocumentDeletions, setPendingDocumentDeletions] = useState<string[]>([]);
63
74
  // Direct-to-bucket large-video PUT progress, keyed by content hash (0..1).
64
75
  // Populated by uploadLargeVideo's XHR upload.onprogress, cleared on
65
76
  // completion or failure.
@@ -72,6 +83,34 @@ export function useMediaPipeline({
72
83
  // dropped when the editor DOES save in the same session.
73
84
  const [manifestDirty, setManifestDirty] = useState(false);
74
85
 
86
+ // Read inside the queue effect, whose deps are deliberately narrow — without
87
+ // the ref the error branch below would close over the first render's handler.
88
+ const onUploadErrorRef = useRef(onUploadError);
89
+ onUploadErrorRef.current = onUploadError;
90
+
91
+ // Read inside uploadDocument's async body, where a state snapshot taken at
92
+ // render time would be stale across the hash await.
93
+ const pendingDocumentDeletionsRef = useRef<string[]>(pendingDocumentDeletions);
94
+ pendingDocumentDeletionsRef.current = pendingDocumentDeletions;
95
+
96
+ /** Re-adding a document that is deleted-but-unsaved un-dooms it rather than
97
+ * letting the upload dedup swallow it — the twin of the image pipeline's
98
+ * `isDeleted` escape below. The ref is updated eagerly so two re-adds in one
99
+ * tick can't both see the id as doomed. */
100
+ const undeleteDocument = useCallback((id: string) => {
101
+ if (!pendingDocumentDeletionsRef.current.includes(id)) return;
102
+ pendingDocumentDeletionsRef.current = pendingDocumentDeletionsRef.current.filter((d) => d !== id);
103
+ setPendingDocumentDeletions((prev) => prev.filter((d) => d !== id));
104
+ }, []);
105
+
106
+ const documents = useDocumentUploads({
107
+ documentManifest,
108
+ setDocumentManifest,
109
+ setLocalChangesExist,
110
+ undeleteDocument,
111
+ onUploadError,
112
+ });
113
+
75
114
  const queueRef = useRef<ProcessingQueue | null>(null);
76
115
  const uploadCallbacksRef = useRef<Map<string, (imageId: string) => void>>(new Map());
77
116
  // Display-folder target per in-flight upload, keyed by content hash. Set at
@@ -138,10 +177,22 @@ export function useMediaPipeline({
138
177
  onEvent: (event: QueueEvent) => {
139
178
  setProcessingItems((prev) => {
140
179
  const next = prev.filter((i) => i.id !== event.item.id);
141
- if (event.item.state !== "complete") next.push(event.item);
180
+ // "error" must LEAVE the list too. Keeping it (the old `!== "complete"`
181
+ // test) is what left a failed item spinning forever in the toolbar.
182
+ if (event.item.state !== "complete" && event.item.state !== "error") next.push(event.item);
142
183
  return next;
143
184
  });
144
185
 
186
+ if (event.type === "error") {
187
+ uploadTargetsRef.current.delete(event.item.hash);
188
+ hiddenUploadsRef.current.delete(event.item.hash);
189
+ uploadCallbacksRef.current.delete(event.item.hash);
190
+ onUploadErrorRef.current?.(
191
+ `"${event.item.originalName}" couldn't be processed${event.item.error ? ` — ${event.item.error}` : ""}.`,
192
+ );
193
+ return;
194
+ }
195
+
145
196
  if (event.type === "complete" && event.item.result) {
146
197
  const result = event.item.result;
147
198
  const kind = event.item.kind;
@@ -359,6 +410,14 @@ export function useMediaPipeline({
359
410
 
360
411
  let alreadyExisting = 0;
361
412
  for (const file of files) {
413
+ // Documents never touch the rasterizer: their bytes must reach the repo
414
+ // or the bucket untransformed. Branch before every image-shaped gate
415
+ // below (size cap, hashing, queue.add).
416
+ if (file.type in DOCUMENT_MIME_TO_EXT) {
417
+ await documents.uploadDocument(file);
418
+ continue;
419
+ }
420
+
362
421
  if (file.size > mediaConfig.maxFileSize) {
363
422
  if (file.type === "video/mp4" || file.type === "video/webm") {
364
423
  // uploadLargeVideo already guarantees it never throws (see its own
@@ -406,7 +465,7 @@ export function useMediaPipeline({
406
465
  : `${alreadyExisting} files already exist in the library and were left where they are.`,
407
466
  );
408
467
  }
409
- }, [siteConfig, mediaManifest, pendingMediaItems, pendingDeletions, onUploadError, onUploadNotice, uploadLargeVideo]);
468
+ }, [siteConfig, mediaManifest, pendingMediaItems, pendingDeletions, onUploadError, onUploadNotice, uploadLargeVideo, documents]);
410
469
 
411
470
  const handleMediaDelete = useCallback(async (ids: string[]) => {
412
471
  const idSet = new Set(ids);
@@ -462,6 +521,41 @@ export function useMediaPipeline({
462
521
  setLocalChangesExist(true);
463
522
  }, [pendingMediaItems, markSectionDirty, setSections, setDirtySectionIds, setLocalChangesExist, siteConfig, onSiteConfigChange]);
464
523
 
524
+ // Document deletion, the twin of handleMediaDelete. It lives here rather than
525
+ // in useDocumentUploads because the reference-repair pass needs the section
526
+ // state that hook cannot see; useDocumentUploads owns only the staged-row
527
+ // cleanup (unstageDocuments).
528
+ const handleDocumentDelete = useCallback(async (ids: string[]) => {
529
+ if (ids.length === 0) return;
530
+ const idSet = new Set(ids);
531
+ const stagedIds = documents.pendingDocuments.filter((d) => idSet.has(d.id)).map((d) => d.id);
532
+ // Only a COMMITTED document produces a deletion for the save to carry: a
533
+ // staged one has no manifest entry on the branch, so recording it would ask
534
+ // the server to remove something that was never written.
535
+ const committedIds = ids.filter((id) => id in documentManifest);
536
+
537
+ if (stagedIds.length > 0) await documents.unstageDocuments(stagedIds);
538
+ if (committedIds.length > 0) {
539
+ // No optimistic documentManifest mutation: the consumption points filter
540
+ // on this list, and the save co-sends the post-deletion manifest.
541
+ setPendingDocumentDeletions((prev) => [...prev, ...committedIds.filter((id) => !prev.includes(id))]);
542
+ }
543
+
544
+ setSections((prev) =>
545
+ prev.map((loaded) => {
546
+ const { changed, section } = clearDocumentRefs(loaded.section, idSet);
547
+ if (!changed) return loaded;
548
+ // A nested hit returns the rewritten CONTAINER — the section that owns
549
+ // the child's bytes, and so the one that gets dirtied and saved.
550
+ markSectionDirty(section.id, section as SectionContent);
551
+ setDirtySectionIds((dirty) => new Set(dirty).add(section.id));
552
+ return { ...loaded, section };
553
+ }),
554
+ );
555
+
556
+ setLocalChangesExist(true);
557
+ }, [documents, documentManifest, markSectionDirty, setSections, setDirtySectionIds, setLocalChangesExist]);
558
+
465
559
  const handleMediaAltChange = useCallback((id: string, alt: string) => {
466
560
  setMediaManifest((prev) => {
467
561
  const item = prev.images[id];
@@ -608,12 +702,33 @@ export function useMediaPipeline({
608
702
  return Object.fromEntries(counts);
609
703
  }, [sections, siteConfig]);
610
704
 
705
+ // The document twin of referenceCountMap, feeding the media modal's document
706
+ // usage pills and its delete confirmation. Unlike images, documents are never
707
+ // referenced by siteConfig — only by `document` sections, nested ones
708
+ // included (tallyDocumentIds recurses).
709
+ const documentReferenceCountMap = useMemo((): Record<string, number> => {
710
+ const counts = new Map<string, number>();
711
+ for (const { section } of sections) tallyDocumentIds(section, counts);
712
+ return Object.fromEntries(counts);
713
+ }, [sections]);
714
+
611
715
  // --- Unified enqueue ---
612
716
 
613
717
  const enqueueFile = useCallback((file: File, onComplete?: (imageId: string) => void, opts?: { hidden?: boolean }) => {
614
718
  if (!siteConfig) return;
615
719
  const mediaConfig = siteConfig.media;
616
- if (file.size > mediaConfig.maxFileSize) return;
720
+ if (file.type in DOCUMENT_MIME_TO_EXT) {
721
+ void documents.uploadDocument(file, onComplete);
722
+ return;
723
+ }
724
+ if (file.size > mediaConfig.maxFileSize) {
725
+ // Previously a bare `return` — the file vanished with no spinner, no
726
+ // error, and no explanation.
727
+ onUploadError?.(
728
+ `"${file.name}" is larger than the ${Math.round(mediaConfig.maxFileSize / 1024 / 1024)}MB media limit.`,
729
+ );
730
+ return;
731
+ }
617
732
 
618
733
  const currentQueue = queueRef.current;
619
734
  (async () => {
@@ -645,7 +760,7 @@ export function useMediaPipeline({
645
760
  console.error(`[useMediaPipeline] Failed to enqueue file "${file.name}":`, err);
646
761
  }
647
762
  })();
648
- }, [siteConfig, mediaManifest, pendingMediaItems, pendingDeletions]);
763
+ }, [siteConfig, mediaManifest, pendingMediaItems, pendingDeletions, onUploadError, documents]);
649
764
 
650
765
  // --- Context value ---
651
766
 
@@ -669,7 +784,18 @@ export function useMediaPipeline({
669
784
  pendingItems: pendingMediaItems,
670
785
  pendingLocalUrls: pendingLocalUrls,
671
786
  siteConfig,
672
- }), [handleMediaUpload, enqueueFile, mediaManifest, pendingMediaItems, pendingLocalUrls, siteConfig]);
787
+ // Read by useResolvedDocument (DocumentSourceField's entry/missing lookup).
788
+ // Document blob URLs deliberately do NOT merge into pendingLocalUrls: their
789
+ // one consumer is the doc-file registry, which U6 feeds straight from the
790
+ // pipeline's pendingDocumentUrls — a context copy would be dead surface.
791
+ documentManifest,
792
+ pendingDocuments: documents.pendingDocuments,
793
+ // Direct document upload, for the `document` section's empty-state drop
794
+ // zone. Deliberately not routed through enqueueFile: that gate keys off the
795
+ // MIME type, so a file the drop zone means as a document but the browser
796
+ // typed as something else would silently become an image.
797
+ uploadDocument: documents.uploadDocument,
798
+ }), [handleMediaUpload, enqueueFile, mediaManifest, pendingMediaItems, pendingLocalUrls, siteConfig, documentManifest, documents.pendingDocuments, documents.uploadDocument]);
673
799
 
674
800
  return {
675
801
  processingItems,
@@ -684,6 +810,9 @@ export function useMediaPipeline({
684
810
  uploadProgress,
685
811
  handleMediaUpload,
686
812
  handleMediaDelete,
813
+ handleDocumentDelete,
814
+ pendingDocumentDeletions,
815
+ setPendingDocumentDeletions,
687
816
  handleMediaAltChange,
688
817
  handleFolderCreate,
689
818
  handleFolderRename,
@@ -692,7 +821,14 @@ export function useMediaPipeline({
692
821
  handleMediaMove,
693
822
  handleFolderUpload,
694
823
  referenceCountMap,
824
+ documentReferenceCountMap,
695
825
  contextValue,
696
826
  enqueueFile,
827
+ pendingDocuments: documents.pendingDocuments,
828
+ pendingDocumentUrls: documents.pendingDocumentUrls,
829
+ documentUploadProgress: documents.documentUploadProgress,
830
+ uploadDocument: documents.uploadDocument,
831
+ publishDocuments: documents.publishDocuments,
832
+ hydratePendingDocuments: documents.hydratePendingDocuments,
697
833
  };
698
834
  }
@@ -0,0 +1,25 @@
1
+ import { useMemo } from "react";
2
+ import { useMediaLibrary } from "../components/shell/MediaLibraryContext";
3
+ import type { DocumentManifestEntry } from "../schemas/documents";
4
+
5
+ export interface ResolvedDocument {
6
+ entry: DocumentManifestEntry | null;
7
+ /** True only when an id WAS requested and nothing backs it — the editor-side
8
+ * "file missing" state. No id (unconfigured section) is not missing. */
9
+ missing: boolean;
10
+ }
11
+
12
+ /** Document lookup. Deliberately separate from useResolvedMedia: documents are
13
+ * zero-variant and dimensionless, never ride resolveManifestReferences, and
14
+ * live in the sidecar manifest. */
15
+ export function useResolvedDocument(documentId: string | undefined): ResolvedDocument {
16
+ const ctx = useMediaLibrary();
17
+
18
+ return useMemo(() => {
19
+ if (!documentId) return { entry: null, missing: false };
20
+ const committed = ctx?.documentManifest?.[documentId];
21
+ const pending = ctx?.pendingDocuments?.find((d) => d.id === documentId)?.entry;
22
+ const entry = committed ?? pending ?? null;
23
+ return { entry, missing: entry === null };
24
+ }, [documentId, ctx]);
25
+ }
package/src/lib/dexie.ts CHANGED
@@ -4,6 +4,7 @@ import type { SiteIndex, SectionMeta, SiteConfig, Page } from "../schemas/site-c
4
4
  import type { NavEntry } from "../schemas/nav";
5
5
  import type { LoadedSection } from "./loader";
6
6
  import type { MediaManifest, MediaItem } from "../media/types";
7
+ import type { DocumentManifest, DocumentManifestEntry } from "../schemas/documents";
7
8
 
8
9
  interface SectionRow {
9
10
  sectionId: string;
@@ -49,6 +50,11 @@ interface ContentCacheRow {
49
50
  savedBranchSha?: string | null;
50
51
  changedSectionIds?: string[];
51
52
  mainIndex?: SiteIndex | null;
53
+ // The document sidecar at this sha. Optional for rows written by older
54
+ // packages; readers fall back to {} (same posture as the fetch path's
55
+ // `data.documentManifest ?? {}`). Without this, a cache-hit editor load left
56
+ // documentManifest null and the whole documents feature dead for the session.
57
+ documentManifest?: DocumentManifest | null;
52
58
  }
53
59
 
54
60
  interface MediaManifestRow {
@@ -70,6 +76,17 @@ interface PendingMediaDeletionRow {
70
76
  deletedAt: string;
71
77
  }
72
78
 
79
+ /** A document staged locally and not yet committed. Git-tier rows carry the
80
+ * bytes (they ride /api/save); bucket-tier rows are manifest-only — their
81
+ * object is already in the private docs bucket. */
82
+ interface PendingDocumentRow {
83
+ id: string;
84
+ entry: DocumentManifestEntry;
85
+ blob: Blob | null;
86
+ localUrl: string;
87
+ updatedAt: string;
88
+ }
89
+
73
90
  export interface PreservedEditRow {
74
91
  id: string;
75
92
  sectionId: string;
@@ -88,6 +105,7 @@ class EditorDatabase extends Dexie {
88
105
  pendingMedia!: Dexie.Table<PendingMediaRow, string>;
89
106
  pendingMediaDeletions!: Dexie.Table<PendingMediaDeletionRow, string>;
90
107
  preservedEdits!: Dexie.Table<PreservedEditRow, string>;
108
+ pendingDocuments!: Dexie.Table<PendingDocumentRow, string>;
91
109
 
92
110
  constructor(siteId: string) {
93
111
  super(`brand-portal-${siteId}`);
@@ -190,6 +208,21 @@ class EditorDatabase extends Dexie {
190
208
  pendingMediaDeletions: "id",
191
209
  preservedEdits: "id, sectionId",
192
210
  });
211
+ // v10: pendingDocuments — locally staged document uploads. Additive store
212
+ // only; every existing table declaration is carried forward unchanged so
213
+ // no data is dropped on upgrade.
214
+ this.version(10).stores({
215
+ sections: "sectionId",
216
+ siteIndex: "key",
217
+ meta: "key",
218
+ siteConfig: "key",
219
+ contentCache: "key",
220
+ mediaManifest: "key",
221
+ pendingMedia: "id",
222
+ pendingMediaDeletions: "id",
223
+ preservedEdits: "id, sectionId",
224
+ pendingDocuments: "id",
225
+ });
193
226
  }
194
227
  }
195
228
 
@@ -450,7 +483,7 @@ export async function cacheContent(
450
483
  sections: LoadedSection[],
451
484
  index: SiteIndex,
452
485
  siteConfig: Record<string, unknown>,
453
- diff?: { savedBranchSha: string | null; changedSectionIds: string[]; mainIndex: SiteIndex | null },
486
+ diff?: { savedBranchSha: string | null; changedSectionIds: string[]; mainIndex: SiteIndex | null; documentManifest?: DocumentManifest },
454
487
  ): Promise<void> {
455
488
  const now = new Date().toISOString();
456
489
  await getDb().contentCache.put({
@@ -463,6 +496,7 @@ export async function cacheContent(
463
496
  savedBranchSha: diff?.savedBranchSha ?? null,
464
497
  changedSectionIds: diff?.changedSectionIds ?? [],
465
498
  mainIndex: diff?.mainIndex ?? null,
499
+ documentManifest: diff?.documentManifest ?? null,
466
500
  });
467
501
  }
468
502
 
@@ -474,6 +508,7 @@ export async function getCachedContent(): Promise<{
474
508
  savedBranchSha: string | null;
475
509
  changedSectionIds: string[];
476
510
  mainIndex: SiteIndex | null;
511
+ documentManifest: DocumentManifest;
477
512
  } | null> {
478
513
  const row = await getDb().contentCache.get("current");
479
514
  if (!row) return null;
@@ -487,6 +522,10 @@ export async function getCachedContent(): Promise<{
487
522
  // No stored mainIndex (e.g. legacy or post-publish cache) means "no draft",
488
523
  // so the published index equals the current index.
489
524
  mainIndex: row.mainIndex ?? row.index,
525
+ // Legacy row (pre-documents package): {} matches the fetch path's posture.
526
+ // The narrow stale edge — an old row at a sha that DOES hold a manifest —
527
+ // heals on the next fresh load or save.
528
+ documentManifest: row.documentManifest ?? {},
490
529
  };
491
530
  }
492
531
 
@@ -593,3 +632,29 @@ export async function listPreservedEdits(): Promise<PreservedEditRow[]> {
593
632
  export async function removePreservedEdit(id: string): Promise<void> {
594
633
  await getDb().preservedEdits.delete(id);
595
634
  }
635
+
636
+ export interface PendingDocumentRecord {
637
+ id: string;
638
+ entry: DocumentManifestEntry;
639
+ blob: Blob | null;
640
+ localUrl: string;
641
+ }
642
+
643
+ export async function addPendingDocument(record: PendingDocumentRecord): Promise<void> {
644
+ await getDb().pendingDocuments.put({ ...record, updatedAt: new Date().toISOString() });
645
+ }
646
+
647
+ export async function getPendingDocuments(): Promise<PendingDocumentRecord[]> {
648
+ const rows = await getDb().pendingDocuments.toArray();
649
+ return rows.map(({ id, entry, blob, localUrl }) => ({ id, entry, blob, localUrl }));
650
+ }
651
+
652
+ export async function getPendingDocumentBlob(id: string): Promise<Blob | null> {
653
+ const row = await getDb().pendingDocuments.get(id);
654
+ return row?.blob ?? null;
655
+ }
656
+
657
+ export async function clearPendingDocumentsByIds(ids: string[]): Promise<void> {
658
+ if (ids.length === 0) return;
659
+ await getDb().pendingDocuments.bulkDelete(ids);
660
+ }
@@ -0,0 +1,83 @@
1
+ import type { Section } from "../schemas/sections";
2
+ import { getContainerChildren } from "./container-ops";
3
+
4
+ /**
5
+ * Structural walkers over section content for `document` references, the
6
+ * counterpart to `image-refs` for the document manifest: `tallyDocumentIds`
7
+ * feeds the media modal's usage pills, `clearDocumentRefs` repairs sections
8
+ * that pointed at a deleted document.
9
+ *
10
+ * Two differences from `image-refs`. The reference is identified by NODE TYPE
11
+ * (`type === "document"`, then `content.doc.documentId`) rather than by key
12
+ * name, so an unrelated field that happens to be called `doc` is never touched.
13
+ * And the walk recurses through container children structurally, deliberately
14
+ * without a depth cap: `MAX_BLOCK_DEPTH` currently makes depth 1 the only
15
+ * reachable nesting, but a counter that miscounts if that rule changes would
16
+ * silently under-report usage and leave dangling references behind.
17
+ */
18
+
19
+ interface DocumentSectionContent {
20
+ doc?: { documentId?: string };
21
+ }
22
+
23
+ /** The non-empty `documentId` this node references, or "" if it references none. */
24
+ function documentIdOf(section: Section): string {
25
+ if (section.type !== "document") return "";
26
+ return (section.content as DocumentSectionContent).doc?.documentId ?? "";
27
+ }
28
+
29
+ /**
30
+ * Count every non-empty `documentId` referenced by `value` — a section, or an
31
+ * array of them — including sections nested inside containers. Accepts a
32
+ * caller-supplied accumulator so a list of `LoadedSection`s can be tallied one
33
+ * `.section` at a time into a single map.
34
+ */
35
+ export function tallyDocumentIds(
36
+ value: Section | readonly Section[],
37
+ counts: Map<string, number> = new Map(),
38
+ ): Map<string, number> {
39
+ if (Array.isArray(value)) {
40
+ for (const item of value) tallyDocumentIds(item, counts);
41
+ return counts;
42
+ }
43
+ const section = value as Section;
44
+ const documentId = documentIdOf(section);
45
+ if (documentId) counts.set(documentId, (counts.get(documentId) ?? 0) + 1);
46
+ for (const child of getContainerChildren(section)) tallyDocumentIds(child, counts);
47
+ return counts;
48
+ }
49
+
50
+ /**
51
+ * Blank every `doc.documentId` in `ids` to "" (the deleted-media repair pass).
52
+ *
53
+ * Returns `changed: false` and the ORIGINAL section reference when nothing
54
+ * matched, so callers can skip a re-render and a dirty mark. A match on a
55
+ * nested child returns a rewritten CONTAINER — the container is the section
56
+ * that owns the child's bytes, so it is what gets dirtied and saved; untouched
57
+ * siblings are carried over by reference.
58
+ */
59
+ export function clearDocumentRefs(
60
+ section: Section,
61
+ ids: Set<string>,
62
+ ): { changed: boolean; section: Section } {
63
+ const documentId = documentIdOf(section);
64
+ if (documentId && ids.has(documentId)) {
65
+ const content = section.content as DocumentSectionContent;
66
+ return {
67
+ changed: true,
68
+ section: { ...section, content: { ...section.content, doc: { ...content.doc, documentId: "" } } },
69
+ };
70
+ }
71
+
72
+ const children = getContainerChildren(section);
73
+ if (children.length === 0) return { changed: false, section };
74
+
75
+ let changed = false;
76
+ const next = children.map((child) => {
77
+ const result = clearDocumentRefs(child, ids);
78
+ if (result.changed) changed = true;
79
+ return result.section;
80
+ });
81
+ if (!changed) return { changed: false, section };
82
+ return { changed: true, section: { ...section, content: { ...section.content, children: next } } };
83
+ }
@@ -0,0 +1,66 @@
1
+ import {
2
+ DOCUMENT_BUCKET_MAX_BYTES,
3
+ DOCUMENT_GIT_MAX_BYTES,
4
+ DOCUMENT_MIME_TO_EXT,
5
+ documentGitPath as documentGitPathFromSlug,
6
+ documentBucketPath,
7
+ } from "../schemas/documents";
8
+ import { sanitizeMediaName } from "../media/utils";
9
+
10
+ // Bucket keys are already spelled once, in schemas/documents (S1) — re-exported
11
+ // so this module is the single import site for the whole upload flow.
12
+ export { documentBucketPath };
13
+
14
+ /** Every way a document upload can be refused. The first three are decidable
15
+ * client-side; `no_documents_store` only comes back from the upload-intent
16
+ * endpoint (the browser cannot see whether a docs bucket is configured). */
17
+ export type DocumentRejectionReason =
18
+ | "unsupported_type"
19
+ | "too_large"
20
+ | "too_small"
21
+ | "no_documents_store";
22
+
23
+ export type DocumentUploadPlan =
24
+ | { tier: "git"; kind: "pdf" | "html"; ext: "pdf" | "html" }
25
+ | { tier: "bucket"; kind: "pdf" | "html"; ext: "pdf" | "html" }
26
+ | { tier: "rejected"; reason: DocumentRejectionReason };
27
+
28
+ /** Pure tier routing. The bucket tier's lower bound is exactly the git cap, so
29
+ * there is no size that falls through both. */
30
+ export function planDocumentUpload(file: { name: string; size: number; type: string }): DocumentUploadPlan {
31
+ // `file.type` is an arbitrary string, so the const map is widened for the
32
+ // lookup rather than indexed with a cast that TS would reject.
33
+ const ext = (DOCUMENT_MIME_TO_EXT as Record<string, "pdf" | "html" | undefined>)[file.type];
34
+ if (!ext) return { tier: "rejected", reason: "unsupported_type" };
35
+ if (file.size <= 0) return { tier: "rejected", reason: "too_small" };
36
+ if (file.size > DOCUMENT_BUCKET_MAX_BYTES) return { tier: "rejected", reason: "too_large" };
37
+ if (file.size <= DOCUMENT_GIT_MAX_BYTES) return { tier: "git", kind: ext, ext };
38
+ return { tier: "bucket", kind: ext, ext };
39
+ }
40
+
41
+ /** Human copy for every rejection code, client- or server-originated. Never
42
+ * falls back to the media pipeline's generic size copy (an oversized PDF
43
+ * today gets "…is larger than the NMB media limit.", which names the wrong
44
+ * limit and no fix). */
45
+ export function documentRejectionMessage(reason: DocumentRejectionReason, filename: string): string {
46
+ switch (reason) {
47
+ case "unsupported_type":
48
+ return `"${filename}" — only PDF and HTML documents can be uploaded.`;
49
+ case "too_large":
50
+ return `"${filename}" is larger than the 200MB document limit.`;
51
+ case "too_small":
52
+ return `"${filename}" is empty.`;
53
+ case "no_documents_store":
54
+ return `"${filename}" is over 2.5MB, and document storage isn't set up on this site yet.`;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Repo path for a git-tier document, from the RAW file name. The layout itself
60
+ * lives once in schemas/documents (S1), which takes an already-sanitized stem;
61
+ * this wrapper only adds `sanitizeMediaName`, so the two can never drift. The
62
+ * extension comes from the explicit pdf/html map — never mimeToExt's "bin".
63
+ */
64
+ export function documentGitPath(originalName: string, hash: string, ext: "pdf" | "html"): string {
65
+ return documentGitPathFromSlug(sanitizeMediaName(originalName), hash, ext);
66
+ }