deepspace 0.7.0 → 0.8.1

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 (63) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +22 -8
  3. package/dist/cli.js +9812 -4674
  4. package/dist/cli.js.map +1 -1
  5. package/dist/feature-installer.js +638 -0
  6. package/dist/feature-installer.js.map +1 -0
  7. package/dist/index.d.ts +107 -117
  8. package/dist/index.js +610 -779
  9. package/dist/index.js.map +1 -1
  10. package/dist/server.d.ts +403 -167
  11. package/dist/server.js +498 -367
  12. package/dist/server.js.map +1 -1
  13. package/dist/testing.d.ts +115 -0
  14. package/dist/testing.js +3 -3
  15. package/dist/testing.js.map +1 -1
  16. package/dist/worker.d.ts +397 -160
  17. package/dist/worker.js +506 -367
  18. package/dist/worker.js.map +1 -1
  19. package/features/ai-chat/feature.json +2 -0
  20. package/features/ai-chat/src/AiChatPage.tsx +2 -3
  21. package/features/ai-chat/src/ChatPanel.messages.tsx +344 -0
  22. package/features/ai-chat/src/ChatPanel.stream.ts +397 -0
  23. package/features/ai-chat/src/ChatPanel.tsx +158 -862
  24. package/features/ai-chat/src/__tests__/ChatPanel.stream.test.ts +163 -0
  25. package/features/canvas/src/CanvasListPage.tsx +1 -1
  26. package/features/docs/feature.json +4 -8
  27. package/features/docs/src/DocsEditorPage.tsx +33 -326
  28. package/features/docs/src/DocsListPage.tsx +33 -29
  29. package/features/docs/src/InviteDialog.tsx +11 -40
  30. package/features/docs/src/__tests__/docs-presence-access.test.ts +71 -0
  31. package/features/docs/src/docs-library-types.ts +28 -3
  32. package/features/docs/src/docs-ui.css +1 -4
  33. package/features/docs/src/use-docs-presence-access.ts +330 -0
  34. package/features/file-attachments/feature.json +2 -2
  35. package/features/file-attachments/src/AttachmentPreview.tsx +428 -0
  36. package/features/file-attachments/src/AttachmentsPage.tsx +100 -462
  37. package/features/file-attachments/src/useFilePreview.ts +54 -68
  38. package/features/file-manager/src/FileManagerPage.tsx +1 -1
  39. package/features/integration-test/src/integration.spec.ts +1 -1
  40. package/features/items/feature.json +1 -5
  41. package/features/items/src/ItemsPage.tsx +207 -135
  42. package/features/landing/feature.json +3 -0
  43. package/features/landing/src/LandingPage.tsx +4 -18
  44. package/features/leaderboard/feature.json +1 -5
  45. package/features/leaderboard/src/LeaderboardPage.tsx +333 -198
  46. package/features/messaging/feature.json +0 -4
  47. package/features/messaging/src/ChatMultiPage.tsx +1 -3
  48. package/features/messaging/src/ChatPage.tsx +1 -3
  49. package/features/messaging/src/components/chat/MessageActionSheet.tsx +40 -16
  50. package/features/search-bar/feature.json +8 -3
  51. package/features/search-bar/src/SearchOverlay.tsx +108 -252
  52. package/features/search-bar/src/__tests__/search-model.test.ts +40 -0
  53. package/features/search-bar/src/search-model.ts +223 -0
  54. package/features/tasks/src/TasksPage.tsx +3 -3
  55. package/features/topbar/src/Topbar.tsx +0 -17
  56. package/package.json +2 -3
  57. package/features/docs/src/docs-favorites.ts +0 -18
  58. package/features/docs/src/use-yjs-room-with-awareness.ts +0 -289
  59. package/features/file-attachments/src/attachments-constants.ts +0 -9
  60. package/features/items/src/items-constants.ts +0 -10
  61. package/features/leaderboard/src/leaderboard-constants.ts +0 -21
  62. package/features/messaging/src/components/chat/ReactionPicker.tsx +0 -2
  63. package/scripts/add-feature.cjs +0 -807
