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.
- package/dist/cli.js +96 -18
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +160 -1
- package/dist/index.js +283 -126
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +172 -2
- package/dist/server.js +581 -1
- package/dist/server.js.map +1 -1
- package/dist/worker.d.ts +252 -2
- package/dist/worker.js +588 -1
- package/dist/worker.js.map +1 -1
- package/features/admin-page/feature.json +29 -0
- package/features/admin-page/src/AdminPage.tsx +292 -0
- package/features/admin-page/src/admin-schema.ts +20 -0
- package/features/ai-chat/feature.json +41 -0
- package/features/ai-chat/src/AiChatPage.tsx +692 -0
- package/features/ai-chat/src/ChatPanel.tsx +1098 -0
- package/features/ai-chat/src/ai-chat-schema.ts +11 -0
- package/features/canvas/feature.json +45 -0
- package/features/canvas/src/CanvasEditorPage.tsx +87 -0
- package/features/canvas/src/CanvasListPage.tsx +202 -0
- package/features/canvas/src/canvas-schema.ts +33 -0
- package/features/canvas/src/components/CanvasToolbar.tsx +170 -0
- package/features/canvas/src/components/CanvasView.tsx +369 -0
- package/features/canvas/src/components/ShapeRenderer.tsx +114 -0
- package/features/cron/feature.json +33 -0
- package/features/cron/src/CronLogPage.tsx +100 -0
- package/features/cron/src/cron.ts +25 -0
- package/features/docs/feature.json +96 -0
- package/features/docs/src/DocsEditorPage.tsx +1012 -0
- package/features/docs/src/DocsListPage.tsx +951 -0
- package/features/docs/src/DocsOutlinePanel.tsx +68 -0
- package/features/docs/src/DocsPresence.tsx +138 -0
- package/features/docs/src/InviteDialog.tsx +336 -0
- package/features/docs/src/LibrarySidebar.tsx +464 -0
- package/features/docs/src/docs-favorites.ts +18 -0
- package/features/docs/src/docs-library-types.ts +15 -0
- package/features/docs/src/docs-schema.ts +80 -0
- package/features/docs/src/docs-ui.css +309 -0
- package/features/docs/src/editor/DocEditorSurface.tsx +257 -0
- package/features/docs/src/editor/DocsTiptapToolbar.tsx +560 -0
- package/features/docs/src/editor/useDocEditor.ts +206 -0
- package/features/docs/src/use-yjs-room-with-awareness.ts +285 -0
- package/features/file-attachments/feature.json +42 -0
- package/features/file-attachments/src/AttachmentsPage.tsx +864 -0
- package/features/file-attachments/src/attachments-constants.ts +9 -0
- package/features/file-attachments/src/attachments-schema.ts +45 -0
- package/features/file-attachments/src/useFilePreview.ts +471 -0
- package/features/file-attachments/src/useMimeTypeDetection.ts +138 -0
- package/features/file-manager/feature.json +24 -0
- package/features/file-manager/src/FileManagerPage.tsx +330 -0
- package/features/integration-test/feature.json +18 -0
- package/features/integration-test/src/IntegrationTestPage.tsx +423 -0
- package/features/integration-test/src/integration.spec.ts +42 -0
- package/features/items/feature.json +33 -0
- package/features/items/src/ItemsPage.tsx +325 -0
- package/features/items/src/items-constants.ts +10 -0
- package/features/items/src/items-schema.ts +27 -0
- package/features/kanban/feature.json +18 -0
- package/features/kanban/src/Kanban.tsx +283 -0
- package/features/landing/feature.json +62 -0
- package/features/landing/src/LandingPage.tsx +627 -0
- package/features/landing/src/primitives.tsx +389 -0
- package/features/landing/src/sections/FAQSection.tsx +110 -0
- package/features/landing/src/sections/FeaturesGridSection.tsx +311 -0
- package/features/landing/src/sections/HowItWorksSection.tsx +62 -0
- package/features/landing/src/sections/LogoCloudSection.tsx +58 -0
- package/features/landing/src/sections/ShowcaseSection.tsx +63 -0
- package/features/landing/src/sections/StatsSection.tsx +31 -0
- package/features/landing/src/sections/TeamSection.tsx +86 -0
- package/features/landing/src/sections/TestimonialsSection.tsx +68 -0
- package/features/landing/src/sections/VideoSection.tsx +71 -0
- package/features/leaderboard/feature.json +33 -0
- package/features/leaderboard/src/LeaderboardPage.tsx +372 -0
- package/features/leaderboard/src/leaderboard-constants.ts +21 -0
- package/features/leaderboard/src/leaderboard-schema.ts +36 -0
- package/features/messaging/feature.json +111 -0
- package/features/messaging/src/ChatMultiPage.tsx +300 -0
- package/features/messaging/src/ChatPage.tsx +75 -0
- package/features/messaging/src/components/chat/ChatHeader.tsx +76 -0
- package/features/messaging/src/components/chat/MessageActionSheet.tsx +168 -0
- package/features/messaging/src/components/chat/MessageInput.tsx +65 -0
- package/features/messaging/src/components/chat/MessageItem.tsx +352 -0
- package/features/messaging/src/components/chat/MessageList.tsx +248 -0
- package/features/messaging/src/components/chat/ReactionPicker.tsx +2 -0
- package/features/messaging/src/components/chat/ThreadPanel.tsx +107 -0
- package/features/messaging/src/components/chat/UserProfilePopover.tsx +158 -0
- package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +188 -0
- package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +163 -0
- package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +147 -0
- package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +344 -0
- package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +460 -0
- package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +190 -0
- package/features/messaging/src/components/chat-multi/InvitationList.tsx +149 -0
- package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +159 -0
- package/features/messaging/src/hooks/useChatChannel.ts +64 -0
- package/features/messaging/src/hooks/useLongPress.ts +74 -0
- package/features/messaging/src/hooks/useMultiChannel.ts +142 -0
- package/features/messaging/src/messaging-schema.ts +25 -0
- package/features/presence-test/feature.json +21 -0
- package/features/presence-test/src/PresenceTestPage.tsx +120 -0
- package/features/sidebar/feature.json +19 -0
- package/features/sidebar/src/AppSidebar.tsx +558 -0
- package/features/tasks/feature.json +33 -0
- package/features/tasks/src/TasksPage.tsx +598 -0
- package/features/tasks/src/tasks-constants.ts +43 -0
- package/features/tasks/src/tasks-schema.ts +56 -0
- package/features/testing/feature.json +49 -0
- package/features/testing/src/TestPage.tsx +145 -0
- package/features/testing/src/cpu-burn-action.ts +51 -0
- package/features/testing/src/subrequest-burn-action.ts +53 -0
- package/features/testing/src/test-schema.ts +23 -0
- package/features/topbar/feature.json +18 -0
- package/features/topbar/src/Topbar.tsx +353 -0
- package/features/tree/feature.json +26 -0
- package/features/tree/src/TreeSidebar.tsx +607 -0
- package/features/tree/src/tree-constants.ts +68 -0
- package/features/tree/src/tree-hooks.ts +258 -0
- package/package.json +4 -2
- 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)} · {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>·</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
|
+
}
|