@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.
- package/dist/{chunk-FAZFADJK.js → chunk-3TO4RRHY.js} +3 -1
- package/dist/{chunk-HKHEAH5F.js → chunk-6SLGKRLW.js} +8 -60
- package/dist/chunk-BS5AQHZG.js +45 -0
- package/dist/{chunk-GYCBWGVW.js → chunk-LBHQWIVL.js} +79 -24
- package/dist/{chunk-KP4DWTPR.js → chunk-P3NNI3QI.js} +328 -4
- package/dist/components/chat/ChatPanel.d.ts.map +1 -1
- package/dist/components/docfile/DocFileOverlay.d.ts +12 -0
- package/dist/components/docfile/DocFileOverlay.d.ts.map +1 -0
- package/dist/components/docfile/DocFilePane.d.ts +17 -0
- package/dist/components/docfile/DocFilePane.d.ts.map +1 -0
- package/dist/components/docfile/doc-file-state.d.ts +38 -0
- package/dist/components/docfile/doc-file-state.d.ts.map +1 -0
- package/dist/components/editor/SettingsForm.d.ts.map +1 -1
- package/dist/components/editor/index.js +1 -1
- package/dist/components/primitives/DocumentDropZone.d.ts +18 -0
- package/dist/components/primitives/DocumentDropZone.d.ts.map +1 -0
- package/dist/components/sections/Document/index.d.ts +24 -0
- package/dist/components/sections/Document/index.d.ts.map +1 -0
- package/dist/components/sections/all-sections.d.ts +10 -0
- package/dist/components/sections/all-sections.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.d.ts +10 -0
- package/dist/components/sections/brand-guide.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.js +3 -1
- package/dist/components/sections/register-schemas.js +5 -3
- package/dist/components/sections/register.js +5 -3
- package/dist/components/shared/DocumentSourceField.d.ts +13 -0
- package/dist/components/shared/DocumentSourceField.d.ts.map +1 -0
- package/dist/components/shared/Navigation.d.ts.map +1 -1
- package/dist/components/shell/EditorModal.d.ts.map +1 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/MediaBreadcrumb.d.ts.map +1 -1
- package/dist/components/shell/MediaFolderTile.d.ts +3 -1
- package/dist/components/shell/MediaFolderTile.d.ts.map +1 -1
- package/dist/components/shell/MediaFolderTree.d.ts +3 -0
- package/dist/components/shell/MediaFolderTree.d.ts.map +1 -1
- package/dist/components/shell/MediaLibraryContext.d.ts +24 -1
- package/dist/components/shell/MediaLibraryContext.d.ts.map +1 -1
- package/dist/components/shell/MediaLibraryModal.d.ts +21 -1
- package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
- package/dist/hooks/useDocumentUploads.d.ts +32 -0
- package/dist/hooks/useDocumentUploads.d.ts.map +1 -0
- package/dist/hooks/useEditorPublish.d.ts +18 -1
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/hooks/useMediaPipeline.d.ts +14 -1
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/hooks/useResolvedDocument.d.ts +12 -0
- package/dist/hooks/useResolvedDocument.d.ts.map +1 -0
- package/dist/index.js +56 -32
- package/dist/lib/dexie.d.ts +13 -0
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +39 -2
- package/dist/lib/document-refs.d.ts +22 -0
- package/dist/lib/document-refs.d.ts.map +1 -0
- package/dist/lib/document-uploads.d.ts +38 -0
- package/dist/lib/document-uploads.d.ts.map +1 -0
- package/dist/lib/documents.d.ts +65 -0
- package/dist/lib/documents.d.ts.map +1 -0
- package/dist/lib/index.js +4 -4
- package/dist/lib/media-folders.d.ts +8 -0
- package/dist/lib/media-folders.d.ts.map +1 -1
- package/dist/lib/media-folders.js +2 -0
- package/dist/lib/nav.d.ts +29 -1
- package/dist/lib/nav.d.ts.map +1 -1
- package/dist/lib/platform-broker.d.ts +12 -0
- package/dist/lib/platform-broker.d.ts.map +1 -1
- package/dist/lib/platform-broker.js +12 -3
- package/dist/lib/registry.d.ts +10 -0
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/lib/save-chunks.d.ts +15 -0
- package/dist/lib/save-chunks.d.ts.map +1 -1
- package/dist/lib/scroll-lock.d.ts +2 -0
- package/dist/lib/scroll-lock.d.ts.map +1 -0
- package/dist/schemas/documents.d.ts +80 -0
- package/dist/schemas/documents.d.ts.map +1 -0
- package/dist/schemas/documents.js +25 -0
- package/dist/schemas/index.d.ts +1 -0
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +23 -1
- package/dist/storage/index.d.ts +2 -2
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +3 -1
- package/dist/storage/types.d.ts +59 -3
- package/dist/storage/types.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/components/chat/ChatPanel.tsx +5 -4
- package/src/components/docfile/DocFileOverlay.tsx +54 -0
- package/src/components/docfile/DocFilePane.tsx +133 -0
- package/src/components/docfile/doc-file-state.ts +208 -0
- package/src/components/editor/SettingsForm.tsx +10 -0
- package/src/components/primitives/DocumentDropZone.tsx +79 -0
- package/src/components/sections/Document/index.tsx +194 -0
- package/src/components/sections/brand-guide.ts +2 -1
- package/src/components/shared/DocumentSourceField.tsx +51 -0
- package/src/components/shared/Navigation.tsx +53 -10
- package/src/components/shell/EditorModal.tsx +2 -5
- package/src/components/shell/EditorShell.tsx +132 -8
- package/src/components/shell/MediaBreadcrumb.tsx +14 -1
- package/src/components/shell/MediaFolderTile.tsx +4 -2
- package/src/components/shell/MediaFolderTree.tsx +20 -3
- package/src/components/shell/MediaLibraryContext.tsx +24 -1
- package/src/components/shell/MediaLibraryModal.tsx +223 -45
- package/src/hooks/useDocumentUploads.ts +336 -0
- package/src/hooks/useEditorPublish.ts +180 -8
- package/src/hooks/useMediaPipeline.ts +141 -5
- package/src/hooks/useResolvedDocument.ts +25 -0
- package/src/lib/dexie.ts +66 -1
- package/src/lib/document-refs.ts +83 -0
- package/src/lib/document-uploads.ts +66 -0
- package/src/lib/documents.ts +138 -0
- package/src/lib/media-folders.ts +9 -0
- package/src/lib/nav.ts +46 -2
- package/src/lib/platform-broker.ts +18 -3
- package/src/lib/registry.ts +9 -0
- package/src/lib/save-chunks.ts +42 -0
- package/src/lib/scroll-lock.ts +27 -0
- package/src/schemas/documents.ts +82 -0
- package/src/schemas/index.ts +1 -0
- package/src/storage/index.ts +2 -2
- package/src/storage/types.ts +58 -4
- /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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
+
}
|