@@ -1,16 +1,6 @@
1
- /**
2
- * Attachments Page
3
- *
4
- * Demonstrates:
5
- * - useMimeTypeDetection for MIME detection and file categorization
6
- * - useFilePreview for centralized preview with dynamic imports
7
- * - useR2Files for file upload/download/delete (R2 storage)
8
- * - useQuery + useMutations for file metadata (collection records)
9
- *
10
- * To use: Copy to src/pages/ and add route in App.tsx
11
- */
12
-
13
- import { useState, useCallback, useRef, useMemo, useEffect, type RefCallback } from 'react'
1
+ /** Attachment metadata/CRUD over records plus binary storage in R2. */
2
+
3
+ import { useState, useCallback, useRef, useMemo, useEffect } from 'react'
14
4
  import {
15
5
  useUser,
16
6
  useQuery,
@@ -20,14 +10,11 @@ import {
20
10
  formatFileSize,
21
11
  type Role,
22
12
  } from 'deepspace'
23
- import { Button, Modal, EmptyState, Badge } from '@/components/ui'
24
- import { useMimeTypeDetection, type FileCategory } from '../hooks/useMimeTypeDetection'
25
- import { useFilePreview } from '../hooks/useFilePreview'
26
- import { MAX_FILE_SIZE } from '../constants/attachments-constants'
13
+ import { Button, Modal, EmptyState, Badge, useToast } from '@/components/ui'
14
+ import { AttachmentPreview, AttachmentThumbnail } from '../components/AttachmentPreview'
15
+ import { useMimeTypeDetection } from '../hooks/useMimeTypeDetection'
27
16
 
28
- // ============================================================================
29
- // Types
30
- // ============================================================================
17
+ const MAX_FILE_SIZE = 50 * 1024 * 1024
31
18
 
32
19
  interface Attachment {
33
20
  fileName: string
@@ -37,359 +24,6 @@ interface Attachment {
37
24
  ownerId: string
38
25
  }
39
26
 
40
- // ============================================================================
41
- // File Icon (fallback for non-previewable types)
42
- // ============================================================================
43
-
44
- const ICON_PATHS: Record<FileCategory, string> = {
45
- image: 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z',
46
- video: 'M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z',
47
- audio: 'M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z',
48
- pdf: 'M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z',
49
- document: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
50
- spreadsheet: 'M3 10h18M3 14h18M9 4v16M15 4v16M3 6a2 2 0 012-2h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6z',
51
- presentation: 'M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z',
52
- archive: 'M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4',
53
- code: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4',
54
- text: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
55
- other: 'M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z',
56
- }
57
-
58
- function FileIcon({ category }: { category: FileCategory }) {
59
- return (
60
- <div className="w-full h-48 bg-muted/20 rounded-lg flex items-center justify-center">
61
- <svg className="w-12 h-12 text-muted-foreground/50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
62
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={ICON_PATHS[category]} />
63
- </svg>
64
- </div>
65
- )
66
- }
67
-
68
- // ============================================================================
69
- // Thumbnail Preview (card grid view)
70
- // ============================================================================
71
-
72
- function ThumbnailPreview({ url, fileName, mimeType: _mimeType, category }: {
73
- url: string | null
74
- fileName: string
75
- mimeType: string
76
- category: FileCategory
77
- }) {
78
- if (!url) return <FileIcon category={category} />
79
-
80
- switch (category) {
81
- case 'image':
82
- return <img src={url} alt={fileName} className="w-full h-48 object-cover rounded-t-xl" loading="lazy" />
83
- case 'video':
84
- return <video src={url} className="w-full h-48 rounded-t-xl bg-black" preload="metadata" muted />
85
- case 'audio':
86
- return (
87
- <div className="w-full h-48 bg-muted/20 rounded-t-xl flex flex-col items-center justify-center gap-2 px-4">
88
- <svg className="w-8 h-8 text-muted-foreground/50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
89
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={ICON_PATHS.audio} />
90
- </svg>
91
- <audio src={url} controls className="w-full" preload="metadata" />
92
- </div>
93
- )
94
- default:
95
- return <FileIcon category={category} />
96
- }
97
- }
98
-
99
- // ============================================================================
100
- // DOCX Preview Renderer (needs its own component for proper ref management)
101
- // ============================================================================
102
-
103
- function DocxPreviewRenderer({ data, module: docxModule }: {
104
- data: ArrayBuffer
105
- module: typeof import('docx-preview')
106
- }) {
107
- const styleRef = useRef<HTMLDivElement>(null)
108
- const [loading, setLoading] = useState(true)
109
-
110
- // Reset loading state when the document changes. This is a side effect
111
- // (setState), so it belongs in useEffect — `useMemo` is for computing
112
- // values, and React docs warn its body may not run under Concurrent Mode.
113
- useEffect(() => {
114
- setLoading(true)
115
- }, [data])
116
-
117
- const onContainerReady: RefCallback<HTMLDivElement> = useCallback((node) => {
118
- if (!node) return
119
- node.innerHTML = ''
120
- if (styleRef.current) styleRef.current.innerHTML = ''
121
-
122
- docxModule.renderAsync(data, node, styleRef.current ?? undefined, {
123
- className: 'docx-preview-page',
124
- inWrapper: true,
125
- ignoreWidth: true,
126
- ignoreHeight: false,
127
- ignoreFonts: false,
128
- breakPages: true,
129
- renderHeaders: true,
130
- renderFooters: true,
131
- renderFootnotes: true,
132
- renderEndnotes: true,
133
- useBase64URL: true,
134
- }).then(() => setLoading(false))
135
- .catch(() => setLoading(false))
136
- }, [data, docxModule])
137
-
138
- return (
139
- <div className="w-full min-h-[400px] rounded-lg overflow-auto" style={{ background: '#e8e8e8' }}>
140
- {loading && (
141
- <div className="p-8 text-center text-muted-foreground">Loading document...</div>
142
- )}
143
- <div ref={styleRef} />
144
- <div ref={onContainerReady} style={{ minHeight: loading ? 0 : undefined }} />
145
- <style>{`
146
- .docx-preview-page-wrapper {
147
- background: #e8e8e8;
148
- padding: 16px 0;
149
- display: flex;
150
- flex-direction: column;
151
- align-items: center;
152
- gap: 16px;
153
- }
154
- .docx-preview-page-wrapper > section.docx-preview-page {
155
- background: #ffffff !important;
156
- color: #1a1a1a !important;
157
- box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
158
- border-radius: 4px;
159
- overflow: hidden;
160
- width: 100% !important;
161
- padding: 24px !important;
162
- box-sizing: border-box !important;
163
- }
164
- .docx-preview-page-wrapper > section.docx-preview-page * {
165
- color: inherit;
166
- }
167
- `}</style>
168
- </div>
169
- )
170
- }
171
-
172
- // ============================================================================
173
- // XLSX Preview Renderer (needs its own component for useState)
174
- // ============================================================================
175
-
176
- function XlsxPreviewRenderer({ data }: { data: import('../hooks/useFilePreview').XlsxData }) {
177
- const [activeSheet, setActiveSheet] = useState(0)
178
- const { sheets } = data
179
- const sheet = sheets[activeSheet]
180
-
181
- return (
182
- <div className="w-full">
183
- {sheets.length > 1 && (
184
- <div className="flex gap-1 px-2 py-1 bg-muted/30 border-b border-border overflow-x-auto">
185
- {sheets.map((s, i) => (
186
- <button
187
- key={i}
188
- onClick={() => setActiveSheet(i)}
189
- className={`px-3 py-1 rounded text-xs font-medium transition-colors whitespace-nowrap ${
190
- i === activeSheet ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted'
191
- }`}
192
- >
193
- {s.name}
194
- </button>
195
- ))}
196
- </div>
197
- )}
198
- {sheet && (
199
- <div className="overflow-auto rounded-lg border border-border">
200
- <table className="w-full border-collapse text-sm font-mono">
201
- <thead>
202
- <tr>
203
- <th className="sticky top-0 px-3 py-2 text-right text-xs text-muted-foreground bg-muted/50 border-b-2 border-border">#</th>
204
- {sheet.headers.map((h, i) => (
205
- <th key={i} className="sticky top-0 px-3 py-2 text-left font-semibold text-foreground bg-muted/50 border-b-2 border-border whitespace-nowrap">{h}</th>
206
- ))}
207
- </tr>
208
- </thead>
209
- <tbody>
210
- {sheet.rows.map((row, ri) => (
211
- <tr key={ri} className={ri % 2 === 0 ? '' : 'bg-muted/20'}>
212
- <td className="px-3 py-1.5 text-right text-xs text-muted-foreground border-b border-border/50">{ri + 1}</td>
213
- {sheet.headers.map((_, ci) => (
214
- <td key={ci} className="px-3 py-1.5 text-foreground border-b border-border/50 whitespace-nowrap max-w-[300px] truncate">
215
- {row[ci] ?? ''}
216
- </td>
217
- ))}
218
- </tr>
219
- ))}
220
- </tbody>
221
- </table>
222
- {sheet.truncated && (
223
- <div className="px-4 py-2 text-center text-xs text-muted-foreground bg-muted/30 border-t border-border">
224
- Showing first 500 rows. Download the file to see all data.
225
- </div>
226
- )}
227
- </div>
228
- )}
229
- </div>
230
- )
231
- }
232
-
233
- // ============================================================================
234
- // Full Preview Modal Content (uses useFilePreview)
235
- // ============================================================================
236
-
237
- function FullPreview({ url, fileName, mimeType }: { url: string; fileName: string; mimeType: string }) {
238
- const preview = useFilePreview({ url, fileName, mimeType })
239
-
240
- if (preview.isLoading) {
241
- return (
242
- <div className="flex items-center justify-center py-16">
243
- <div className="w-8 h-8 border-2 border-primary/20 border-t-primary rounded-full animate-spin" />
244
- </div>
245
- )
246
- }
247
-
248
- if (preview.error) {
249
- return (
250
- <div className="p-8 text-center text-muted-foreground">
251
- Failed to load preview: {preview.error}
252
- </div>
253
- )
254
- }
255
-
256
- // Image
257
- if (preview.category === 'image') {
258
- return <img src={url} alt={fileName} className="w-full rounded-lg" />
259
- }
260
-
261
- // Video
262
- if (preview.category === 'video') {
263
- return <video src={url} controls className="w-full rounded-lg bg-black" />
264
- }
265
-
266
- // Audio
267
- if (preview.category === 'audio') {
268
- return (
269
- <div className="flex flex-col items-center gap-4 p-8">
270
- <FileIcon category="audio" />
271
- <audio src={url} controls className="w-full" />
272
- </div>
273
- )
274
- }
275
-
276
- // PDF — render via PDF.js canvases (avoids iframe sandbox blocking)
277
- if (preview.category === 'pdf' && preview.pdfPages) {
278
- return (
279
- <div className="flex flex-col gap-4 overflow-auto" style={{ maxHeight: '80vh' }}>
280
- {preview.pdfPages.map((canvas, i) => {
281
- const aspectRatio = canvas.width / canvas.height
282
- return (
283
- <div
284
- key={i}
285
- ref={(el) => {
286
- if (el && !el.hasChildNodes()) {
287
- canvas.style.width = '100%'
288
- canvas.style.height = 'auto'
289
- canvas.style.display = 'block'
290
- el.appendChild(canvas)
291
- }
292
- }}
293
- className="w-full rounded-lg overflow-hidden border border-border bg-white flex-shrink-0"
294
- style={{ aspectRatio: `${aspectRatio}` }}
295
- />
296
- )
297
- })}
298
- </div>
299
- )
300
- }
301
-
302
- // Code/Text with CodeMirror
303
- if ((preview.category === 'code' || preview.category === 'text') && preview.textContent !== null) {
304
- if (preview.codeEditorKit) {
305
- const { Component, langExtensions, darkTheme } = preview.codeEditorKit
306
- return (
307
- <div className="w-full overflow-hidden rounded-lg border border-border">
308
- <Component
309
- value={preview.textContent}
310
- extensions={[...langExtensions, darkTheme]}
311
- readOnly
312
- editable={false}
313
- theme="dark"
314
- height="400px"
315
- basicSetup={{ lineNumbers: true, foldGutter: true, highlightActiveLine: false }}
316
- style={{ fontSize: '13px' }}
317
- />
318
- </div>
319
- )
320
- }
321
- // Fallback: plain text
322
- return (
323
- <pre className="w-full p-4 bg-muted/20 rounded-lg border border-border text-sm text-foreground overflow-auto max-h-[400px] whitespace-pre-wrap">
324
- {preview.textContent}
325
- </pre>
326
- )
327
- }
328
-
329
- // CSV
330
- if (preview.csvData) {
331
- const { headers, rows, truncated } = preview.csvData
332
- return (
333
- <div className="w-full overflow-auto rounded-lg border border-border">
334
- <table className="w-full border-collapse text-sm font-mono">
335
- <thead>
336
- <tr>
337
- <th className="sticky top-0 px-3 py-2 text-right text-xs text-muted-foreground bg-muted/50 border-b-2 border-border">#</th>
338
- {headers.map((h, i) => (
339
- <th key={i} className="sticky top-0 px-3 py-2 text-left font-semibold text-foreground bg-muted/50 border-b-2 border-border whitespace-nowrap">{h}</th>
340
- ))}
341
- </tr>
342
- </thead>
343
- <tbody>
344
- {rows.map((row, ri) => (
345
- <tr key={ri} className={ri % 2 === 0 ? '' : 'bg-muted/20'}>
346
- <td className="px-3 py-1.5 text-right text-xs text-muted-foreground border-b border-border/50">{ri + 1}</td>
347
- {headers.map((_, ci) => (
348
- <td key={ci} className="px-3 py-1.5 text-foreground border-b border-border/50 whitespace-nowrap max-w-[300px] truncate" title={row[ci] ?? ''}>
349
- {row[ci] ?? ''}
350
- </td>
351
- ))}
352
- </tr>
353
- ))}
354
- </tbody>
355
- </table>
356
- {truncated && (
357
- <div className="px-4 py-2 text-center text-xs text-muted-foreground bg-muted/30 border-t border-border">
358
- Showing first 500 rows. Download the file to see all data.
359
- </div>
360
- )}
361
- </div>
362
- )
363
- }
364
-
365
- // DOCX — use DocxPreviewRenderer subcomponent to manage refs properly
366
- if (preview.category === 'document' && preview.docxData && preview.docxModule) {
367
- return <DocxPreviewRenderer data={preview.docxData} module={preview.docxModule} />
368
- }
369
-
370
- // XLSX
371
- if (preview.xlsxData) {
372
- return <XlsxPreviewRenderer data={preview.xlsxData} />
373
- }
374
-
375
- // Fallback — no preview available for this type
376
- return (
377
- <div className="flex flex-col items-center gap-4 p-12 text-center">
378
- <FileIcon category={preview.category} />
379
- <div className="space-y-1">
380
- <p className="text-sm font-medium text-foreground">No preview available</p>
381
- <p className="text-xs text-muted-foreground">
382
- Preview is not supported for this file type. You can download the file to view it.
383
- </p>
384
- </div>
385
- </div>
386
- )
387
- }
388
-
389
- // ============================================================================
390
- // Category Filter Labels
391
- // ============================================================================
392
-
393
27
  const CATEGORY_LABELS: Record<string, string> = {
394
28
  all: 'All',
395
29
  image: 'Images',
@@ -405,14 +39,10 @@ const CATEGORY_LABELS: Record<string, string> = {
405
39
  other: 'Other',
406
40
  }
407
41
 
408
- // ============================================================================
409
- // Upload Modal
410
- // ============================================================================
411
-
412
42
  interface UploadModalProps {
413
43
  isOpen: boolean
414
44
  onClose: () => void
415
- onUpload: (file: File, detectedMime: string) => Promise<void>
45
+ onUpload: (file: File, detectedMime: string) => Promise<boolean>
416
46
  isUploading: boolean
417
47
  }
418
48
 
@@ -421,6 +51,7 @@ function UploadModal({ isOpen, onClose, onUpload, isUploading }: UploadModalProp
421
51
  const [detectedType, setDetectedType] = useState<string | null>(null)
422
52
  const [error, setError] = useState<string | null>(null)
423
53
  const inputRef = useRef<HTMLInputElement>(null)
54
+ const selectedFileRef = useRef<File | null>(null)
424
55
  const { detectMimeType } = useMimeTypeDetection()
425
56
 
426
57
  const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -428,20 +59,30 @@ function UploadModal({ isOpen, onClose, onUpload, isUploading }: UploadModalProp
428
59
  if (!file) return
429
60
 
430
61
  if (file.size > MAX_FILE_SIZE) {
62
+ selectedFileRef.current = null
63
+ setSelectedFile(null)
64
+ setDetectedType(null)
431
65
  setError(`File too large. Maximum size is ${formatFileSize(MAX_FILE_SIZE)}.`)
66
+ e.currentTarget.value = ''
432
67
  return
433
68
  }
434
69
 
435
70
  setError(null)
71
+ selectedFileRef.current = file
436
72
  setSelectedFile(file)
73
+ setDetectedType(null)
437
74
  const mime = await detectMimeType(file)
438
- setDetectedType(mime)
75
+ if (selectedFileRef.current === file) setDetectedType(mime)
439
76
  }
440
77
 
441
78
  const handleUpload = async () => {
442
79
  if (!selectedFile) return
443
- const mime = detectedType || await detectMimeType(selectedFile)
444
- await onUpload(selectedFile, mime)
80
+ const file = selectedFile
81
+ const mime = detectedType || (await detectMimeType(file))
82
+ if (selectedFileRef.current !== file) return
83
+ const uploaded = await onUpload(file, mime)
84
+ if (!uploaded) return
85
+ selectedFileRef.current = null
445
86
  setSelectedFile(null)
446
87
  setDetectedType(null)
447
88
  setError(null)
@@ -450,6 +91,7 @@ function UploadModal({ isOpen, onClose, onUpload, isUploading }: UploadModalProp
450
91
  }
451
92
 
452
93
  const handleClose = () => {
94
+ selectedFileRef.current = null
453
95
  setSelectedFile(null)
454
96
  setDetectedType(null)
455
97
  setError(null)
@@ -459,7 +101,7 @@ function UploadModal({ isOpen, onClose, onUpload, isUploading }: UploadModalProp
459
101
 
460
102
  return (
461
103
  <Modal open={isOpen} onClose={handleClose} size="sm" data-testid="upload-modal">
462
- <Modal.Header onClose={handleClose}>
104
+ <Modal.Header>
463
105
  <Modal.Title>Upload File</Modal.Title>
464
106
  </Modal.Header>
465
107
  <Modal.Body>
@@ -499,10 +141,6 @@ function UploadModal({ isOpen, onClose, onUpload, isUploading }: UploadModalProp
499
141
  )
500
142
  }
501
143
 
502
- // ============================================================================
503
- // Preview Modal
504
- // ============================================================================
505
-
506
144
  function PreviewModal({ isOpen, onClose, attachment, previewUrl, onDownload }: {
507
145
  isOpen: boolean
508
146
  onClose: () => void
@@ -515,15 +153,15 @@ function PreviewModal({ isOpen, onClose, attachment, previewUrl, onDownload }: {
515
153
 
516
154
  return (
517
155
  <Modal open={isOpen} onClose={onClose} size="lg" data-testid="preview-modal">
518
- <Modal.Header onClose={onClose}>
156
+ <Modal.Header>
519
157
  <Modal.Title>{fileName}</Modal.Title>
520
158
  </Modal.Header>
521
159
  <Modal.Body>
522
160
  <div className="space-y-4">
523
161
  {previewUrl ? (
524
- <FullPreview url={previewUrl} fileName={fileName} mimeType={mimeType} />
162
+ <AttachmentPreview url={previewUrl} fileName={fileName} mimeType={mimeType} />
525
163
  ) : (
526
- <FileIcon category="other" />
164
+ <AttachmentThumbnail url={null} fileName={fileName} category="other" />
527
165
  )}
528
166
  <div className="flex items-center gap-3 text-sm text-muted-foreground">
529
167
  <span>{formatFileSize(fileSize)}</span>
@@ -540,33 +178,23 @@ function PreviewModal({ isOpen, onClose, attachment, previewUrl, onDownload }: {
540
178
  )
541
179
  }
542
180
 
543
- // ============================================================================
544
- // Main Page
545
- // ============================================================================
546
-
547
181
  export default function AttachmentsPage() {
548
182
  const { user } = useUser()
549
183
  const userRole = (user?.role ?? ROLES.VIEWER) as Role
550
184
  const canUpload = userRole === ROLES.MEMBER || userRole === ROLES.ADMIN
551
185
  const isAdmin = userRole === ROLES.ADMIN
552
- const { getFileCategory } = useMimeTypeDetection()
186
+ const { getFileCategory, canPreview } = useMimeTypeDetection()
187
+ const toast = useToast()
553
188
 
554
189
  const [showUploadModal, setShowUploadModal] = useState(false)
555
190
  const [previewAttachment, setPreviewAttachment] = useState<{ data: Attachment; recordId: string } | null>(null)
556
191
  const [deleteTarget, setDeleteTarget] = useState<{ recordId: string; fileKey: string; fileName: string } | null>(null)
557
192
  const [filter, setFilter] = useState<string>('all')
558
193
 
559
- // R2 file storage
560
194
  const { upload, downloadFile, readFile, deleteFile, isUploading } = useR2Files()
561
195
 
562
- // Collection records (metadata).
563
- //
564
- // We sort client-side instead of relying on the server's `orderDir: 'desc'`:
565
- // the SDK's RecordStore.applyChange always appends new records to the end
566
- // (see store.ts — the comment there even admits it only fits asc feeds),
567
- // so on a desc list a freshly-uploaded attachment would land at the bottom
568
- // until the next full fetch. ISO-8601 strings sort lexicographically, so
569
- // localeCompare on createdAt gives the right order without parsing dates.
196
+ // Realtime changes append regardless of requested order. ISO timestamps
197
+ // sort lexicographically, so this keeps new uploads first without parsing.
570
198
  const { records: rawAttachments, status } = useQuery<Attachment>('attachments')
571
199
  const attachments = useMemo(
572
200
  () => [...rawAttachments].sort((a, b) => b.createdAt.localeCompare(a.createdAt)),
@@ -574,82 +202,87 @@ export default function AttachmentsPage() {
574
202
  )
575
203
  const { create, remove } = useMutations<Attachment>('attachments')
576
204
 
577
- // Build preview URLs for previewable files.
578
- //
579
- // Dedup is ref-based, not state-based. The realtime subscription gives
580
- // `attachments` a fresh array reference on every server push (even when
581
- // contents are unchanged), which re-fires the load effect. If we deduped
582
- // via `previewUrls` state, the stale-closure race would let two pushes
583
- // start two parallel fetches for the same key, each calling
584
- // URL.createObjectURL → setPreviewUrls would swap the <img src> to a new
585
- // blob URL on settle → visible flash + leaked object URLs.
205
+ const filteredAttachments = useMemo(() => {
206
+ if (filter === 'all') return attachments
207
+ return attachments.filter(att => getFileCategory(att.data.mimeType, att.data.fileName) === filter)
208
+ }, [attachments, filter, getFileCategory])
209
+
210
+ // Refs deduplicate loads across realtime array refreshes and retain every
211
+ // created object URL for cleanup.
586
212
  const [previewUrls, setPreviewUrls] = useState<Record<string, string>>({})
587
213
  const inflightKeysRef = useRef<Set<string>>(new Set())
588
214
  const objectUrlsRef = useRef<Set<string>>(new Set())
215
+ const isMountedRef = useRef(false)
216
+
217
+ // Free every object URL, including URLs added after the initial render.
218
+ useEffect(() => {
219
+ const objectUrls = objectUrlsRef.current
220
+ const inflightKeys = inflightKeysRef.current
221
+ isMountedRef.current = true
222
+ return () => {
223
+ isMountedRef.current = false
224
+ for (const url of objectUrls) URL.revokeObjectURL(url)
225
+ objectUrls.clear()
226
+ inflightKeys.clear()
227
+ }
228
+ }, [])
589
229
 
590
230
  const loadPreviewUrl = useCallback(async (fileKey: string, mimeType: string) => {
591
231
  if (inflightKeysRef.current.has(fileKey)) return
592
232
  inflightKeysRef.current.add(fileKey)
593
233
  try {
594
234
  const response = await readFile(fileKey)
595
- if (response) {
596
- const rawBlob = await response.blob()
597
- // Re-create blob with correct MIME type (Chrome blocks PDFs in iframes without it)
598
- const blob = new Blob([rawBlob], { type: mimeType || rawBlob.type })
599
- const url = URL.createObjectURL(blob)
600
- objectUrlsRef.current.add(url)
601
- setPreviewUrls(prev => ({ ...prev, [fileKey]: url }))
602
- }
235
+ const rawBlob = await response.blob()
236
+ if (!isMountedRef.current) return
237
+
238
+ // Preserve the detected type for native media and preview parsers.
239
+ const blob = new Blob([rawBlob], { type: mimeType || rawBlob.type })
240
+ const url = URL.createObjectURL(blob)
241
+ objectUrlsRef.current.add(url)
242
+ setPreviewUrls(prev => ({ ...prev, [fileKey]: url }))
603
243
  } catch {
604
244
  // Preview not available — allow a retry on the next attachments push.
605
245
  inflightKeysRef.current.delete(fileKey)
606
246
  }
607
247
  }, [readFile])
608
248
 
609
- // Load preview URLs for visible files.
249
+ // Native thumbnails need their bytes immediately. Heavier document formats
250
+ // are fetched only when selected, rather than eagerly downloading every
251
+ // attachment in the collection.
610
252
  useEffect(() => {
611
- attachments.forEach(att => {
612
- loadPreviewUrl(att.data.fileKey, att.data.mimeType)
253
+ filteredAttachments.forEach(att => {
254
+ const category = getFileCategory(att.data.mimeType, att.data.fileName)
255
+ if (category === 'image' || category === 'video' || category === 'audio') {
256
+ void loadPreviewUrl(att.data.fileKey, att.data.mimeType)
257
+ }
613
258
  })
614
- }, [attachments, loadPreviewUrl])
615
-
616
- // Free all blob URLs we created on unmount.
617
- useEffect(() => {
618
- return () => {
619
- // Read the refs' LATEST contents at unmount so we revoke every URL and
620
- // clear every key accumulated over the component's life. Capturing at
621
- // setup time (empty) would leak URLs; these are collection refs, not DOM
622
- // nodes.
623
- /* eslint-disable react-hooks/exhaustive-deps -- must revoke/clear the latest ref contents at unmount, not the empty setup-time values */
624
- for (const url of objectUrlsRef.current) URL.revokeObjectURL(url)
625
- objectUrlsRef.current.clear()
626
- inflightKeysRef.current.clear()
627
- /* eslint-enable react-hooks/exhaustive-deps */
259
+ if (previewAttachment) {
260
+ const { fileKey, fileName, mimeType } = previewAttachment.data
261
+ const category = getFileCategory(mimeType, fileName)
262
+ if (canPreview(category)) void loadPreviewUrl(fileKey, mimeType)
628
263
  }
629
- }, [])
264
+ }, [canPreview, filteredAttachments, getFileCategory, loadPreviewUrl, previewAttachment])
630
265
 
631
- // Filter attachments
632
- const filteredAttachments = useMemo(() => {
633
- if (filter === 'all') return attachments
634
- return attachments.filter(att => getFileCategory(att.data.mimeType, att.data.fileName) === filter)
635
- }, [attachments, filter, getFileCategory])
636
-
637
- // Handlers
638
266
  const handleUpload = async (file: File, detectedMime: string) => {
639
267
  const result = await upload(file, file.name)
640
- if (result.success && result.key) {
641
- // `ownerId` is intentionally omitted — the schema declares it
642
- // `userBound: true, immutable: true`, so the server fills it in
643
- // from the authenticated caller and ignores any client-supplied
644
- // value. The Attachment type still includes it (stored records
645
- // always carry it), hence the cast.
646
- await create({
647
- fileName: file.name,
648
- fileKey: result.key,
649
- mimeType: detectedMime,
650
- fileSize: file.size,
651
- } as Attachment)
268
+ if (!result.success || !result.key) {
269
+ toast.error(result.error ?? 'Upload failed')
270
+ return false
652
271
  }
272
+
273
+ // `ownerId` is intentionally omitted — the schema declares it
274
+ // `userBound: true, immutable: true`, so the server fills it in
275
+ // from the authenticated caller and ignores any client-supplied
276
+ // value. The Attachment type still includes it (stored records
277
+ // always carry it), hence the cast.
278
+ await create({
279
+ fileName: file.name,
280
+ fileKey: result.key,
281
+ mimeType: detectedMime,
282
+ fileSize: file.size,
283
+ } as Attachment)
284
+ toast.success('File uploaded')
285
+ return true
653
286
  }
654
287
 
655
288
  const handleDeleteRequest = (recordId: string, fileKey: string, fileName: string) => {
@@ -658,7 +291,11 @@ export default function AttachmentsPage() {
658
291
 
659
292
  const handleDeleteConfirm = async () => {
660
293
  if (!deleteTarget) return
661
- await deleteFile(deleteTarget.fileKey)
294
+ const result = await deleteFile(deleteTarget.fileKey)
295
+ if (!result.success) {
296
+ toast.error(result.error ?? 'Delete failed')
297
+ return
298
+ }
662
299
  await remove(deleteTarget.recordId)
663
300
  // Free the blob URL we created for this file and clear dedup state
664
301
  // so a future re-upload of the same key (rare but possible) can fetch.
@@ -675,15 +312,16 @@ export default function AttachmentsPage() {
675
312
  return next
676
313
  })
677
314
  setDeleteTarget(null)
315
+ toast.success('File deleted')
678
316
  }
679
317
 
680
318
  const handleDownload = async (fileKey: string, fileName: string) => {
681
- await downloadFile(fileKey, fileName)
319
+ const result = await downloadFile(fileKey, fileName)
320
+ if (!result.success) toast.error(result.error ?? 'Download failed')
682
321
  }
683
322
 
684
323
  const isLoading = status === 'loading'
685
324
 
686
- // Category counts for filter badges
687
325
  const categoryCounts = useMemo(() => {
688
326
  const counts: Record<string, number> = { all: attachments.length }
689
327
  attachments.forEach(att => {
@@ -781,7 +419,7 @@ export default function AttachmentsPage() {
781
419
  >
782
420
  {/* Thumbnail */}
783
421
  <div className="cursor-pointer" onClick={() => setPreviewAttachment(att)}>
784
- <ThumbnailPreview url={url} fileName={att.data.fileName} mimeType={att.data.mimeType} category={category} />
422
+ <AttachmentThumbnail url={url} fileName={att.data.fileName} category={category} />
785
423
  </div>
786
424
 
787
425
  {/* Info */}
@@ -849,7 +487,7 @@ export default function AttachmentsPage() {
849
487
 
850
488
  {/* Delete Confirmation Modal */}
851
489
  <Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} size="sm" data-testid="delete-confirm-modal">
852
- <Modal.Header onClose={() => setDeleteTarget(null)}>
490
+ <Modal.Header>
853
491
  <Modal.Title>Delete File</Modal.Title>
854
492
  </Modal.Header>
855
493
  <Modal.Body>