@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
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { useState, useRef, useCallback, useMemo, useEffect } from "react";
|
|
2
|
-
import { Search, Upload, Trash2, FolderPlus, FolderInput } from "lucide-react";
|
|
2
|
+
import { Search, Upload, Trash2, FolderPlus, FolderInput, FileText } from "lucide-react";
|
|
3
3
|
import { cn } from "../../lib/cn";
|
|
4
4
|
import { Button } from "../shared/Button";
|
|
5
5
|
import { UploadRejectionAlert } from "../shared/UploadRejectionAlert";
|
|
6
6
|
import { Select } from "../shared/Select";
|
|
7
|
-
import type { MediaFolder, MediaItem
|
|
7
|
+
import type { MediaFolder, MediaItem } from "../../media/types";
|
|
8
8
|
import { displayFilename, displayName, formatFileSize, mimeToExt } from "../../media/utils";
|
|
9
9
|
import {
|
|
10
|
-
childFolderIds, descendantFolderIds, folderPathIds, folderPathNames,
|
|
10
|
+
childFolderIds, descendantFolderIds, DOCUMENTS_FOLDER_ID, folderPathIds, folderPathNames,
|
|
11
|
+
itemFolderId, recursiveItemCounts,
|
|
11
12
|
} from "../../lib/media-folders";
|
|
12
13
|
import { buildMediaDragData } from "../../lib/media-dnd";
|
|
13
14
|
import { useMediaDraggable } from "../../hooks/useMediaFolderDnd";
|
|
@@ -16,15 +17,42 @@ import { MediaFolderTile } from "./MediaFolderTile";
|
|
|
16
17
|
import { MediaBreadcrumb } from "./MediaBreadcrumb";
|
|
17
18
|
import { MoveToFolderDialog } from "./MoveToFolderDialog";
|
|
18
19
|
import { collectDroppedFiles, type DroppedFile } from "../../media/folder-drop";
|
|
20
|
+
import { DOCUMENT_MIME_TO_EXT } from "../../schemas/documents";
|
|
21
|
+
import type { LibraryKind } from "./MediaLibraryContext";
|
|
22
|
+
|
|
23
|
+
/** Flattened document-manifest row for display. The document manifest is a
|
|
24
|
+
* sidecar keyed by documentId; this is the id + the fields the tile shows. */
|
|
25
|
+
export interface DocumentLibraryItem {
|
|
26
|
+
id: string;
|
|
27
|
+
filename: string;
|
|
28
|
+
size: number;
|
|
29
|
+
kind: "pdf" | "html";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ALL_LIBRARY_KINDS: ReadonlyArray<LibraryKind> = ["image", "animated", "video", "document"];
|
|
33
|
+
/** Kinds a picker sees when the caller specified none — the pre-documents
|
|
34
|
+
* behavior every existing openSelectModal call site relies on. */
|
|
35
|
+
const DEFAULT_PICKER_KINDS: ReadonlyArray<LibraryKind> = ["image", "animated", "video"];
|
|
19
36
|
|
|
20
37
|
export interface MediaLibraryModalProps {
|
|
21
38
|
mode: "select" | "manage";
|
|
22
39
|
items: MediaItem[];
|
|
40
|
+
/** Sidecar-manifest documents, merged into the grid for display only. */
|
|
41
|
+
documents?: DocumentLibraryItem[];
|
|
42
|
+
/** Restricts which kinds are shown and which filter options are offered. */
|
|
43
|
+
kinds?: ReadonlyArray<LibraryKind>;
|
|
23
44
|
onSelect: (id: string) => void;
|
|
24
45
|
onUpload: (files: File[], targetFolderId?: string | null) => void;
|
|
25
46
|
onDelete: (ids: string[]) => void;
|
|
47
|
+
/** Manage-mode delete for the document half of a selection. Required: a
|
|
48
|
+
* no-op default would let an editor confirm a deletion that silently
|
|
49
|
+
* vanishes. */
|
|
50
|
+
onDeleteDocuments: (ids: string[]) => void;
|
|
26
51
|
onAltChange?: (id: string, alt: string) => void;
|
|
27
52
|
referenceCountMap: Record<string, number>;
|
|
53
|
+
/** Usage counts keyed by documentId (the `tallyDocumentIds` twin of
|
|
54
|
+
* `referenceCountMap`). */
|
|
55
|
+
documentReferenceCountMap?: Record<string, number>;
|
|
28
56
|
localUrlMap?: Record<string, string>;
|
|
29
57
|
maxFileSize?: number;
|
|
30
58
|
// Direct-to-bucket large-video upload progress, keyed by content hash (0..1).
|
|
@@ -45,7 +73,7 @@ export interface MediaLibraryModalProps {
|
|
|
45
73
|
onDismissUploadNotice?: () => void;
|
|
46
74
|
}
|
|
47
75
|
|
|
48
|
-
type KindFilter = "all" |
|
|
76
|
+
type KindFilter = "all" | LibraryKind;
|
|
49
77
|
|
|
50
78
|
function thumbnailSrc(item: MediaItem, localUrls: Record<string, string>): string {
|
|
51
79
|
const localUrl = localUrls[item.id];
|
|
@@ -60,6 +88,71 @@ function thumbnailSrc(item: MediaItem, localUrls: Record<string, string>): strin
|
|
|
60
88
|
return `/api/media/${item.id}/poster.webp`;
|
|
61
89
|
}
|
|
62
90
|
|
|
91
|
+
function DocumentTile({ doc, manage, isSelected, refCount, showFolderChip, onClick }: {
|
|
92
|
+
doc: DocumentLibraryItem;
|
|
93
|
+
manage: boolean;
|
|
94
|
+
isSelected: boolean;
|
|
95
|
+
refCount: number;
|
|
96
|
+
showFolderChip: boolean;
|
|
97
|
+
onClick: () => void;
|
|
98
|
+
}) {
|
|
99
|
+
return (
|
|
100
|
+
<button
|
|
101
|
+
type="button"
|
|
102
|
+
aria-label={`Select ${doc.filename}`}
|
|
103
|
+
// Manage mode is a selection toggle, not a picker — aria-pressed is what
|
|
104
|
+
// says which of the two this click does.
|
|
105
|
+
aria-pressed={manage ? isSelected : undefined}
|
|
106
|
+
onClick={onClick}
|
|
107
|
+
className="group flex cursor-pointer flex-col gap-1.5 text-left"
|
|
108
|
+
>
|
|
109
|
+
<div className={cn(
|
|
110
|
+
"relative flex aspect-square items-center justify-center rounded-lg border-2 bg-base-accent transition-colors",
|
|
111
|
+
manage
|
|
112
|
+
? isSelected ? "border-primary" : "border-transparent hover:border-base-300"
|
|
113
|
+
: "border-transparent hover:border-primary",
|
|
114
|
+
)}>
|
|
115
|
+
<FileText size={32} className="text-base-contrast-light" aria-hidden="true" />
|
|
116
|
+
<span className="absolute bottom-1.5 right-1.5 rounded-full bg-black/50 px-2 py-0.5 text-[11px] font-medium text-white">
|
|
117
|
+
{doc.kind.toUpperCase()}
|
|
118
|
+
</span>
|
|
119
|
+
|
|
120
|
+
{/* Selection radio + usage pill — the image-tile idioms, same corners. */}
|
|
121
|
+
{manage && (
|
|
122
|
+
<div className={cn(
|
|
123
|
+
"absolute top-1.5 left-1.5 flex h-5 w-5 items-center justify-center rounded-full border-2 transition-colors",
|
|
124
|
+
isSelected
|
|
125
|
+
? "border-primary bg-primary"
|
|
126
|
+
: "border-white/80 bg-white/40 opacity-0 group-hover:opacity-100",
|
|
127
|
+
)}>
|
|
128
|
+
{isSelected && <div className="h-2 w-2 rounded-full bg-white" />}
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
{manage && (
|
|
132
|
+
<div className={cn(
|
|
133
|
+
"absolute top-1.5 right-1.5 rounded-full bg-black/50 px-2 py-0.5 text-[11px] font-medium transition-opacity",
|
|
134
|
+
isSelected ? "opacity-100" : "opacity-0 group-hover:opacity-100",
|
|
135
|
+
refCount > 0 ? "text-white" : "text-white/70",
|
|
136
|
+
)}>
|
|
137
|
+
{refCount === 0 ? "Not used" : `Used ${refCount}x`}
|
|
138
|
+
</div>
|
|
139
|
+
)}
|
|
140
|
+
|
|
141
|
+
{/* Folder chip — documents live in the pseudo-folder, so a global
|
|
142
|
+
search result says so the same way a filed image does. */}
|
|
143
|
+
{showFolderChip && (
|
|
144
|
+
<div className="absolute bottom-1.5 left-1.5 max-w-[calc(100%-12px)] truncate rounded-full bg-black/50 px-2 py-0.5 text-[11px] text-white">
|
|
145
|
+
Documents
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
<div className="px-0.5">
|
|
150
|
+
<p className="truncate text-xs text-base-contrast">{doc.filename}</p>
|
|
151
|
+
<p className="text-[11px] text-base-contrast-light">{formatFileSize(doc.size)}</p>
|
|
152
|
+
</div>
|
|
153
|
+
</button>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
63
156
|
|
|
64
157
|
function UploadZone({ onUpload, onFolderUpload }: {
|
|
65
158
|
onUpload: (files: File[]) => void;
|
|
@@ -135,7 +228,7 @@ function UploadZone({ onUpload, onFolderUpload }: {
|
|
|
135
228
|
ref={inputRef}
|
|
136
229
|
type="file"
|
|
137
230
|
multiple
|
|
138
|
-
accept="image/*,video
|
|
231
|
+
accept="image/*,video/*,application/pdf,text/html"
|
|
139
232
|
className="hidden"
|
|
140
233
|
onClick={(e) => e.stopPropagation()}
|
|
141
234
|
onChange={(e) => { handleFiles(e.target.files); e.target.value = ""; }}
|
|
@@ -185,11 +278,15 @@ function MediaCard({ item, manage, isSelected, dragIds, onClick, children }: {
|
|
|
185
278
|
export function MediaLibraryModal({
|
|
186
279
|
mode,
|
|
187
280
|
items,
|
|
281
|
+
documents = [],
|
|
282
|
+
kinds,
|
|
188
283
|
onSelect,
|
|
189
284
|
onUpload,
|
|
190
285
|
onDelete,
|
|
286
|
+
onDeleteDocuments,
|
|
191
287
|
onAltChange,
|
|
192
288
|
referenceCountMap,
|
|
289
|
+
documentReferenceCountMap = {},
|
|
193
290
|
localUrlMap = {},
|
|
194
291
|
maxFileSize,
|
|
195
292
|
uploadProgress = {},
|
|
@@ -219,6 +316,16 @@ export function MediaLibraryModal({
|
|
|
219
316
|
const [folderToDelete, setFolderToDelete] = useState<string | null>(null);
|
|
220
317
|
|
|
221
318
|
const manage = mode === "manage";
|
|
319
|
+
// Manage mode curates everything; a picker sees only what it asked for.
|
|
320
|
+
const visibleKinds = kinds ?? (manage ? ALL_LIBRARY_KINDS : DEFAULT_PICKER_KINDS);
|
|
321
|
+
const showDocuments = visibleKinds.includes("document");
|
|
322
|
+
// The pseudo-folder exists to keep documents out of a MIXED grid. A picker
|
|
323
|
+
// restricted to documents is already the documents view, so it gets none.
|
|
324
|
+
const documentsFolderEnabled = showDocuments && visibleKinds.some((k) => k !== "document");
|
|
325
|
+
const inDocuments = currentFolderId === DOCUMENTS_FOLDER_ID;
|
|
326
|
+
// Every write path that takes the current folder: the pseudo-folder is not a
|
|
327
|
+
// real folder, so uploads and new folders made from inside it land at root.
|
|
328
|
+
const writeFolderId = inDocuments ? null : currentFolderId;
|
|
222
329
|
const filtering = search !== "" || kindFilter !== "all";
|
|
223
330
|
|
|
224
331
|
const itemsById = useMemo(
|
|
@@ -270,18 +377,24 @@ export function MediaLibraryModal({
|
|
|
270
377
|
});
|
|
271
378
|
}, []);
|
|
272
379
|
|
|
273
|
-
// Current folder deleted (or healed away) -> back to root.
|
|
380
|
+
// Current folder deleted (or healed away) -> back to root. The Documents
|
|
381
|
+
// pseudo-folder is exempt: it has no `folders` entry BY DESIGN, so without
|
|
382
|
+
// this it could never be opened.
|
|
274
383
|
useEffect(() => {
|
|
275
|
-
if (currentFolderId && !folders[currentFolderId])
|
|
384
|
+
if (currentFolderId && currentFolderId !== DOCUMENTS_FOLDER_ID && !folders[currentFolderId]) {
|
|
385
|
+
setCurrentFolderId(null);
|
|
386
|
+
}
|
|
276
387
|
}, [folders, currentFolderId]);
|
|
277
388
|
|
|
278
389
|
const childIds = filtering ? [] : childFolderIds(folders, currentFolderId);
|
|
390
|
+
const showDocumentsTile =
|
|
391
|
+
documentsFolderEnabled && documents.length > 0 && !filtering && currentFolderId === null;
|
|
279
392
|
|
|
280
393
|
const commitNewFolder = () => {
|
|
281
394
|
const name = newFolderName.trim();
|
|
282
395
|
if (name && onCreateFolder) {
|
|
283
|
-
onCreateFolder(name,
|
|
284
|
-
if (
|
|
396
|
+
onCreateFolder(name, writeFolderId);
|
|
397
|
+
if (writeFolderId) expand(writeFolderId);
|
|
285
398
|
}
|
|
286
399
|
setNewFolderOpen(false);
|
|
287
400
|
setNewFolderName("");
|
|
@@ -289,7 +402,7 @@ export function MediaLibraryModal({
|
|
|
289
402
|
|
|
290
403
|
const handleUpload = useCallback((files: File[]) => {
|
|
291
404
|
if (!maxFileSize) {
|
|
292
|
-
onUpload(files,
|
|
405
|
+
onUpload(files, writeFolderId);
|
|
293
406
|
return;
|
|
294
407
|
}
|
|
295
408
|
const valid: File[] = [];
|
|
@@ -297,19 +410,23 @@ export function MediaLibraryModal({
|
|
|
297
410
|
for (const file of files) {
|
|
298
411
|
// MP4/WebM videos over the git-path cap route through the pipeline's
|
|
299
412
|
// direct-to-bucket upload instead of being rejected here — it applies
|
|
300
|
-
// its own (much larger) asset cap and reports its own errors.
|
|
413
|
+
// its own (much larger) asset cap and reports its own errors. Documents
|
|
414
|
+
// do the same through the document pipeline (git ≤2.5MiB, bucket above),
|
|
415
|
+
// so this image-shaped cap must not pre-reject them.
|
|
301
416
|
const isBucketEligibleVideo = file.type === "video/mp4" || file.type === "video/webm";
|
|
302
|
-
|
|
417
|
+
const isDocument = file.type in DOCUMENT_MIME_TO_EXT;
|
|
418
|
+
if (file.size > maxFileSize && !isBucketEligibleVideo && !isDocument) {
|
|
303
419
|
rejected.push(file.name);
|
|
304
420
|
} else {
|
|
305
421
|
valid.push(file);
|
|
306
422
|
}
|
|
307
423
|
}
|
|
308
424
|
if (rejected.length > 0) setRejectedFiles(rejected);
|
|
309
|
-
if (valid.length > 0) onUpload(valid,
|
|
310
|
-
}, [maxFileSize, onUpload,
|
|
425
|
+
if (valid.length > 0) onUpload(valid, writeFolderId);
|
|
426
|
+
}, [maxFileSize, onUpload, writeFolderId]);
|
|
311
427
|
|
|
312
428
|
const filtered = items.filter((item) => {
|
|
429
|
+
if (!visibleKinds.includes(item.kind)) return false;
|
|
313
430
|
const matchesSearch = displayName(item).toLowerCase().includes(search.toLowerCase());
|
|
314
431
|
const matchesKind = kindFilter === "all" || item.kind === kindFilter;
|
|
315
432
|
if (!matchesSearch || !matchesKind) return false;
|
|
@@ -317,6 +434,18 @@ export function MediaLibraryModal({
|
|
|
317
434
|
return true;
|
|
318
435
|
});
|
|
319
436
|
|
|
437
|
+
// Documents carry no folderId: they live in the Documents pseudo-folder (or,
|
|
438
|
+
// when it isn't offered, at the root) and in any search/filter result.
|
|
439
|
+
const documentsHome = documentsFolderEnabled ? DOCUMENTS_FOLDER_ID : null;
|
|
440
|
+
const filteredDocuments = showDocuments
|
|
441
|
+
? documents.filter((doc) => {
|
|
442
|
+
if (!doc.filename.toLowerCase().includes(search.toLowerCase())) return false;
|
|
443
|
+
if (kindFilter !== "all" && kindFilter !== "document") return false;
|
|
444
|
+
if (!filtering && currentFolderId !== documentsHome) return false;
|
|
445
|
+
return true;
|
|
446
|
+
})
|
|
447
|
+
: [];
|
|
448
|
+
|
|
320
449
|
function toggleSelect(id: string) {
|
|
321
450
|
setSelected((prev) => {
|
|
322
451
|
const next = new Set(prev);
|
|
@@ -331,31 +460,47 @@ export function MediaLibraryModal({
|
|
|
331
460
|
|
|
332
461
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
333
462
|
|
|
463
|
+
// One selection, two kinds. Everything downstream — delete dispatch, usage
|
|
464
|
+
// totals, the move dialog — splits on this: documents have no folder and
|
|
465
|
+
// their delete goes to a different handler.
|
|
466
|
+
const documentIdSet = useMemo(() => new Set(documents.map((d) => d.id)), [documents]);
|
|
334
467
|
const selectedIds = Array.from(selected);
|
|
335
|
-
const
|
|
468
|
+
const selectedDocumentIds = selectedIds.filter((id) => documentIdSet.has(id));
|
|
469
|
+
const selectedImageIds = selectedIds.filter((id) => !documentIdSet.has(id));
|
|
470
|
+
const totalUsage =
|
|
471
|
+
selectedImageIds.reduce((sum, id) => sum + (referenceCountMap[id] ?? 0), 0) +
|
|
472
|
+
selectedDocumentIds.reduce((sum, id) => sum + (documentReferenceCountMap[id] ?? 0), 0);
|
|
473
|
+
const usedNoun =
|
|
474
|
+
selectedDocumentIds.length === 0 ? "images"
|
|
475
|
+
: selectedImageIds.length === 0 ? "documents"
|
|
476
|
+
: "files";
|
|
336
477
|
|
|
337
478
|
// The move dialog's no-op target is the selection's folder — not the folder
|
|
338
479
|
// being viewed (selection survives navigation). Heterogeneous selections have
|
|
339
480
|
// no single no-op target, so nothing is disabled.
|
|
340
481
|
const selectionFolderIds = new Set(
|
|
341
|
-
|
|
482
|
+
selectedImageIds.map((id) => (itemsById[id] ? itemFolderId(folders, itemsById[id]) : null)),
|
|
342
483
|
);
|
|
343
484
|
const moveDialogCurrentTarget =
|
|
344
485
|
selectionFolderIds.size === 1 ? Array.from(selectionFolderIds)[0] : undefined;
|
|
345
486
|
|
|
487
|
+
function dispatchDelete() {
|
|
488
|
+
if (selectedImageIds.length > 0) onDelete(selectedImageIds);
|
|
489
|
+
if (selectedDocumentIds.length > 0) onDeleteDocuments(selectedDocumentIds);
|
|
490
|
+
setSelected(new Set());
|
|
491
|
+
}
|
|
492
|
+
|
|
346
493
|
function handleBatchDelete() {
|
|
347
494
|
if (totalUsage > 0) {
|
|
348
495
|
setConfirmDelete(true);
|
|
349
496
|
setFolderToDelete(null); // the two delete confirmations are mutually exclusive
|
|
350
497
|
return;
|
|
351
498
|
}
|
|
352
|
-
|
|
353
|
-
setSelected(new Set());
|
|
499
|
+
dispatchDelete();
|
|
354
500
|
}
|
|
355
501
|
|
|
356
502
|
function handleConfirmDelete() {
|
|
357
|
-
|
|
358
|
-
setSelected(new Set());
|
|
503
|
+
dispatchDelete();
|
|
359
504
|
setConfirmDelete(false);
|
|
360
505
|
}
|
|
361
506
|
|
|
@@ -369,6 +514,7 @@ export function MediaLibraryModal({
|
|
|
369
514
|
onExpand: expand,
|
|
370
515
|
onToggleExpand: toggleExpand,
|
|
371
516
|
dnd,
|
|
517
|
+
documentCount: documentsFolderEnabled ? documents.length : 0,
|
|
372
518
|
};
|
|
373
519
|
|
|
374
520
|
return (
|
|
@@ -408,11 +554,12 @@ export function MediaLibraryModal({
|
|
|
408
554
|
? (dropped) => {
|
|
409
555
|
const valid = dropped.filter(({ file }) => {
|
|
410
556
|
const isBucketEligibleVideo = file.type === "video/mp4" || file.type === "video/webm";
|
|
411
|
-
|
|
557
|
+
const isDocument = file.type in DOCUMENT_MIME_TO_EXT;
|
|
558
|
+
return !maxFileSize || file.size <= maxFileSize || isBucketEligibleVideo || isDocument;
|
|
412
559
|
});
|
|
413
560
|
const rejected = dropped.length - valid.length;
|
|
414
561
|
if (rejected > 0) setRejectedFiles(dropped.filter((d) => !valid.includes(d)).map((d) => d.file.name));
|
|
415
|
-
if (valid.length > 0) onFolderUpload(valid,
|
|
562
|
+
if (valid.length > 0) onFolderUpload(valid, writeFolderId);
|
|
416
563
|
}
|
|
417
564
|
: undefined
|
|
418
565
|
}
|
|
@@ -468,24 +615,27 @@ export function MediaLibraryModal({
|
|
|
468
615
|
onChange={(v) => setKindFilter(v as KindFilter)}
|
|
469
616
|
options={[
|
|
470
617
|
{ value: "all", label: "All types" },
|
|
471
|
-
{ value: "image", label: "Images" },
|
|
472
|
-
{ value: "animated", label: "Animated" },
|
|
473
|
-
{ value: "video", label: "Video" },
|
|
618
|
+
...(visibleKinds.includes("image") ? [{ value: "image", label: "Images" }] : []),
|
|
619
|
+
...(visibleKinds.includes("animated") ? [{ value: "animated", label: "Animated" }] : []),
|
|
620
|
+
...(visibleKinds.includes("video") ? [{ value: "video", label: "Video" }] : []),
|
|
621
|
+
...(showDocuments ? [{ value: "document", label: "Documents" }] : []),
|
|
474
622
|
]}
|
|
475
623
|
selectClassName="py-1.5 pr-7 pl-2.5"
|
|
476
624
|
/>
|
|
477
625
|
|
|
478
|
-
{manage && onCreateFolder && !filtering && (
|
|
626
|
+
{manage && onCreateFolder && !filtering && !inDocuments && (
|
|
479
627
|
<Button variant="secondary" size="sm" onClick={() => setNewFolderOpen(true)} className="flex items-center gap-1.5">
|
|
480
628
|
<FolderPlus size={13} />
|
|
481
629
|
New folder
|
|
482
630
|
</Button>
|
|
483
631
|
)}
|
|
484
632
|
|
|
485
|
-
{
|
|
633
|
+
{/* Documents have no folder, so they are not movable — a mixed
|
|
634
|
+
selection offers to move only its images. */}
|
|
635
|
+
{mode === "manage" && selectedImageIds.length > 0 && onMoveMedia && (
|
|
486
636
|
<Button variant="secondary" size="sm" onClick={() => setMoveDialogOpen(true)} className="flex items-center gap-1.5">
|
|
487
637
|
<FolderInput size={13} />
|
|
488
|
-
Move {
|
|
638
|
+
Move {selectedImageIds.length}
|
|
489
639
|
</Button>
|
|
490
640
|
)}
|
|
491
641
|
|
|
@@ -504,11 +654,11 @@ export function MediaLibraryModal({
|
|
|
504
654
|
|
|
505
655
|
{moveDialogOpen && onMoveMedia && (
|
|
506
656
|
<div>
|
|
507
|
-
<p className="mb-2 text-sm font-medium text-base-contrast">Move {
|
|
657
|
+
<p className="mb-2 text-sm font-medium text-base-contrast">Move {selectedImageIds.length} item{selectedImageIds.length === 1 ? "" : "s"} to…</p>
|
|
508
658
|
<MoveToFolderDialog
|
|
509
659
|
folders={folders}
|
|
510
660
|
currentTarget={moveDialogCurrentTarget}
|
|
511
|
-
onMove={(target) => { onMoveMedia(
|
|
661
|
+
onMove={(target) => { onMoveMedia(selectedImageIds, target); setSelected(new Set()); }}
|
|
512
662
|
onClose={() => setMoveDialogOpen(false)}
|
|
513
663
|
/>
|
|
514
664
|
</div>
|
|
@@ -536,7 +686,7 @@ export function MediaLibraryModal({
|
|
|
536
686
|
{confirmDelete && (
|
|
537
687
|
<div className="flex flex-col items-center rounded-lg border border-red-200 bg-red-50 px-4 py-3 lg:flex-row lg:justify-between dark:border-red-900/50 dark:bg-red-950/30">
|
|
538
688
|
<p className="mb-2 text-center text-sm text-red-800 lg:mb-0 lg:text-left dark:text-red-200">
|
|
539
|
-
Some of these
|
|
689
|
+
Some of these {usedNoun} are being used. Deleting them will remove them from the sections that use them.
|
|
540
690
|
</p>
|
|
541
691
|
<div className="flex shrink-0 items-center gap-2">
|
|
542
692
|
<Button variant="secondary" size="sm" onClick={() => setConfirmDelete(false)}>
|
|
@@ -606,8 +756,21 @@ export function MediaLibraryModal({
|
|
|
606
756
|
<MediaBreadcrumb folders={folders} currentFolderId={currentFolderId} onNavigate={navigate} dnd={dnd} />
|
|
607
757
|
)}
|
|
608
758
|
|
|
609
|
-
{childIds.length > 0 && (
|
|
759
|
+
{(childIds.length > 0 || showDocumentsTile) && (
|
|
610
760
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
|
761
|
+
{showDocumentsTile && (
|
|
762
|
+
// No dnd bindings and manage={false}: the pseudo-folder can be
|
|
763
|
+
// neither renamed, deleted, dragged, nor dropped into.
|
|
764
|
+
<MediaFolderTile
|
|
765
|
+
id={DOCUMENTS_FOLDER_ID}
|
|
766
|
+
name="Documents"
|
|
767
|
+
count={documents.length}
|
|
768
|
+
onOpen={navigate}
|
|
769
|
+
manage={false}
|
|
770
|
+
icon={<FileText size={16} className="shrink-0 text-base-contrast-light" />}
|
|
771
|
+
ariaLabel="Open Documents folder"
|
|
772
|
+
/>
|
|
773
|
+
)}
|
|
611
774
|
{childIds.map((fid) => (
|
|
612
775
|
<MediaFolderTile
|
|
613
776
|
key={fid}
|
|
@@ -641,32 +804,47 @@ export function MediaLibraryModal({
|
|
|
641
804
|
)}
|
|
642
805
|
|
|
643
806
|
{/* Grid */}
|
|
644
|
-
{filtered.length === 0 ? (
|
|
645
|
-
childIds.length === 0 && (
|
|
807
|
+
{filtered.length === 0 && filteredDocuments.length === 0 ? (
|
|
808
|
+
childIds.length === 0 && !showDocumentsTile && (
|
|
646
809
|
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center">
|
|
647
810
|
<p className="text-sm font-medium text-base-contrast">
|
|
648
|
-
{!filtering &&
|
|
649
|
-
? "
|
|
650
|
-
:
|
|
651
|
-
? "
|
|
652
|
-
:
|
|
811
|
+
{!filtering && inDocuments
|
|
812
|
+
? "No documents yet"
|
|
813
|
+
: !filtering && currentFolderId !== null
|
|
814
|
+
? "This folder is empty"
|
|
815
|
+
: items.length === 0 && documents.length === 0
|
|
816
|
+
? "No media uploaded yet"
|
|
817
|
+
: "No results found"}
|
|
653
818
|
</p>
|
|
654
819
|
<p className="text-xs text-base-contrast-light">
|
|
655
|
-
{!filtering &&
|
|
656
|
-
? "
|
|
657
|
-
:
|
|
658
|
-
? "
|
|
659
|
-
:
|
|
820
|
+
{!filtering && inDocuments
|
|
821
|
+
? "Upload a PDF or HTML file using the drop zone above."
|
|
822
|
+
: !filtering && currentFolderId !== null
|
|
823
|
+
? "Drag media here or upload into this folder."
|
|
824
|
+
: items.length === 0 && documents.length === 0
|
|
825
|
+
? "Upload files using the drop zone above."
|
|
826
|
+
: "Try a different search or filter."}
|
|
660
827
|
</p>
|
|
661
828
|
</div>
|
|
662
829
|
)
|
|
663
830
|
) : (
|
|
664
831
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
|
832
|
+
{filteredDocuments.map((doc) => (
|
|
833
|
+
<DocumentTile
|
|
834
|
+
key={doc.id}
|
|
835
|
+
doc={doc}
|
|
836
|
+
manage={manage}
|
|
837
|
+
isSelected={selected.has(doc.id)}
|
|
838
|
+
refCount={documentReferenceCountMap[doc.id] ?? 0}
|
|
839
|
+
showFolderChip={filtering && documentsFolderEnabled}
|
|
840
|
+
onClick={() => (manage ? toggleSelect(doc.id) : onSelect(doc.id))}
|
|
841
|
+
/>
|
|
842
|
+
))}
|
|
665
843
|
{filtered.map((item) => {
|
|
666
844
|
const isSelected = selected.has(item.id);
|
|
667
845
|
const refCount = referenceCountMap[item.id] ?? 0;
|
|
668
846
|
const usageLabel = refCount === 0 ? "Not used" : `Used ${refCount}x`;
|
|
669
|
-
const dragIds = selected.has(item.id)
|
|
847
|
+
const dragIds = selected.has(item.id) ? selectedImageIds : [item.id];
|
|
670
848
|
|
|
671
849
|
return (
|
|
672
850
|
<MediaCard
|