@drawnagency/primitives 0.1.99 → 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 (117) hide show
  1. package/dist/{chunk-FAZFADJK.js → chunk-3TO4RRHY.js} +3 -1
  2. package/dist/{chunk-KP4DWTPR.js → chunk-72FMQQIA.js} +246 -3
  3. package/dist/chunk-BS5AQHZG.js +45 -0
  4. package/dist/{chunk-HKHEAH5F.js → chunk-GOCFTXR3.js} +8 -60
  5. package/dist/{chunk-GYCBWGVW.js → chunk-LBHQWIVL.js} +79 -24
  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 +33 -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/sections/Document/index.d.ts +18 -0
  16. package/dist/components/sections/Document/index.d.ts.map +1 -0
  17. package/dist/components/sections/all-sections.d.ts +10 -0
  18. package/dist/components/sections/all-sections.d.ts.map +1 -1
  19. package/dist/components/sections/brand-guide.d.ts +10 -0
  20. package/dist/components/sections/brand-guide.d.ts.map +1 -1
  21. package/dist/components/sections/brand-guide.js +3 -1
  22. package/dist/components/sections/register-schemas.js +5 -3
  23. package/dist/components/sections/register.js +5 -3
  24. package/dist/components/shared/DocumentSourceField.d.ts +13 -0
  25. package/dist/components/shared/DocumentSourceField.d.ts.map +1 -0
  26. package/dist/components/shared/Navigation.d.ts.map +1 -1
  27. package/dist/components/shell/EditorModal.d.ts.map +1 -1
  28. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  29. package/dist/components/shell/MediaBreadcrumb.d.ts.map +1 -1
  30. package/dist/components/shell/MediaFolderTile.d.ts +3 -1
  31. package/dist/components/shell/MediaFolderTile.d.ts.map +1 -1
  32. package/dist/components/shell/MediaFolderTree.d.ts +3 -0
  33. package/dist/components/shell/MediaFolderTree.d.ts.map +1 -1
  34. package/dist/components/shell/MediaLibraryContext.d.ts +18 -1
  35. package/dist/components/shell/MediaLibraryContext.d.ts.map +1 -1
  36. package/dist/components/shell/MediaLibraryModal.d.ts +21 -1
  37. package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
  38. package/dist/hooks/useDocumentUploads.d.ts +32 -0
  39. package/dist/hooks/useDocumentUploads.d.ts.map +1 -0
  40. package/dist/hooks/useEditorPublish.d.ts +18 -1
  41. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  42. package/dist/hooks/useMediaPipeline.d.ts +14 -1
  43. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  44. package/dist/hooks/useResolvedDocument.d.ts +12 -0
  45. package/dist/hooks/useResolvedDocument.d.ts.map +1 -0
  46. package/dist/index.js +56 -32
  47. package/dist/lib/dexie.d.ts +11 -0
  48. package/dist/lib/dexie.d.ts.map +1 -1
  49. package/dist/lib/dexie.js +32 -0
  50. package/dist/lib/document-refs.d.ts +22 -0
  51. package/dist/lib/document-refs.d.ts.map +1 -0
  52. package/dist/lib/document-uploads.d.ts +38 -0
  53. package/dist/lib/document-uploads.d.ts.map +1 -0
  54. package/dist/lib/documents.d.ts +65 -0
  55. package/dist/lib/documents.d.ts.map +1 -0
  56. package/dist/lib/index.js +4 -4
  57. package/dist/lib/media-folders.d.ts +8 -0
  58. package/dist/lib/media-folders.d.ts.map +1 -1
  59. package/dist/lib/media-folders.js +2 -0
  60. package/dist/lib/nav.d.ts +29 -1
  61. package/dist/lib/nav.d.ts.map +1 -1
  62. package/dist/lib/platform-broker.d.ts +12 -0
  63. package/dist/lib/platform-broker.d.ts.map +1 -1
  64. package/dist/lib/platform-broker.js +12 -3
  65. package/dist/lib/registry.d.ts +10 -0
  66. package/dist/lib/registry.d.ts.map +1 -1
  67. package/dist/lib/save-chunks.d.ts +15 -0
  68. package/dist/lib/save-chunks.d.ts.map +1 -1
  69. package/dist/lib/scroll-lock.d.ts +2 -0
  70. package/dist/lib/scroll-lock.d.ts.map +1 -0
  71. package/dist/schemas/documents.d.ts +80 -0
  72. package/dist/schemas/documents.d.ts.map +1 -0
  73. package/dist/schemas/documents.js +25 -0
  74. package/dist/schemas/index.d.ts +1 -0
  75. package/dist/schemas/index.d.ts.map +1 -1
  76. package/dist/schemas/index.js +23 -1
  77. package/dist/storage/index.d.ts +2 -2
  78. package/dist/storage/index.d.ts.map +1 -1
  79. package/dist/storage/index.js +3 -1
  80. package/dist/storage/types.d.ts +47 -0
  81. package/dist/storage/types.d.ts.map +1 -1
  82. package/package.json +5 -1
  83. package/src/components/chat/ChatPanel.tsx +5 -4
  84. package/src/components/docfile/DocFileOverlay.tsx +54 -0
  85. package/src/components/docfile/DocFilePane.tsx +129 -0
  86. package/src/components/docfile/doc-file-state.ts +180 -0
  87. package/src/components/editor/SettingsForm.tsx +10 -0
  88. package/src/components/sections/Document/index.tsx +163 -0
  89. package/src/components/sections/brand-guide.ts +2 -1
  90. package/src/components/shared/DocumentSourceField.tsx +51 -0
  91. package/src/components/shared/Navigation.tsx +53 -10
  92. package/src/components/shell/EditorModal.tsx +2 -5
  93. package/src/components/shell/EditorShell.tsx +111 -7
  94. package/src/components/shell/MediaBreadcrumb.tsx +14 -1
  95. package/src/components/shell/MediaFolderTile.tsx +4 -2
  96. package/src/components/shell/MediaFolderTree.tsx +20 -3
  97. package/src/components/shell/MediaLibraryContext.tsx +18 -1
  98. package/src/components/shell/MediaLibraryModal.tsx +223 -45
  99. package/src/hooks/useDocumentUploads.ts +330 -0
  100. package/src/hooks/useEditorPublish.ts +180 -8
  101. package/src/hooks/useMediaPipeline.ts +136 -5
  102. package/src/hooks/useResolvedDocument.ts +25 -0
  103. package/src/lib/dexie.ts +54 -0
  104. package/src/lib/document-refs.ts +83 -0
  105. package/src/lib/document-uploads.ts +66 -0
  106. package/src/lib/documents.ts +138 -0
  107. package/src/lib/media-folders.ts +9 -0
  108. package/src/lib/nav.ts +46 -2
  109. package/src/lib/platform-broker.ts +18 -3
  110. package/src/lib/registry.ts +9 -0
  111. package/src/lib/save-chunks.ts +42 -0
  112. package/src/lib/scroll-lock.ts +27 -0
  113. package/src/schemas/documents.ts +82 -0
  114. package/src/schemas/index.ts +1 -0
  115. package/src/storage/index.ts +2 -2
  116. package/src/storage/types.ts +46 -0
  117. /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,13 @@ 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
