deepspace 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +22 -8
- package/dist/cli.js +10306 -5256
- package/dist/cli.js.map +1 -1
- package/dist/feature-installer.js +638 -0
- package/dist/feature-installer.js.map +1 -0
- package/dist/index.d.ts +107 -117
- package/dist/index.js +610 -779
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +403 -167
- package/dist/server.js +498 -367
- package/dist/server.js.map +1 -1
- package/dist/testing.d.ts +115 -0
- package/dist/testing.js +3 -3
- package/dist/testing.js.map +1 -1
- package/dist/worker.d.ts +397 -160
- package/dist/worker.js +506 -367
- package/dist/worker.js.map +1 -1
- package/features/ai-chat/feature.json +2 -0
- package/features/ai-chat/src/AiChatPage.tsx +2 -3
- package/features/ai-chat/src/ChatPanel.messages.tsx +344 -0
- package/features/ai-chat/src/ChatPanel.stream.ts +397 -0
- package/features/ai-chat/src/ChatPanel.tsx +158 -862
- package/features/ai-chat/src/__tests__/ChatPanel.stream.test.ts +163 -0
- package/features/canvas/src/CanvasListPage.tsx +1 -1
- package/features/docs/feature.json +4 -8
- package/features/docs/src/DocsEditorPage.tsx +33 -326
- package/features/docs/src/DocsListPage.tsx +33 -29
- package/features/docs/src/InviteDialog.tsx +11 -40
- package/features/docs/src/__tests__/docs-presence-access.test.ts +71 -0
- package/features/docs/src/docs-library-types.ts +28 -3
- package/features/docs/src/docs-ui.css +1 -4
- package/features/docs/src/use-docs-presence-access.ts +330 -0
- package/features/file-attachments/feature.json +2 -2
- package/features/file-attachments/src/AttachmentPreview.tsx +428 -0
- package/features/file-attachments/src/AttachmentsPage.tsx +100 -462
- package/features/file-attachments/src/useFilePreview.ts +54 -68
- package/features/file-manager/src/FileManagerPage.tsx +1 -1
- package/features/integration-test/src/integration.spec.ts +1 -1
- package/features/items/feature.json +1 -5
- package/features/items/src/ItemsPage.tsx +207 -135
- package/features/landing/feature.json +3 -0
- package/features/landing/src/LandingPage.tsx +4 -18
- package/features/leaderboard/feature.json +1 -5
- package/features/leaderboard/src/LeaderboardPage.tsx +333 -198
- package/features/messaging/feature.json +0 -4
- package/features/messaging/src/ChatMultiPage.tsx +1 -3
- package/features/messaging/src/ChatPage.tsx +1 -3
- package/features/messaging/src/components/chat/MessageActionSheet.tsx +40 -16
- package/features/search-bar/feature.json +8 -3
- package/features/search-bar/src/SearchOverlay.tsx +108 -252
- package/features/search-bar/src/__tests__/search-model.test.ts +40 -0
- package/features/search-bar/src/search-model.ts +223 -0
- package/features/tasks/src/TasksPage.tsx +3 -3
- package/features/topbar/src/Topbar.tsx +0 -17
- package/package.json +1 -2
- package/features/docs/src/docs-favorites.ts +0 -18
- package/features/docs/src/use-yjs-room-with-awareness.ts +0 -289
- package/features/file-attachments/src/attachments-constants.ts +0 -9
- package/features/items/src/items-constants.ts +0 -10
- package/features/leaderboard/src/leaderboard-constants.ts +0 -21
- package/features/messaging/src/components/chat/ReactionPicker.tsx +0 -2
- package/scripts/add-feature.cjs +0 -807
|
@@ -1,16 +1,6 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
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 {
|
|
25
|
-
import {
|
|
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<
|
|
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
|
|
444
|
-
await
|
|
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
|
|
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
|
|
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
|
-
<
|
|
162
|
+
<AttachmentPreview url={previewUrl} fileName={fileName} mimeType={mimeType} />
|
|
525
163
|
) : (
|
|
526
|
-
<
|
|
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
|
-
//
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
//
|
|
583
|
-
//
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
612
|
-
|
|
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
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
<
|
|
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
|
|
490
|
+
<Modal.Header>
|
|
853
491
|
<Modal.Title>Delete File</Modal.Title>
|
|
854
492
|
</Modal.Header>
|
|
855
493
|
<Modal.Body>
|