deepspace 0.3.9 → 0.3.11

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 (120) hide show
  1. package/dist/cli.js +96 -18
  2. package/dist/cli.js.map +1 -1
  3. package/dist/index.d.ts +160 -1
  4. package/dist/index.js +283 -126
  5. package/dist/index.js.map +1 -1
  6. package/dist/server.d.ts +172 -2
  7. package/dist/server.js +581 -1
  8. package/dist/server.js.map +1 -1
  9. package/dist/worker.d.ts +252 -2
  10. package/dist/worker.js +588 -1
  11. package/dist/worker.js.map +1 -1
  12. package/features/admin-page/feature.json +29 -0
  13. package/features/admin-page/src/AdminPage.tsx +292 -0
  14. package/features/admin-page/src/admin-schema.ts +20 -0
  15. package/features/ai-chat/feature.json +41 -0
  16. package/features/ai-chat/src/AiChatPage.tsx +692 -0
  17. package/features/ai-chat/src/ChatPanel.tsx +1098 -0
  18. package/features/ai-chat/src/ai-chat-schema.ts +11 -0
  19. package/features/canvas/feature.json +45 -0
  20. package/features/canvas/src/CanvasEditorPage.tsx +87 -0
  21. package/features/canvas/src/CanvasListPage.tsx +202 -0
  22. package/features/canvas/src/canvas-schema.ts +33 -0
  23. package/features/canvas/src/components/CanvasToolbar.tsx +170 -0
  24. package/features/canvas/src/components/CanvasView.tsx +369 -0
  25. package/features/canvas/src/components/ShapeRenderer.tsx +114 -0
  26. package/features/cron/feature.json +33 -0
  27. package/features/cron/src/CronLogPage.tsx +100 -0
  28. package/features/cron/src/cron.ts +25 -0
  29. package/features/docs/feature.json +96 -0
  30. package/features/docs/src/DocsEditorPage.tsx +1012 -0
  31. package/features/docs/src/DocsListPage.tsx +951 -0
  32. package/features/docs/src/DocsOutlinePanel.tsx +68 -0
  33. package/features/docs/src/DocsPresence.tsx +138 -0
  34. package/features/docs/src/InviteDialog.tsx +336 -0
  35. package/features/docs/src/LibrarySidebar.tsx +464 -0
  36. package/features/docs/src/docs-favorites.ts +18 -0
  37. package/features/docs/src/docs-library-types.ts +15 -0
  38. package/features/docs/src/docs-schema.ts +80 -0
  39. package/features/docs/src/docs-ui.css +309 -0
  40. package/features/docs/src/editor/DocEditorSurface.tsx +257 -0
  41. package/features/docs/src/editor/DocsTiptapToolbar.tsx +560 -0
  42. package/features/docs/src/editor/useDocEditor.ts +206 -0
  43. package/features/docs/src/use-yjs-room-with-awareness.ts +285 -0
  44. package/features/file-attachments/feature.json +42 -0
  45. package/features/file-attachments/src/AttachmentsPage.tsx +864 -0
  46. package/features/file-attachments/src/attachments-constants.ts +9 -0
  47. package/features/file-attachments/src/attachments-schema.ts +45 -0
  48. package/features/file-attachments/src/useFilePreview.ts +471 -0
  49. package/features/file-attachments/src/useMimeTypeDetection.ts +138 -0
  50. package/features/file-manager/feature.json +24 -0
  51. package/features/file-manager/src/FileManagerPage.tsx +330 -0
  52. package/features/integration-test/feature.json +18 -0
  53. package/features/integration-test/src/IntegrationTestPage.tsx +423 -0
  54. package/features/integration-test/src/integration.spec.ts +42 -0
  55. package/features/items/feature.json +33 -0
  56. package/features/items/src/ItemsPage.tsx +325 -0
  57. package/features/items/src/items-constants.ts +10 -0
  58. package/features/items/src/items-schema.ts +27 -0
  59. package/features/kanban/feature.json +18 -0
  60. package/features/kanban/src/Kanban.tsx +283 -0
  61. package/features/landing/feature.json +62 -0
  62. package/features/landing/src/LandingPage.tsx +627 -0
  63. package/features/landing/src/primitives.tsx +389 -0
  64. package/features/landing/src/sections/FAQSection.tsx +110 -0
  65. package/features/landing/src/sections/FeaturesGridSection.tsx +311 -0
  66. package/features/landing/src/sections/HowItWorksSection.tsx +62 -0
  67. package/features/landing/src/sections/LogoCloudSection.tsx +58 -0
  68. package/features/landing/src/sections/ShowcaseSection.tsx +63 -0
  69. package/features/landing/src/sections/StatsSection.tsx +31 -0
  70. package/features/landing/src/sections/TeamSection.tsx +86 -0
  71. package/features/landing/src/sections/TestimonialsSection.tsx +68 -0
  72. package/features/landing/src/sections/VideoSection.tsx +71 -0
  73. package/features/leaderboard/feature.json +33 -0
  74. package/features/leaderboard/src/LeaderboardPage.tsx +372 -0
  75. package/features/leaderboard/src/leaderboard-constants.ts +21 -0
  76. package/features/leaderboard/src/leaderboard-schema.ts +36 -0
  77. package/features/messaging/feature.json +111 -0
  78. package/features/messaging/src/ChatMultiPage.tsx +300 -0
  79. package/features/messaging/src/ChatPage.tsx +75 -0
  80. package/features/messaging/src/components/chat/ChatHeader.tsx +76 -0
  81. package/features/messaging/src/components/chat/MessageActionSheet.tsx +168 -0
  82. package/features/messaging/src/components/chat/MessageInput.tsx +65 -0
  83. package/features/messaging/src/components/chat/MessageItem.tsx +352 -0
  84. package/features/messaging/src/components/chat/MessageList.tsx +248 -0
  85. package/features/messaging/src/components/chat/ReactionPicker.tsx +2 -0
  86. package/features/messaging/src/components/chat/ThreadPanel.tsx +107 -0
  87. package/features/messaging/src/components/chat/UserProfilePopover.tsx +158 -0
  88. package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +188 -0
  89. package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +163 -0
  90. package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +147 -0
  91. package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +344 -0
  92. package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +460 -0
  93. package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +190 -0
  94. package/features/messaging/src/components/chat-multi/InvitationList.tsx +149 -0
  95. package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +159 -0
  96. package/features/messaging/src/hooks/useChatChannel.ts +64 -0
  97. package/features/messaging/src/hooks/useLongPress.ts +74 -0
  98. package/features/messaging/src/hooks/useMultiChannel.ts +142 -0
  99. package/features/messaging/src/messaging-schema.ts +25 -0
  100. package/features/presence-test/feature.json +21 -0
  101. package/features/presence-test/src/PresenceTestPage.tsx +120 -0
  102. package/features/sidebar/feature.json +19 -0
  103. package/features/sidebar/src/AppSidebar.tsx +558 -0
  104. package/features/tasks/feature.json +33 -0
  105. package/features/tasks/src/TasksPage.tsx +598 -0
  106. package/features/tasks/src/tasks-constants.ts +43 -0
  107. package/features/tasks/src/tasks-schema.ts +56 -0
  108. package/features/testing/feature.json +49 -0
  109. package/features/testing/src/TestPage.tsx +145 -0
  110. package/features/testing/src/cpu-burn-action.ts +51 -0
  111. package/features/testing/src/subrequest-burn-action.ts +53 -0
  112. package/features/testing/src/test-schema.ts +23 -0
  113. package/features/topbar/feature.json +18 -0
  114. package/features/topbar/src/Topbar.tsx +353 -0
  115. package/features/tree/feature.json +26 -0
  116. package/features/tree/src/TreeSidebar.tsx +607 -0
  117. package/features/tree/src/tree-constants.ts +68 -0
  118. package/features/tree/src/tree-hooks.ts +258 -0
  119. package/package.json +4 -2
  120. package/scripts/add-feature.cjs +654 -0