+ }), [handleMediaUpload, enqueueFile, mediaManifest, pendingMediaItems, pendingLocalUrls, siteConfig, documentManifest, documents.pendingDocuments]);
673
794
 
674
795
  return {
675
796
  processingItems,
@@ -684,6 +805,9 @@ export function useMediaPipeline({
684
805
  uploadProgress,
685
806
  handleMediaUpload,
686
807
  handleMediaDelete,
808
+ handleDocumentDelete,
809
+ pendingDocumentDeletions,
810
+ setPendingDocumentDeletions,
687
811
  handleMediaAltChange,
688
812
  handleFolderCreate,
689
813
  handleFolderRename,
@@ -692,7 +816,14 @@ export function useMediaPipeline({
692
816
  handleMediaMove,
693
817
  handleFolderUpload,
694
818
  referenceCountMap,
819
+ documentReferenceCountMap,
695
820
  contextValue,
696
821
  enqueueFile,
822
+ pendingDocuments: documents.pendingDocuments,
823
+ pendingDocumentUrls: documents.pendingDocumentUrls,
824
+ documentUploadProgress: documents.documentUploadProgress,
825
+ uploadDocument: documents.uploadDocument,
826
+ publishDocuments: documents.publishDocuments,
827
+ hydratePendingDocuments: documents.hydratePendingDocuments,
697
828
  };
698
829
  }
@@ -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 { DocumentManifestEntry } from "../schemas/documents";
7
8
 
8
9
  interface SectionRow {
9
10
  sectionId: string;
@@ -70,6 +71,17 @@ interface PendingMediaDeletionRow {
70
71
  deletedAt: string;
71
72
  }
72
73
 
74
+ /** A document staged locally and not yet committed. Git-tier rows carry the
75
+ * bytes (they ride /api/save); bucket-tier rows are manifest-only — their
76
+ * object is already in the private docs bucket. */
77
+ interface PendingDocumentRow {
78
+ id: string;
79
+ entry: DocumentManifestEntry;
80
+ blob: Blob | null;
81
+ localUrl: string;
82
+ updatedAt: string;
83
+ }
84
+
73
85
  export interface PreservedEditRow {
74
86
  id: string;
75
87
  sectionId: string;
@@ -88,6 +100,7 @@ class EditorDatabase extends Dexie {
88
100
  pendingMedia!: Dexie.Table<PendingMediaRow, string>;
89
101
  pendingMediaDeletions!: Dexie.Table<PendingMediaDeletionRow, string>;
90
102
  preservedEdits!: Dexie.Table<PreservedEditRow, string>;
103
+ pendingDocuments!: Dexie.Table<PendingDocumentRow, string>;
91
104
 
92
105
  constructor(siteId: string) {
93
106
  super(`brand-portal-${siteId}`);
@@ -190,6 +203,21 @@ class EditorDatabase extends Dexie {
190
203
  pendingMediaDeletions: "id",
191
204
  preservedEdits: "id, sectionId",
192
205
  });
206
+ // v10: pendingDocuments — locally staged document uploads. Additive store
207
+ // only; every existing table declaration is carried forward unchanged so
208
+ // no data is dropped on upgrade.
209
+ this.version(10).stores({
210
+ sections: "sectionId",
211
+ siteIndex: "key",
212
+ meta: "key",
213
+ siteConfig: "key",
214
+ contentCache: "key",
215
+ mediaManifest: "key",
216
+ pendingMedia: "id",
217
+ pendingMediaDeletions: "id",
218
+ preservedEdits: "id, sectionId",
219
+ pendingDocuments: "id",
220
+ });
193
221
  }
194
222
  }
195
223
 
@@ -593,3 +621,29 @@ export async function listPreservedEdits(): Promise<PreservedEditRow[]> {
593
621
  export async function removePreservedEdit(id: string): Promise<void> {
594
622
  await getDb().preservedEdits.delete(id);
595
623
  }
624
+
625
+ export interface PendingDocumentRecord {
626
+ id: string;
627
+ entry: DocumentManifestEntry;
628
+ blob: Blob | null;
629
+ localUrl: string;
630
+ }
631
+
632
+ export async function addPendingDocument(record: PendingDocumentRecord): Promise<void> {
633
+ await getDb().pendingDocuments.put({ ...record, updatedAt: new Date().toISOString() });
634
+ }
635
+
636
+ export async function getPendingDocuments(): Promise<PendingDocumentRecord[]> {
637
+ const rows = await getDb().pendingDocuments.toArray();
638
+ return rows.map(({ id, entry, blob, localUrl }) => ({ id, entry, blob, localUrl }));
639
+ }
640
+
641
+ export async function getPendingDocumentBlob(id: string): Promise<Blob | null> {
642
+ const row = await getDb().pendingDocuments.get(id);
643
+ return row?.blob ?? null;
644
+ }
645
+
646
+ export async function clearPendingDocumentsByIds(ids: string[]): Promise<void> {
647
+ if (ids.length === 0) return;
648
+ await getDb().pendingDocuments.bulkDelete(ids);
649
+ }
@@ -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
+ }
@@ -0,0 +1,138 @@
1
+ import type { LoadedSection } from "./loader";
2
+ import type { Section } from "../schemas/sections";
3
+ import type { SiteIndex, SectionMeta } from "../schemas/site-config";
4
+ import type { DocumentManifest } from "../schemas/documents";
5
+ import { documentFileUrl } from "../schemas/documents";
6
+ import { getContainerChildren } from "./container-ops";
7
+ import { audiencePasses } from "./pages";
8
+
9
+ // The gated URL has exactly ONE definition, in schemas/documents (S1). This is
10
+ // a re-export so `@/lib/documents` consumers get it without a second spelling.
11
+ export { documentFileUrl };
12
+
13
+ /**
14
+ * One `document` section, joined against the document manifest and filtered to
15
+ * what THIS requester may see. Derived per request — never persisted, never
16
+ * written back into index.json or nav.json.
17
+ */
18
+ export interface SiteDocument {
19
+ sectionId: string;
20
+ /** Manifest key. Carried so the overlay can build the ?download=1 URL with
21
+ * documentFileUrl(), and so the editor can key a staged blob URL by id. */
22
+ documentId: string;
23
+ title: string;
24
+ filename: string;
25
+ size: number;
26
+ kind: "pdf" | "html";
27
+ /** The auth-gated route. Append `?download=1` for an attachment response. */
28
+ url: string;
29
+ /** Mirrors content.showInDocumentsNav — generateSiteNav filters on it, while
30
+ * the overlay and the rows need the unfiltered list (an unflagged document
31
+ * is still openable from its own row). */
32
+ showInDocumentsNav: boolean;
33
+ }
34
+
35
+ interface DocumentSectionContent {
36
+ title?: string;
37
+ doc?: { documentId?: string };
38
+ showInDocumentsNav?: boolean;
39
+ }
40
+
41
+ /**
42
+ * Filename without its extension. The document row and the overlay's top bar
43
+ * render `content.title || filenameStem(entry.filename)` — a DISPLAY fallback,
44
+ * not a write. (SettingsForm.handleFieldChange is per-key and cannot set a
45
+ * sibling field, so "auto-fill title on pick" would need cross-field machinery
46
+ * for the same visible result.)
47
+ */
48
+ export function filenameStem(filename: string): string {
49
+ const dot = filename.lastIndexOf(".");
50
+ return dot > 0 ? filename.slice(0, dot) : filename;
51
+ }
52
+
53
+ /** Compact byte size for the row's `filename · size` line. */
54
+ export function formatDocSize(bytes: number): string {
55
+ if (!Number.isFinite(bytes) || bytes < 0) return "";
56
+ if (bytes < 1024) return `${Math.round(bytes)} B`;
57
+ const units = ["KB", "MB", "GB"];
58
+ let value = bytes / 1024;
59
+ let unit = 0;
60
+ while (value >= 1024 && unit < units.length - 1) {
61
+ value /= 1024;
62
+ unit++;
63
+ }
64
+ return `${value >= 100 ? Math.round(value) : Number(value.toFixed(1))} ${units[unit]}`;
65
+ }
66
+
67
+ /**
68
+ * Every document the requester may see, in page order.
69
+ *
70
+ * Viewer mode mirrors viewer-page.ts exactly: the owning page must be live and
71
+ * audience-passing, AND the section's own meta must be live and audience-
72
+ * passing. Editors see everything on the draft. A document with no manifest
73
+ * entry is excluded in BOTH modes — there are no bytes to serve and no
74
+ * filename/size to render; the editor's row derives its "file missing" state
75
+ * from that absence.
76
+ *
77
+ * Documents nested one level inside a container count too, and are emitted right
78
+ * after their container's own position. A child has no meta of its own and never
79
+ * appears in `page.order`, so both gates read the PARENT's meta and the PARENT's
80
+ * page; the emitted `sectionId` is the CHILD's — that is what `DocumentRow`
81
+ * matches and what `#doc=` deep links carry. Depth 1 only: `MAX_BLOCK_DEPTH`
82
+ * makes anything deeper unrepresentable on write and on load.
83
+ *
84
+ * This is one half of a documented mirror. `referencedByVisibleSection` (the
85
+ * gated file route) applies the same gates with the same recursion — change one,
86
+ * change the other, or a document becomes listable but unservable (or worse).
87
+ */
88
+ export function deriveSiteDocuments(opts: {
89
+ index: SiteIndex;
90
+ sections: LoadedSection[];
91
+ manifest: DocumentManifest;
92
+ mode: "viewer" | "editor";
93
+ audience: string | null;
94
+ }): SiteDocument[] {
95
+ const { index, sections, manifest, mode, audience } = opts;
96
+
97
+ const pageBySectionId = new Map<string, SiteIndex["pages"][number]>();
98
+ for (const page of index.pages) for (const id of page.order) pageBySectionId.set(id, page);
99
+
100
+ const out: SiteDocument[] = [];
101
+ const seen = new Set<string>();
102
+
103
+ /** `candidate` supplies the id and the body; `owner` (itself, or the container
104
+ * it sits in) supplies the meta and the page. */
105
+ const consider = (candidate: Section, ownerId: string, meta: SectionMeta) => {
106
+ if (candidate.type !== "document" || !candidate.id) return;
107
+ const content = candidate.content as DocumentSectionContent;
108
+ const documentId = content.doc?.documentId ?? "";
109
+ const entry = documentId ? manifest[documentId] : undefined;
110
+ if (!entry) return;
111
+
112
+ if (mode === "viewer") {
113
+ const page = pageBySectionId.get(ownerId);
114
+ if (!page || page.status !== "live" || !audiencePasses(page.access, audience)) return;
115
+ if (meta.status !== "live" || !audiencePasses(meta.access, audience)) return;
116
+ }
117
+
118
+ if (seen.has(candidate.id)) return;
119
+ seen.add(candidate.id);
120
+
121
+ out.push({
122
+ sectionId: candidate.id,
123
+ documentId,
124
+ title: content.title?.trim() || filenameStem(entry.filename),
125
+ filename: entry.filename,
126
+ size: entry.size,
127
+ kind: entry.kind,
128
+ url: documentFileUrl(documentId, entry.filename),
129
+ showInDocumentsNav: content.showInDocumentsNav !== false,
130
+ });
131
+ };
132
+
133
+ for (const { section, meta } of sections) {
134
+ consider(section, section.id, meta);
135
+ for (const child of getContainerChildren(section)) consider(child, section.id, meta);
136
+ }
137
+ return out;
138
+ }
@@ -13,6 +13,15 @@ export interface FolderUploadPlan {
13
13
  fileTargets: { file: File; folderId: string | null }[];
14
14
  }
15
15
 
16
+ /**
17
+ * The library's Documents pseudo-folder. UI-only: it is NEVER a `folders` entry
18
+ * (they round-trip to disk verbatim, and no MediaItem can point at it), so
19
+ * every `currentFolderId` consumer that reads `folders` must special-case it.
20
+ * Cannot collide with a real `f_`-prefixed id; distinct from the nav's
21
+ * `__documents__` group id.
22
+ */
23
+ export const DOCUMENTS_FOLDER_ID = "__documents-folder__";
24
+
16
25
  /** Display-folder id. NOT related to MediaItem.folder (the git storage dir). */
17
26
  export function createFolderId(): string {
18
27
  const bytes = new Uint8Array(6);