@drawnagency/primitives 0.1.98 → 0.1.100

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/dist/{chunk-EUSWEVOA.js → chunk-3TO4RRHY.js} +12 -1
  2. package/dist/{chunk-KP4DWTPR.js → chunk-72FMQQIA.js} +246 -3
  3. package/dist/chunk-BS5AQHZG.js +45 -0
  4. package/dist/{chunk-NA5EJM6Z.js → chunk-CZLYECHC.js} +19 -8
  5. package/dist/{chunk-HKHEAH5F.js → chunk-GOCFTXR3.js} +8 -60
  6. package/dist/{chunk-GYCBWGVW.js → chunk-LBHQWIVL.js} +79 -24
  7. package/dist/{chunk-HJSE6SK5.js → chunk-REDDTTGJ.js} +3 -3
  8. package/dist/collab/rebase.d.ts.map +1 -1
  9. package/dist/collab/rebase.js +3 -1
  10. package/dist/components/chat/ChatPanel.d.ts.map +1 -1
  11. package/dist/components/docfile/DocFileOverlay.d.ts +12 -0
  12. package/dist/components/docfile/DocFileOverlay.d.ts.map +1 -0
  13. package/dist/components/docfile/DocFilePane.d.ts +17 -0
  14. package/dist/components/docfile/DocFilePane.d.ts.map +1 -0
  15. package/dist/components/docfile/doc-file-state.d.ts +33 -0
  16. package/dist/components/docfile/doc-file-state.d.ts.map +1 -0
  17. package/dist/components/editor/SettingsForm.d.ts.map +1 -1
  18. package/dist/components/editor/index.js +1 -1
  19. package/dist/components/sections/Document/index.d.ts +18 -0
  20. package/dist/components/sections/Document/index.d.ts.map +1 -0
  21. package/dist/components/sections/all-sections.d.ts +10 -0
  22. package/dist/components/sections/all-sections.d.ts.map +1 -1
  23. package/dist/components/sections/brand-guide.d.ts +10 -0
  24. package/dist/components/sections/brand-guide.d.ts.map +1 -1
  25. package/dist/components/sections/brand-guide.js +3 -1
  26. package/dist/components/sections/register-schemas.js +5 -3
  27. package/dist/components/sections/register.js +5 -3
  28. package/dist/components/shared/DocumentSourceField.d.ts +13 -0
  29. package/dist/components/shared/DocumentSourceField.d.ts.map +1 -0
  30. package/dist/components/shared/Navigation.d.ts.map +1 -1
  31. package/dist/components/shell/EditorModal.d.ts.map +1 -1
  32. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  33. package/dist/components/shell/MediaBreadcrumb.d.ts.map +1 -1
  34. package/dist/components/shell/MediaFolderTile.d.ts +3 -1
  35. package/dist/components/shell/MediaFolderTile.d.ts.map +1 -1
  36. package/dist/components/shell/MediaFolderTree.d.ts +3 -0
  37. package/dist/components/shell/MediaFolderTree.d.ts.map +1 -1
  38. package/dist/components/shell/MediaLibraryContext.d.ts +18 -1
  39. package/dist/components/shell/MediaLibraryContext.d.ts.map +1 -1
  40. package/dist/components/shell/MediaLibraryModal.d.ts +21 -1
  41. package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
  42. package/dist/hooks/useDocumentUploads.d.ts +32 -0
  43. package/dist/hooks/useDocumentUploads.d.ts.map +1 -0
  44. package/dist/hooks/useEditorPublish.d.ts +23 -1
  45. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  46. package/dist/hooks/useMediaPipeline.d.ts +14 -1
  47. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  48. package/dist/hooks/useResolvedDocument.d.ts +12 -0
  49. package/dist/hooks/useResolvedDocument.d.ts.map +1 -0
  50. package/dist/index.js +60 -34
  51. package/dist/lib/block-move.d.ts.map +1 -1
  52. package/dist/lib/dexie.d.ts +11 -0
  53. package/dist/lib/dexie.d.ts.map +1 -1
  54. package/dist/lib/dexie.js +32 -0
  55. package/dist/lib/document-refs.d.ts +22 -0
  56. package/dist/lib/document-refs.d.ts.map +1 -0
  57. package/dist/lib/document-uploads.d.ts +38 -0
  58. package/dist/lib/document-uploads.d.ts.map +1 -0
  59. package/dist/lib/documents.d.ts +65 -0
  60. package/dist/lib/documents.d.ts.map +1 -0
  61. package/dist/lib/index-ops.d.ts +1 -0
  62. package/dist/lib/index-ops.d.ts.map +1 -1
  63. package/dist/lib/index-ops.js +13 -1
  64. package/dist/lib/index.js +6 -6
  65. package/dist/lib/loader.d.ts +6 -0
  66. package/dist/lib/loader.d.ts.map +1 -1
  67. package/dist/lib/media-folders.d.ts +8 -0
  68. package/dist/lib/media-folders.d.ts.map +1 -1
  69. package/dist/lib/media-folders.js +2 -0
  70. package/dist/lib/nav.d.ts +29 -1
  71. package/dist/lib/nav.d.ts.map +1 -1
  72. package/dist/lib/pages.d.ts +18 -0
  73. package/dist/lib/pages.d.ts.map +1 -1
  74. package/dist/lib/platform-broker.d.ts +12 -0
  75. package/dist/lib/platform-broker.d.ts.map +1 -1
  76. package/dist/lib/platform-broker.js +12 -3
  77. package/dist/lib/registry.d.ts +10 -0
  78. package/dist/lib/registry.d.ts.map +1 -1
  79. package/dist/lib/save-chunks.d.ts +15 -0
  80. package/dist/lib/save-chunks.d.ts.map +1 -1
  81. package/dist/lib/scroll-lock.d.ts +2 -0
  82. package/dist/lib/scroll-lock.d.ts.map +1 -0
  83. package/dist/schemas/documents.d.ts +80 -0
  84. package/dist/schemas/documents.d.ts.map +1 -0
  85. package/dist/schemas/documents.js +25 -0
  86. package/dist/schemas/index.d.ts +1 -0
  87. package/dist/schemas/index.d.ts.map +1 -1
  88. package/dist/schemas/index.js +24 -2
  89. package/dist/storage/index.d.ts +2 -2
  90. package/dist/storage/index.d.ts.map +1 -1
  91. package/dist/storage/index.js +5 -1
  92. package/dist/storage/types.d.ts +69 -5
  93. package/dist/storage/types.d.ts.map +1 -1
  94. package/dist/types/database.d.ts +14 -0
  95. package/dist/types/database.d.ts.map +1 -1
  96. package/package.json +5 -1
  97. package/src/collab/rebase.ts +15 -0
  98. package/src/components/chat/ChatPanel.tsx +5 -4
  99. package/src/components/docfile/DocFileOverlay.tsx +54 -0
  100. package/src/components/docfile/DocFilePane.tsx +129 -0
  101. package/src/components/docfile/doc-file-state.ts +180 -0
  102. package/src/components/editor/SettingsForm.tsx +10 -0
  103. package/src/components/sections/Document/index.tsx +163 -0
  104. package/src/components/sections/brand-guide.ts +2 -1
  105. package/src/components/shared/DocumentSourceField.tsx +51 -0
  106. package/src/components/shared/Navigation.tsx +53 -10
  107. package/src/components/shell/EditorModal.tsx +2 -5
  108. package/src/components/shell/EditorShell.tsx +154 -10
  109. package/src/components/shell/MediaBreadcrumb.tsx +14 -1
  110. package/src/components/shell/MediaFolderTile.tsx +4 -2
  111. package/src/components/shell/MediaFolderTree.tsx +20 -3
  112. package/src/components/shell/MediaLibraryContext.tsx +18 -1
  113. package/src/components/shell/MediaLibraryModal.tsx +223 -45
  114. package/src/hooks/useDocumentUploads.ts +330 -0
  115. package/src/hooks/useEditorPublish.ts +197 -11
  116. package/src/hooks/useMediaPipeline.ts +136 -5
  117. package/src/hooks/useResolvedDocument.ts +25 -0
  118. package/src/lib/block-move.ts +15 -4
  119. package/src/lib/dexie.ts +54 -0
  120. package/src/lib/document-refs.ts +83 -0
  121. package/src/lib/document-uploads.ts +66 -0
  122. package/src/lib/documents.ts +138 -0
  123. package/src/lib/index-ops.ts +16 -0
  124. package/src/lib/loader.ts +32 -11
  125. package/src/lib/media-folders.ts +9 -0
  126. package/src/lib/nav.ts +46 -2
  127. package/src/lib/pages.ts +48 -0
  128. package/src/lib/platform-broker.ts +18 -3
  129. package/src/lib/registry.ts +9 -0
  130. package/src/lib/save-chunks.ts +42 -0
  131. package/src/lib/scroll-lock.ts +27 -0
  132. package/src/schemas/documents.ts +82 -0
  133. package/src/schemas/index.ts +1 -0
  134. package/src/schemas/nav.ts +3 -3
  135. package/src/storage/index.ts +2 -2
  136. package/src/storage/types.ts +71 -5
  137. package/src/types/database.ts +14 -0
  138. /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, MediaKind } from "../../media/types";
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, itemFolderId, recursiveItemCounts,
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" | MediaKind;
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]) setCurrentFolderId(null);
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, currentFolderId);
284
- if (currentFolderId) expand(currentFolderId);
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, currentFolderId);
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
- if (file.size > maxFileSize && !isBucketEligibleVideo) {
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, currentFolderId);
310
- }, [maxFileSize, onUpload, currentFolderId]);
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 totalUsage = selectedIds.reduce((sum, id) => sum + (referenceCountMap[id] ?? 0), 0);
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
- Array.from(selected).map((id) => (itemsById[id] ? itemFolderId(folders, itemsById[id]) : null)),
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
- onDelete(selectedIds);
353
- setSelected(new Set());
499
+ dispatchDelete();
354
500
  }
355
501
 
356
502
  function handleConfirmDelete() {
357
- onDelete(selectedIds);
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
- return !maxFileSize || file.size <= maxFileSize || isBucketEligibleVideo;
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, currentFolderId);
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
- {mode === "manage" && selected.size > 0 && onMoveMedia && (
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 {selected.size}
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 {selected.size} item{selected.size === 1 ? "" : "s"} to…</p>
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(Array.from(selected), target); setSelected(new Set()); }}
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 images are being used. Deleting these will remove them from these references.
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 && currentFolderId !== null
649
- ? "This folder is empty"
650
- : items.length === 0
651
- ? "No media uploaded yet"
652
- : "No results found"}
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 && currentFolderId !== null
656
- ? "Drag media here or upload into this folder."
657
- : items.length === 0
658
- ? "Upload files using the drop zone above."
659
- : "Try a different search or filter."}
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) && selected.size > 0 ? Array.from(selected) : [item.id];
847
+ const dragIds = selected.has(item.id) ? selectedImageIds : [item.id];
670
848
 
671
849
  return (
672
850
  <MediaCard