@@ -0,0 +1,864 @@
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'
14
+ import {
15
+ useUser,
16
+ useQuery,
17
+ useMutations,
18
+ useR2Files,
19
+ Button,
20
+ Modal,
21
+ EmptyState,
22
+ Badge,
23
+ ROLES,
24
+ formatFileSize,
25
+ type Role,
26
+ } from 'deepspace'
27
+ import { useMimeTypeDetection, type FileCategory } from '../hooks/useMimeTypeDetection'
28
+ import { useFilePreview } from '../hooks/useFilePreview'
29
+ import { MAX_FILE_SIZE } from '../constants/attachments-constants'
30
+
31
+ // ============================================================================
32
+ // Types
33
+ // ============================================================================
34
+
35
+ interface Attachment {
36
+ fileName: string
37
+ fileKey: string
38
+ mimeType: string
39
+ fileSize: number
40
+ ownerId: string
41
+ }
42
+
43
+ // ============================================================================
44
+ // File Icon (fallback for non-previewable types)
45
+ // ============================================================================
46
+
47
+ const ICON_PATHS: Record<FileCategory, string> = {
48
+ 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',
49
+ 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',
50
+ 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',
51
+ 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',
52
+ 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',
53
+ 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',
54
+ 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',
55
+ archive: 'M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4',
56
+ code: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4',
57
+ 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',
58
+ 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',
59
+ }
60
+
61
+ function FileIcon({ category }: { category: FileCategory }) {
62
+ return (
63
+ <div className="w-full h-48 bg-muted/20 rounded-lg flex items-center justify-center">
64
+ <svg className="w-12 h-12 text-muted-foreground/50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
65
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={ICON_PATHS[category]} />
66
+ </svg>
67
+ </div>
68
+ )
69
+ }
70
+
71
+ // ============================================================================
72
+ // Thumbnail Preview (card grid view)
73
+ // ============================================================================
74
+
75
+ function ThumbnailPreview({ url, fileName, mimeType, category }: {
76
+ url: string | null
77
+ fileName: string
78
+ mimeType: string
79
+ category: FileCategory
80
+ }) {
81
+ if (!url) return <FileIcon category={category} />
82
+
83
+ switch (category) {
84
+ case 'image':
85
+ return <img src={url} alt={fileName} className="w-full h-48 object-cover rounded-t-xl" loading="lazy" />
86
+ case 'video':
87
+ return <video src={url} className="w-full h-48 rounded-t-xl bg-black" preload="metadata" muted />
88
+ case 'audio':
89
+ return (
90
+ <div className="w-full h-48 bg-muted/20 rounded-t-xl flex flex-col items-center justify-center gap-2 px-4">
91
+ <svg className="w-8 h-8 text-muted-foreground/50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
92
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={ICON_PATHS.audio} />
93
+ </svg>
94
+ <audio src={url} controls className="w-full" preload="metadata" />
95
+ </div>
96
+ )
97
+ default:
98
+ return <FileIcon category={category} />
99
+ }
100
+ }
101
+
102
+ // ============================================================================
103
+ // DOCX Preview Renderer (needs its own component for proper ref management)
104
+ // ============================================================================
105
+
106
+ function DocxPreviewRenderer({ data, module: docxModule }: {
107
+ data: ArrayBuffer
108
+ module: typeof import('docx-preview')
109
+ }) {
110
+ const styleRef = useRef<HTMLDivElement>(null)
111
+ const [loading, setLoading] = useState(true)
112
+
113
+ // Reset loading state when the document changes. This is a side effect
114
+ // (setState), so it belongs in useEffect — `useMemo` is for computing
115
+ // values, and React docs warn its body may not run under Concurrent Mode.
116
+ useEffect(() => {
117
+ setLoading(true)
118
+ }, [data])
119
+
120
+ const onContainerReady: RefCallback<HTMLDivElement> = useCallback((node) => {
121
+ if (!node) return
122
+ node.innerHTML = ''
123
+ if (styleRef.current) styleRef.current.innerHTML = ''
124
+
125
+ docxModule.renderAsync(data, node, styleRef.current ?? undefined, {
126
+ className: 'docx-preview-page',
127
+ inWrapper: true,
128
+ ignoreWidth: true,
129
+ ignoreHeight: false,
130
+ ignoreFonts: false,
131
+ breakPages: true,
132
+ renderHeaders: true,
133
+ renderFooters: true,
134
+ renderFootnotes: true,
135
+ renderEndnotes: true,
136
+ useBase64URL: true,
137
+ }).then(() => setLoading(false))
138
+ .catch(() => setLoading(false))
139
+ }, [data, docxModule])
140
+
141
+ return (
142
+ <div className="w-full min-h-[400px] rounded-lg overflow-auto" style={{ background: '#e8e8e8' }}>
143
+ {loading && (
144
+ <div className="p-8 text-center text-muted-foreground">Loading document...</div>
145
+ )}
146
+ <div ref={styleRef} />
147
+ <div ref={onContainerReady} style={{ minHeight: loading ? 0 : undefined }} />
148
+ <style>{`
149
+ .docx-preview-page-wrapper {
150
+ background: #e8e8e8;
151
+ padding: 16px 0;
152
+ display: flex;
153
+ flex-direction: column;
154
+ align-items: center;
155
+ gap: 16px;
156
+ }
157
+ .docx-preview-page-wrapper > section.docx-preview-page {
158
+ background: #ffffff !important;
159
+ color: #1a1a1a !important;
160
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
161
+ border-radius: 4px;
162
+ overflow: hidden;
163
+ width: 100% !important;
164
+ padding: 24px !important;
165
+ box-sizing: border-box !important;
166
+ }
167
+ .docx-preview-page-wrapper > section.docx-preview-page * {
168
+ color: inherit;
169
+ }
170
+ `}</style>
171
+ </div>
172
+ )
173
+ }
174
+
175
+ // ============================================================================
176
+ // XLSX Preview Renderer (needs its own component for useState)
177
+ // ============================================================================
178
+
179
+ function XlsxPreviewRenderer({ data }: { data: import('../hooks/useFilePreview').XlsxData }) {
180
+ const [activeSheet, setActiveSheet] = useState(0)
181
+ const { sheets } = data
182
+ const sheet = sheets[activeSheet]
183
+
184
+ return (
185
+ <div className="w-full">
186
+ {sheets.length > 1 && (
187
+ <div className="flex gap-1 px-2 py-1 bg-muted/30 border-b border-border overflow-x-auto">
188
+ {sheets.map((s, i) => (
189
+ <button
190
+ key={i}
191
+ onClick={() => setActiveSheet(i)}
192
+ className={`px-3 py-1 rounded text-xs font-medium transition-colors whitespace-nowrap ${
193
+ i === activeSheet ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted'
194
+ }`}
195
+ >
196
+ {s.name}
197
+ </button>
198
+ ))}
199
+ </div>
200
+ )}
201
+ {sheet && (
202
+ <div className="overflow-auto rounded-lg border border-border">
203
+ <table className="w-full border-collapse text-sm font-mono">
204
+ <thead>
205
+ <tr>
206
+ <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>
207
+ {sheet.headers.map((h, i) => (
208
+ <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>
209
+ ))}
210
+ </tr>
211
+ </thead>
212
+ <tbody>
213
+ {sheet.rows.map((row, ri) => (
214
+ <tr key={ri} className={ri % 2 === 0 ? '' : 'bg-muted/20'}>
215
+ <td className="px-3 py-1.5 text-right text-xs text-muted-foreground border-b border-border/50">{ri + 1}</td>
216
+ {sheet.headers.map((_, ci) => (
217
+ <td key={ci} className="px-3 py-1.5 text-foreground border-b border-border/50 whitespace-nowrap max-w-[300px] truncate">
218
+ {row[ci] ?? ''}
219
+ </td>
220
+ ))}
221
+ </tr>
222
+ ))}
223
+ </tbody>
224
+ </table>
225
+ {sheet.truncated && (
226
+ <div className="px-4 py-2 text-center text-xs text-muted-foreground bg-muted/30 border-t border-border">
227
+ Showing first 500 rows. Download the file to see all data.
228
+ </div>
229
+ )}
230
+ </div>
231
+ )}
232
+ </div>
233
+ )
234
+ }
235
+
236
+ // ============================================================================
237
+ // Full Preview Modal Content (uses useFilePreview)
238
+ // ============================================================================
239
+
240
+ function FullPreview({ url, fileName, mimeType }: { url: string; fileName: string; mimeType: string }) {
241
+ const preview = useFilePreview({ url, fileName, mimeType })
242
+
243
+ if (preview.isLoading) {
244
+ return (
245
+ <div className="flex items-center justify-center py-16">
246
+ <div className="w-8 h-8 border-2 border-primary/20 border-t-primary rounded-full animate-spin" />
247
+ </div>
248
+ )
249
+ }
250
+
251
+ if (preview.error) {
252
+ return (
253
+ <div className="p-8 text-center text-muted-foreground">
254
+ Failed to load preview: {preview.error}
255
+ </div>
256
+ )
257
+ }
258
+
259
+ // Image
260
+ if (preview.category === 'image') {
261
+ return <img src={url} alt={fileName} className="w-full rounded-lg" />
262
+ }
263
+
264
+ // Video
265
+ if (preview.category === 'video') {
266
+ return <video src={url} controls className="w-full rounded-lg bg-black" />
267
+ }
268
+
269
+ // Audio
270
+ if (preview.category === 'audio') {
271
+ return (
272
+ <div className="flex flex-col items-center gap-4 p-8">
273
+ <FileIcon category="audio" />
274
+ <audio src={url} controls className="w-full" />
275
+ </div>
276
+ )
277
+ }
278
+
279
+ // PDF — render via PDF.js canvases (avoids iframe sandbox blocking)
280
+ if (preview.category === 'pdf' && preview.pdfPages) {
281
+ return (
282
+ <div className="flex flex-col gap-4 overflow-auto" style={{ maxHeight: '80vh' }}>
283
+ {preview.pdfPages.map((canvas, i) => {
284
+ const aspectRatio = canvas.width / canvas.height
285
+ return (
286
+ <div
287
+ key={i}
288
+ ref={(el) => {
289
+ if (el && !el.hasChildNodes()) {
290
+ canvas.style.width = '100%'
291
+ canvas.style.height = 'auto'
292
+ canvas.style.display = 'block'
293
+ el.appendChild(canvas)
294
+ }
295
+ }}
296
+ className="w-full rounded-lg overflow-hidden border border-border bg-white flex-shrink-0"
297
+ style={{ aspectRatio: `${aspectRatio}` }}
298
+ />
299
+ )
300
+ })}
301
+ </div>
302
+ )
303
+ }
304
+
305
+ // Code/Text with CodeMirror
306
+ if ((preview.category === 'code' || preview.category === 'text') && preview.textContent !== null) {
307
+ if (preview.codeEditorKit) {
308
+ const { Component, langExtensions, darkTheme } = preview.codeEditorKit
309
+ return (
310
+ <div className="w-full overflow-hidden rounded-lg border border-border">
311
+ <Component
312
+ value={preview.textContent}
313
+ extensions={[...langExtensions, darkTheme]}
314
+ readOnly
315
+ editable={false}
316
+ theme="dark"
317
+ height="400px"
318
+ basicSetup={{ lineNumbers: true, foldGutter: true, highlightActiveLine: false }}
319
+ style={{ fontSize: '13px' }}
320
+ />
321
+ </div>
322
+ )
323
+ }
324
+ // Fallback: plain text
325
+ return (
326
+ <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">
327
+ {preview.textContent}
328
+ </pre>
329
+ )
330
+ }
331
+
332
+ // CSV
333
+ if (preview.csvData) {
334
+ const { headers, rows, truncated } = preview.csvData
335
+ return (
336
+ <div className="w-full overflow-auto rounded-lg border border-border">
337
+ <table className="w-full border-collapse text-sm font-mono">
338
+ <thead>
339
+ <tr>
340
+ <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>
341
+ {headers.map((h, i) => (
342
+ <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>
343
+ ))}
344
+ </tr>
345
+ </thead>
346
+ <tbody>
347
+ {rows.map((row, ri) => (
348
+ <tr key={ri} className={ri % 2 === 0 ? '' : 'bg-muted/20'}>
349
+ <td className="px-3 py-1.5 text-right text-xs text-muted-foreground border-b border-border/50">{ri + 1}</td>
350
+ {headers.map((_, ci) => (
351
+ <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] ?? ''}>
352
+ {row[ci] ?? ''}
353
+ </td>
354
+ ))}
355
+ </tr>
356
+ ))}
357
+ </tbody>
358
+ </table>
359
+ {truncated && (
360
+ <div className="px-4 py-2 text-center text-xs text-muted-foreground bg-muted/30 border-t border-border">
361
+ Showing first 500 rows. Download the file to see all data.
362
+ </div>
363
+ )}
364
+ </div>
365
+ )
366
+ }
367
+
368
+ // DOCX — use DocxPreviewRenderer subcomponent to manage refs properly
369
+ if (preview.category === 'document' && preview.docxData && preview.docxModule) {
370
+ return <DocxPreviewRenderer data={preview.docxData} module={preview.docxModule} />
371
+ }
372
+
373
+ // XLSX
374
+ if (preview.xlsxData) {
375
+ return <XlsxPreviewRenderer data={preview.xlsxData} />
376
+ }
377
+
378
+ // Fallback — no preview available for this type
379
+ return (
380
+ <div className="flex flex-col items-center gap-4 p-12 text-center">
381
+ <FileIcon category={preview.category} />
382
+ <div className="space-y-1">
383
+ <p className="text-sm font-medium text-foreground">No preview available</p>
384
+ <p className="text-xs text-muted-foreground">
385
+ Preview is not supported for this file type. You can download the file to view it.
386
+ </p>
387
+ </div>
388
+ </div>
389
+ )
390
+ }
391
+
392
+ // ============================================================================
393
+ // Category Filter Labels
394
+ // ============================================================================
395
+
396
+ const CATEGORY_LABELS: Record<string, string> = {
397
+ all: 'All',
398
+ image: 'Images',
399
+ video: 'Videos',
400
+ audio: 'Audio',
401
+ pdf: 'PDFs',
402
+ code: 'Code',
403
+ text: 'Text',
404
+ spreadsheet: 'Spreadsheets',
405
+ document: 'Documents',
406
+ presentation: 'Presentations',
407
+ archive: 'Archives',
408
+ other: 'Other',
409
+ }
410
+
411
+ // ============================================================================
412
+ // Upload Modal
413
+ // ============================================================================
414
+
415
+ interface UploadModalProps {
416
+ isOpen: boolean
417
+ onClose: () => void
418
+ onUpload: (file: File, detectedMime: string) => Promise<void>
419
+ isUploading: boolean
420
+ }
421
+
422
+ function UploadModal({ isOpen, onClose, onUpload, isUploading }: UploadModalProps) {
423
+ const [selectedFile, setSelectedFile] = useState<File | null>(null)
424
+ const [detectedType, setDetectedType] = useState<string | null>(null)
425
+ const [error, setError] = useState<string | null>(null)
426
+ const inputRef = useRef<HTMLInputElement>(null)
427
+ const { detectMimeType } = useMimeTypeDetection()
428
+
429
+ const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
430
+ const file = e.target.files?.[0]
431
+ if (!file) return
432
+
433
+ if (file.size > MAX_FILE_SIZE) {
434
+ setError(`File too large. Maximum size is ${formatFileSize(MAX_FILE_SIZE)}.`)
435
+ return
436
+ }
437
+
438
+ setError(null)
439
+ setSelectedFile(file)
440
+ const mime = await detectMimeType(file)
441
+ setDetectedType(mime)
442
+ }
443
+
444
+ const handleUpload = async () => {
445
+ if (!selectedFile) return
446
+ const mime = detectedType || await detectMimeType(selectedFile)
447
+ await onUpload(selectedFile, mime)
448
+ setSelectedFile(null)
449
+ setDetectedType(null)
450
+ setError(null)
451
+ if (inputRef.current) inputRef.current.value = ''
452
+ onClose()
453
+ }
454
+
455
+ const handleClose = () => {
456
+ setSelectedFile(null)
457
+ setDetectedType(null)
458
+ setError(null)
459
+ if (inputRef.current) inputRef.current.value = ''
460
+ onClose()
461
+ }
462
+
463
+ return (
464
+ <Modal open={isOpen} onClose={handleClose} size="sm" data-testid="upload-modal">
465
+ <Modal.Header onClose={handleClose}>
466
+ <Modal.Title>Upload File</Modal.Title>
467
+ </Modal.Header>
468
+ <Modal.Body>
469
+ <div className="space-y-4">
470
+ <div
471
+ onClick={() => inputRef.current?.click()}
472
+ className="border-2 border-dashed border-border rounded-lg p-8 text-center cursor-pointer hover:border-primary/50 hover:bg-muted/20 transition-colors"
473
+ >
474
+ <input ref={inputRef} type="file" className="hidden" onChange={handleFileSelect} />
475
+ <svg className="w-10 h-10 mx-auto text-muted-foreground mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
476
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
477
+ </svg>
478
+ {selectedFile ? (
479
+ <div>
480
+ <p className="text-sm font-medium text-foreground">{selectedFile.name}</p>
481
+ <p className="text-xs text-muted-foreground mt-1">
482
+ {formatFileSize(selectedFile.size)} &middot; {detectedType || selectedFile.type || 'detecting...'}
483
+ </p>
484
+ </div>
485
+ ) : (
486
+ <div>
487
+ <p className="text-sm text-muted-foreground">Click to select a file</p>
488
+ <p className="text-xs text-muted-foreground mt-1">Max {formatFileSize(MAX_FILE_SIZE)}</p>
489
+ </div>
490
+ )}
491
+ </div>
492
+ {error && <p className="text-sm text-destructive">{error}</p>}
493
+ </div>
494
+ </Modal.Body>
495
+ <Modal.Footer>
496
+ <Button variant="secondary" onClick={handleClose}>Cancel</Button>
497
+ <Button onClick={handleUpload} disabled={!selectedFile || isUploading}>
498
+ {isUploading ? 'Uploading...' : 'Upload'}
499
+ </Button>
500
+ </Modal.Footer>
501
+ </Modal>
502
+ )
503
+ }
504
+
505
+ // ============================================================================
506
+ // Preview Modal
507
+ // ============================================================================
508
+
509
+ function PreviewModal({ isOpen, onClose, attachment, previewUrl, onDownload }: {
510
+ isOpen: boolean
511
+ onClose: () => void
512
+ attachment: { data: Attachment; recordId: string } | null
513
+ previewUrl: string | null
514
+ onDownload: () => void
515
+ }) {
516
+ if (!attachment) return null
517
+ const { fileName, mimeType, fileSize } = attachment.data
518
+
519
+ return (
520
+ <Modal open={isOpen} onClose={onClose} size="lg" data-testid="preview-modal">
521
+ <Modal.Header onClose={onClose}>
522
+ <Modal.Title>{fileName}</Modal.Title>
523
+ </Modal.Header>
524
+ <Modal.Body>
525
+ <div className="space-y-4">
526
+ {previewUrl ? (
527
+ <FullPreview url={previewUrl} fileName={fileName} mimeType={mimeType} />
528
+ ) : (
529
+ <FileIcon category="other" />
530
+ )}
531
+ <div className="flex items-center gap-3 text-sm text-muted-foreground">
532
+ <span>{formatFileSize(fileSize)}</span>
533
+ <span>&middot;</span>
534
+ <span>{mimeType}</span>
535
+ </div>
536
+ </div>
537
+ </Modal.Body>
538
+ <Modal.Footer>
539
+ <Button variant="secondary" onClick={onClose}>Close</Button>
540
+ <Button onClick={onDownload}>Download</Button>
541
+ </Modal.Footer>
542
+ </Modal>
543
+ )
544
+ }
545
+
546
+ // ============================================================================
547
+ // Main Page
548
+ // ============================================================================
549
+
550
+ export default function AttachmentsPage() {
551
+ const { user } = useUser()
552
+ const userRole = (user?.role ?? ROLES.VIEWER) as Role
553
+ const canUpload = userRole === ROLES.MEMBER || userRole === ROLES.ADMIN
554
+ const isAdmin = userRole === ROLES.ADMIN
555
+ const { getFileCategory } = useMimeTypeDetection()
556
+
557
+ const [showUploadModal, setShowUploadModal] = useState(false)
558
+ const [previewAttachment, setPreviewAttachment] = useState<{ data: Attachment; recordId: string } | null>(null)
559
+ const [deleteTarget, setDeleteTarget] = useState<{ recordId: string; fileKey: string; fileName: string } | null>(null)
560
+ const [filter, setFilter] = useState<string>('all')
561
+
562
+ // R2 file storage
563
+ const { upload, downloadFile, readFile, deleteFile, isUploading } = useR2Files()
564
+
565
+ // Collection records (metadata).
566
+ //
567
+ // We sort client-side instead of relying on the server's `orderDir: 'desc'`:
568
+ // the SDK's RecordStore.applyChange always appends new records to the end
569
+ // (see store.ts — the comment there even admits it only fits asc feeds),
570
+ // so on a desc list a freshly-uploaded attachment would land at the bottom
571
+ // until the next full fetch. ISO-8601 strings sort lexicographically, so
572
+ // localeCompare on createdAt gives the right order without parsing dates.
573
+ const { records: rawAttachments, status } = useQuery<Attachment>('attachments')
574
+ const attachments = useMemo(
575
+ () => [...rawAttachments].sort((a, b) => b.createdAt.localeCompare(a.createdAt)),
576
+ [rawAttachments],
577
+ )
578
+ const { create, remove } = useMutations<Attachment>('attachments')
579
+
580
+ // Build preview URLs for previewable files.
581
+ //
582
+ // Dedup is ref-based, not state-based. The realtime subscription gives
583
+ // `attachments` a fresh array reference on every server push (even when
584
+ // contents are unchanged), which re-fires the load effect. If we deduped
585
+ // via `previewUrls` state, the stale-closure race would let two pushes
586
+ // start two parallel fetches for the same key, each calling
587
+ // URL.createObjectURL → setPreviewUrls would swap the <img src> to a new
588
+ // blob URL on settle → visible flash + leaked object URLs.
589
+ const [previewUrls, setPreviewUrls] = useState<Record<string, string>>({})
590
+ const inflightKeysRef = useRef<Set<string>>(new Set())
591
+ const objectUrlsRef = useRef<Set<string>>(new Set())
592
+
593
+ const loadPreviewUrl = useCallback(async (fileKey: string, mimeType: string) => {
594
+ if (inflightKeysRef.current.has(fileKey)) return
595
+ inflightKeysRef.current.add(fileKey)
596
+ try {
597
+ const response = await readFile(fileKey)
598
+ if (response) {
599
+ const rawBlob = await response.blob()
600
+ // Re-create blob with correct MIME type (Chrome blocks PDFs in iframes without it)
601
+ const blob = new Blob([rawBlob], { type: mimeType || rawBlob.type })
602
+ const url = URL.createObjectURL(blob)
603
+ objectUrlsRef.current.add(url)
604
+ setPreviewUrls(prev => ({ ...prev, [fileKey]: url }))
605
+ }
606
+ } catch {
607
+ // Preview not available — allow a retry on the next attachments push.
608
+ inflightKeysRef.current.delete(fileKey)
609
+ }
610
+ }, [readFile])
611
+
612
+ // Load preview URLs for visible files.
613
+ useEffect(() => {
614
+ attachments.forEach(att => {
615
+ loadPreviewUrl(att.data.fileKey, att.data.mimeType)
616
+ })
617
+ }, [attachments, loadPreviewUrl])
618
+
619
+ // Free all blob URLs we created on unmount.
620
+ useEffect(() => {
621
+ return () => {
622
+ for (const url of objectUrlsRef.current) URL.revokeObjectURL(url)
623
+ objectUrlsRef.current.clear()
624
+ inflightKeysRef.current.clear()
625
+ }
626
+ }, [])
627
+
628
+ // Filter attachments
629
+ const filteredAttachments = useMemo(() => {
630
+ if (filter === 'all') return attachments
631
+ return attachments.filter(att => getFileCategory(att.data.mimeType, att.data.fileName) === filter)
632
+ }, [attachments, filter, getFileCategory])
633
+
634
+ // Handlers
635
+ const handleUpload = async (file: File, detectedMime: string) => {
636
+ const result = await upload(file, file.name)
637
+ if (result.success && result.key) {
638
+ // `ownerId` is intentionally omitted — the schema declares it
639
+ // `userBound: true, immutable: true`, so the server fills it in
640
+ // from the authenticated caller and ignores any client-supplied
641
+ // value. The Attachment type still includes it (stored records
642
+ // always carry it), hence the cast.
643
+ await create({
644
+ fileName: file.name,
645
+ fileKey: result.key,
646
+ mimeType: detectedMime,
647
+ fileSize: file.size,
648
+ } as Attachment)
649
+ }
650
+ }
651
+
652
+ const handleDeleteRequest = (recordId: string, fileKey: string, fileName: string) => {
653
+ setDeleteTarget({ recordId, fileKey, fileName })
654
+ }
655
+
656
+ const handleDeleteConfirm = async () => {
657
+ if (!deleteTarget) return
658
+ await deleteFile(deleteTarget.fileKey)
659
+ await remove(deleteTarget.recordId)
660
+ // Free the blob URL we created for this file and clear dedup state
661
+ // so a future re-upload of the same key (rare but possible) can fetch.
662
+ const url = previewUrls[deleteTarget.fileKey]
663
+ if (url) {
664
+ URL.revokeObjectURL(url)
665
+ objectUrlsRef.current.delete(url)
666
+ }
667
+ inflightKeysRef.current.delete(deleteTarget.fileKey)
668
+ setPreviewUrls(prev => {
669
+ if (!(deleteTarget.fileKey in prev)) return prev
670
+ const next = { ...prev }
671
+ delete next[deleteTarget.fileKey]
672
+ return next
673
+ })
674
+ setDeleteTarget(null)
675
+ }
676
+
677
+ const handleDownload = async (fileKey: string, fileName: string) => {
678
+ await downloadFile(fileKey, fileName)
679
+ }
680
+
681
+ const isLoading = status === 'loading'
682
+
683
+ // Category counts for filter badges
684
+ const categoryCounts = useMemo(() => {
685
+ const counts: Record<string, number> = { all: attachments.length }
686
+ attachments.forEach(att => {
687
+ const cat = getFileCategory(att.data.mimeType, att.data.fileName)
688
+ counts[cat] = (counts[cat] ?? 0) + 1
689
+ })
690
+ return counts
691
+ }, [attachments, getFileCategory])
692
+
693
+ return (
694
+ <div className="h-full bg-background overflow-y-auto">
695
+ {/* Header */}
696
+ <div className="bg-card/60 backdrop-blur-md border-b border-border sticky top-0 z-10">
697
+ <div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
698
+ <div className="flex items-center justify-between">
699
+ <div>
700
+ <h1 className="text-2xl font-bold text-foreground">Attachments</h1>
701
+ <p className="text-muted-foreground mt-1">
702
+ {attachments.length} file{attachments.length !== 1 ? 's' : ''} uploaded
703
+ </p>
704
+ </div>
705
+ {canUpload && (
706
+ <Button onClick={() => setShowUploadModal(true)}>
707
+ <svg className="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
708
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
709
+ </svg>
710
+ Upload
711
+ </Button>
712
+ )}
713
+ </div>
714
+
715
+ {/* Filter badges */}
716
+ {attachments.length > 0 && (
717
+ <div className="flex items-center gap-2 mt-4 overflow-x-auto pb-1">
718
+ {Object.entries(CATEGORY_LABELS).map(([key, label]) => {
719
+ const count = categoryCounts[key] ?? 0
720
+ if (key !== 'all' && count === 0) return null
721
+ return (
722
+ <button
723
+ key={key}
724
+ onClick={() => setFilter(key)}
725
+ className={`px-3 py-1 rounded-full text-xs font-medium transition-colors whitespace-nowrap ${
726
+ filter === key
727
+ ? 'bg-primary text-primary-foreground'
728
+ : 'bg-muted/50 text-muted-foreground hover:bg-muted'
729
+ }`}
730
+ >
731
+ {label} {count > 0 && `(${count})`}
732
+ </button>
733
+ )
734
+ })}
735
+ </div>
736
+ )}
737
+ </div>
738
+ </div>
739
+
740
+ {/* Content */}
741
+ <div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
742
+ {isLoading ? (
743
+ <div className="flex items-center justify-center py-16">
744
+ <div className="w-8 h-8 border-2 border-primary/20 border-t-primary rounded-full animate-spin" />
745
+ </div>
746
+ ) : attachments.length === 0 ? (
747
+ <EmptyState
748
+ title="No files yet"
749
+ description={canUpload ? 'Upload your first file to get started' : 'No files have been uploaded yet'}
750
+ icon={
751
+ <svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
752
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
753
+ </svg>
754
+ }
755
+ />
756
+ ) : filteredAttachments.length === 0 ? (
757
+ <EmptyState
758
+ title="No files match"
759
+ description="Try a different filter"
760
+ icon={
761
+ <svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
762
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
763
+ </svg>
764
+ }
765
+ />
766
+ ) : (
767
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
768
+ {filteredAttachments.map(att => {
769
+ const isOwner = att.data.ownerId === user?.id
770
+ const canDelete = isOwner || isAdmin
771
+ const category = getFileCategory(att.data.mimeType, att.data.fileName)
772
+ const url = previewUrls[att.data.fileKey] ?? null
773
+
774
+ return (
775
+ <div
776
+ key={att.recordId}
777
+ className="bg-card/60 rounded-xl border border-border overflow-hidden hover:border-border/80 transition-colors group"
778
+ >
779
+ {/* Thumbnail */}
780
+ <div className="cursor-pointer" onClick={() => setPreviewAttachment(att)}>
781
+ <ThumbnailPreview url={url} fileName={att.data.fileName} mimeType={att.data.mimeType} category={category} />
782
+ </div>
783
+
784
+ {/* Info */}
785
+ <div className="p-3">
786
+ <div className="flex items-start justify-between gap-2">
787
+ <div className="min-w-0 flex-1">
788
+ <p className="text-sm font-medium text-foreground truncate">{att.data.fileName}</p>
789
+ <div className="flex items-center gap-2 mt-1">
790
+ <Badge variant="secondary" size="sm">{category}</Badge>
791
+ <span className="text-xs text-muted-foreground">{formatFileSize(att.data.fileSize)}</span>
792
+ </div>
793
+ </div>
794
+
795
+ <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
796
+ <button
797
+ onClick={() => handleDownload(att.data.fileKey, att.data.fileName)}
798
+ className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-muted/60 rounded-lg transition-colors"
799
+ title="Download"
800
+ >
801
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
802
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
803
+ </svg>
804
+ </button>
805
+ {canDelete && (
806
+ <button
807
+ onClick={() => handleDeleteRequest(att.recordId, att.data.fileKey, att.data.fileName)}
808
+ className="p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/20 rounded-lg transition-colors"
809
+ title="Delete"
810
+ >
811
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
812
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
813
+ </svg>
814
+ </button>
815
+ )}
816
+ </div>
817
+ </div>
818
+ </div>
819
+ </div>
820
+ )
821
+ })}
822
+ </div>
823
+ )}
824
+ </div>
825
+
826
+ {/* Upload Modal */}
827
+ <UploadModal
828
+ isOpen={showUploadModal}
829
+ onClose={() => setShowUploadModal(false)}
830
+ onUpload={handleUpload}
831
+ isUploading={isUploading}
832
+ />
833
+
834
+ {/* Preview Modal */}
835
+ <PreviewModal
836
+ isOpen={!!previewAttachment}
837
+ onClose={() => setPreviewAttachment(null)}
838
+ attachment={previewAttachment}
839
+ previewUrl={previewAttachment ? (previewUrls[previewAttachment.data.fileKey] ?? null) : null}
840
+ onDownload={() => {
841
+ if (previewAttachment) {
842
+ handleDownload(previewAttachment.data.fileKey, previewAttachment.data.fileName)
843
+ }
844
+ }}
845
+ />
846
+
847
+ {/* Delete Confirmation Modal */}
848
+ <Modal open={!!deleteTarget} onClose={() => setDeleteTarget(null)} size="sm" data-testid="delete-confirm-modal">
849
+ <Modal.Header onClose={() => setDeleteTarget(null)}>
850
+ <Modal.Title>Delete File</Modal.Title>
851
+ </Modal.Header>
852
+ <Modal.Body>
853
+ <p className="text-sm text-muted-foreground">
854
+ Are you sure you want to delete <span className="font-medium text-foreground">{deleteTarget?.fileName}</span>? This cannot be undone.
855
+ </p>
856
+ </Modal.Body>
857
+ <Modal.Footer>
858
+ <Button variant="secondary" onClick={() => setDeleteTarget(null)}>Cancel</Button>
859
+ <Button variant="destructive" onClick={handleDeleteConfirm}>Delete</Button>
860
+ </Modal.Footer>
861
+ </Modal>
862
+ </div>
863
+ )
864
+ }