@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.
- package/dist/{chunk-FAZFADJK.js → chunk-3TO4RRHY.js} +3 -1
- package/dist/{chunk-KP4DWTPR.js → chunk-72FMQQIA.js} +246 -3
- package/dist/chunk-BS5AQHZG.js +45 -0
- package/dist/{chunk-HKHEAH5F.js → chunk-GOCFTXR3.js} +8 -60
- package/dist/{chunk-GYCBWGVW.js → chunk-LBHQWIVL.js} +79 -24
- 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 +33 -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/sections/Document/index.d.ts +18 -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 +18 -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 +11 -0
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +32 -0
- 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 +47 -0
- 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 +129 -0
- package/src/components/docfile/doc-file-state.ts +180 -0
- package/src/components/editor/SettingsForm.tsx +10 -0
- package/src/components/sections/Document/index.tsx +163 -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 +111 -7
- 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 +18 -1
- package/src/components/shell/MediaLibraryModal.tsx +223 -45
- package/src/hooks/useDocumentUploads.ts +330 -0
- package/src/hooks/useEditorPublish.ts +180 -8
- package/src/hooks/useMediaPipeline.ts +136 -5
- package/src/hooks/useResolvedDocument.ts +25 -0
- package/src/lib/dexie.ts +54 -0
- 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 +46 -0
- /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,13 @@ 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
|
+
}), [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
|
+
}
|
package/src/lib/media-folders.ts
CHANGED
|
@@ -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);
|