deepspace 0.19.5 → 0.21.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 +24 -0
- package/dist/{chunk-A7UFIOWO.js → chunk-KDCDRHBA.js} +7 -2
- package/dist/chunk-KDCDRHBA.js.map +1 -0
- package/dist/cli.js +219 -94
- package/dist/cli.js.map +1 -1
- package/dist/documentation-client-core.d.ts +3 -2
- package/dist/documentation-client-core.js +1 -1
- package/dist/documentation-default-renderer.cjs +48 -48
- package/dist/documentation-react.d.ts +2 -1
- package/dist/documentation-react.js +1 -1
- package/dist/documentation-runtime.js +7 -7
- package/dist/documentation-server-core.d.ts +3 -2
- package/dist/documentation-server-core.js +1 -1
- package/dist/documentation.d.ts +40 -12
- package/dist/documentation.js +40 -7
- package/dist/documentation.js.map +1 -1
- package/dist/index.d.ts +225 -21
- package/dist/index.js +262 -112
- package/dist/index.js.map +1 -1
- package/dist/{public-CFAj1hL2.d.ts → public-DenQ1TsZ.d.ts} +36 -11
- package/dist/server.d.ts +46 -29
- package/dist/server.js +1041 -254
- package/dist/server.js.map +1 -1
- package/dist/testing-mcp.d.ts +69 -0
- package/dist/testing-mcp.js +99 -0
- package/dist/testing-mcp.js.map +1 -0
- package/dist/testing.d.ts +17 -20
- package/dist/testing.js +47 -25
- package/dist/testing.js.map +1 -1
- package/dist/worker.d.ts +73 -32
- package/dist/worker.js +1094 -244
- package/dist/worker.js.map +1 -1
- package/features/ai-chat/feature.json +2 -2
- package/features/ai-chat/src/AiChatPage.tsx +56 -21
- package/features/ai-chat/src/ChatPanel.tsx +33 -10
- package/features/ai-chat/src/__tests__/ChatPanel.lifecycle.test.tsx +156 -0
- package/features/cron/src/CronLogPage.tsx +107 -69
- package/features/file-attachments/feature.json +2 -1
- package/features/file-manager/feature.json +2 -1
- package/features/file-manager/src/FileManagerPage.tsx +227 -194
- package/features/integration-test/src/IntegrationTestPage.tsx +54 -34
- package/package.json +5 -1
- package/dist/chunk-A7UFIOWO.js.map +0 -1
|
@@ -1,48 +1,36 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
import type
|
|
15
|
-
|
|
16
|
-
// ============================================================================
|
|
17
|
-
// Helpers
|
|
18
|
-
// ============================================================================
|
|
1
|
+
/** Per-user file management with authenticated previews and visible mutation outcomes. */
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
Download,
|
|
6
|
+
Eye,
|
|
7
|
+
File as FileIcon,
|
|
8
|
+
FileImage,
|
|
9
|
+
FolderOpen,
|
|
10
|
+
Trash2,
|
|
11
|
+
TriangleAlert,
|
|
12
|
+
UploadCloud,
|
|
13
|
+
} from 'lucide-react'
|
|
14
|
+
import { useR2Files, type R2FileInfo } from 'deepspace'
|
|
15
|
+
import { Badge, Button, ConfirmModal, EmptyState, Modal, useToast } from '@/components/ui'
|
|
19
16
|
|
|
20
17
|
function formatBytes(bytes: number): string {
|
|
21
18
|
if (bytes === 0) return '0 B'
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
|
|
19
|
+
const units = ['B', 'KB', 'MB', 'GB']
|
|
20
|
+
const unit = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
|
|
21
|
+
return `${parseFloat((bytes / 1024 ** unit).toFixed(1))} ${units[unit]}`
|
|
26
22
|
}
|
|
27
23
|
|
|
28
|
-
function
|
|
29
|
-
return
|
|
24
|
+
function displayName(file: R2FileInfo): string {
|
|
25
|
+
return file.originalName ?? file.key.split('/').pop() ?? 'Unnamed'
|
|
30
26
|
}
|
|
31
27
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if (/\.(mp4|mov|webm|avi)$/i.test(name)) return 'video'
|
|
37
|
-
if (/\.(mp3|wav|ogg|flac)$/i.test(name)) return 'audio'
|
|
38
|
-
if (/\.(zip|tar|gz|rar|7z)$/i.test(name)) return 'archive'
|
|
39
|
-
return 'file'
|
|
28
|
+
// SVG is served as a download for origin safety, so it is deliberately not
|
|
29
|
+
// rendered inline. It remains uploadable and downloadable like every file.
|
|
30
|
+
function canPreviewImage(name: string): boolean {
|
|
31
|
+
return /\.(jpg|jpeg|png|gif|webp|bmp|ico)$/i.test(name)
|
|
40
32
|
}
|
|
41
33
|
|
|
42
|
-
// ============================================================================
|
|
43
|
-
// Drop Zone
|
|
44
|
-
// ============================================================================
|
|
45
|
-
|
|
46
34
|
interface DropZoneProps {
|
|
47
35
|
onFiles: (files: File[]) => void
|
|
48
36
|
isUploading: boolean
|
|
@@ -52,75 +40,60 @@ function DropZone({ onFiles, isUploading }: DropZoneProps) {
|
|
|
52
40
|
const [isDragging, setIsDragging] = useState(false)
|
|
53
41
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
54
42
|
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
65
|
-
e.preventDefault()
|
|
66
|
-
setIsDragging(false)
|
|
67
|
-
const files = Array.from(e.dataTransfer.files)
|
|
68
|
-
if (files.length > 0) onFiles(files)
|
|
69
|
-
}, [onFiles])
|
|
70
|
-
|
|
71
|
-
const handleClick = () => inputRef.current?.click()
|
|
72
|
-
|
|
73
|
-
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
74
|
-
const files = Array.from(e.target.files ?? [])
|
|
75
|
-
if (files.length > 0) onFiles(files)
|
|
76
|
-
e.target.value = ''
|
|
77
|
-
}
|
|
43
|
+
const handleDrop = useCallback(
|
|
44
|
+
(event: React.DragEvent) => {
|
|
45
|
+
event.preventDefault()
|
|
46
|
+
setIsDragging(false)
|
|
47
|
+
const files = Array.from(event.dataTransfer.files)
|
|
48
|
+
if (files.length > 0) onFiles(files)
|
|
49
|
+
},
|
|
50
|
+
[onFiles],
|
|
51
|
+
)
|
|
78
52
|
|
|
79
53
|
return (
|
|
80
54
|
<div
|
|
81
|
-
onDragOver={
|
|
82
|
-
|
|
55
|
+
onDragOver={(event) => {
|
|
56
|
+
event.preventDefault()
|
|
57
|
+
setIsDragging(true)
|
|
58
|
+
}}
|
|
59
|
+
onDragLeave={() => setIsDragging(false)}
|
|
83
60
|
onDrop={handleDrop}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
${isDragging
|
|
88
|
-
? 'border-primary bg-primary/5'
|
|
89
|
-
: 'border-border hover:border-primary/50 hover:bg-muted/30'
|
|
90
|
-
}
|
|
91
|
-
${isUploading ? 'pointer-events-none opacity-60' : ''}
|
|
92
|
-
`}
|
|
61
|
+
className={`rounded-xl border-2 border-dashed p-8 text-center transition-colors ${
|
|
62
|
+
isDragging ? 'border-primary bg-primary/5' : 'border-border bg-card/30'
|
|
63
|
+
}`}
|
|
93
64
|
>
|
|
94
65
|
<input
|
|
95
66
|
ref={inputRef}
|
|
96
67
|
type="file"
|
|
97
68
|
multiple
|
|
98
|
-
onChange={
|
|
69
|
+
onChange={(event) => {
|
|
70
|
+
const files = Array.from(event.target.files ?? [])
|
|
71
|
+
if (files.length > 0) onFiles(files)
|
|
72
|
+
event.target.value = ''
|
|
73
|
+
}}
|
|
99
74
|
className="hidden"
|
|
100
75
|
/>
|
|
101
76
|
{isUploading ? (
|
|
102
|
-
<div className="
|
|
103
|
-
<div className="
|
|
104
|
-
<
|
|
77
|
+
<div className="mx-auto max-w-xs space-y-3" aria-label="Uploading files" aria-busy="true">
|
|
78
|
+
<div className="mx-auto size-10 animate-pulse rounded-lg bg-muted" />
|
|
79
|
+
<div className="mx-auto h-4 w-32 animate-pulse rounded bg-muted" />
|
|
80
|
+
<div className="mx-auto h-3 w-48 animate-pulse rounded bg-muted/70" />
|
|
105
81
|
</div>
|
|
106
82
|
) : (
|
|
107
|
-
<div className="flex flex-col items-center gap-
|
|
108
|
-
<
|
|
109
|
-
<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" />
|
|
110
|
-
</svg>
|
|
83
|
+
<div className="flex flex-col items-center gap-3">
|
|
84
|
+
<UploadCloud className="size-10 text-muted-foreground" aria-hidden="true" />
|
|
111
85
|
<p className="text-sm text-muted-foreground">
|
|
112
|
-
|
|
86
|
+
Drag files here or choose them from your device.
|
|
113
87
|
</p>
|
|
88
|
+
<Button variant="secondary" onClick={() => inputRef.current?.click()}>
|
|
89
|
+
Choose files
|
|
90
|
+
</Button>
|
|
114
91
|
</div>
|
|
115
92
|
)}
|
|
116
93
|
</div>
|
|
117
94
|
)
|
|
118
95
|
}
|
|
119
96
|
|
|
120
|
-
// ============================================================================
|
|
121
|
-
// File Card
|
|
122
|
-
// ============================================================================
|
|
123
|
-
|
|
124
97
|
interface FileCardProps {
|
|
125
98
|
file: R2FileInfo
|
|
126
99
|
onDownload: () => void
|
|
@@ -129,180 +102,234 @@ interface FileCardProps {
|
|
|
129
102
|
}
|
|
130
103
|
|
|
131
104
|
function FileCard({ file, onDownload, onDelete, onPreview }: FileCardProps) {
|
|
132
|
-
const name = file
|
|
133
|
-
const
|
|
134
|
-
const previewable = isImage(name)
|
|
135
|
-
|
|
136
|
-
const iconColor: Record<string, string> = {
|
|
137
|
-
image: 'text-emerald-500',
|
|
138
|
-
pdf: 'text-red-500',
|
|
139
|
-
doc: 'text-blue-500',
|
|
140
|
-
video: 'text-purple-500',
|
|
141
|
-
audio: 'text-orange-500',
|
|
142
|
-
archive: 'text-yellow-500',
|
|
143
|
-
file: 'text-muted-foreground',
|
|
144
|
-
}
|
|
105
|
+
const name = displayName(file)
|
|
106
|
+
const previewable = canPreviewImage(name)
|
|
145
107
|
|
|
146
108
|
return (
|
|
147
|
-
<div className="
|
|
109
|
+
<div className="rounded-xl border border-border bg-card/60 p-4 transition-colors hover:border-primary/30">
|
|
148
110
|
<div className="flex items-start gap-3">
|
|
149
|
-
<div
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="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" />
|
|
156
|
-
) : (
|
|
157
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="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" />
|
|
158
|
-
)}
|
|
159
|
-
</svg>
|
|
111
|
+
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted/60">
|
|
112
|
+
{previewable ? (
|
|
113
|
+
<FileImage className="size-5 text-emerald-500" aria-hidden="true" />
|
|
114
|
+
) : (
|
|
115
|
+
<FileIcon className="size-5 text-muted-foreground" aria-hidden="true" />
|
|
116
|
+
)}
|
|
160
117
|
</div>
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
<
|
|
164
|
-
|
|
165
|
-
|
|
118
|
+
<div className="min-w-0 flex-1">
|
|
119
|
+
<p className="truncate text-sm font-medium text-foreground">{name}</p>
|
|
120
|
+
<div className="mt-1 flex items-center gap-2">
|
|
121
|
+
<Badge variant="secondary" size="sm">
|
|
122
|
+
{formatBytes(file.size)}
|
|
123
|
+
</Badge>
|
|
166
124
|
<span className="text-xs text-muted-foreground">
|
|
167
125
|
{new Date(file.uploaded).toLocaleDateString()}
|
|
168
126
|
</span>
|
|
169
127
|
</div>
|
|
170
128
|
</div>
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
>
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
</
|
|
129
|
+
</div>
|
|
130
|
+
<div className="mt-4 flex flex-wrap gap-2">
|
|
131
|
+
{previewable && (
|
|
132
|
+
<Button variant="ghost" size="sm" onClick={onPreview}>
|
|
133
|
+
<Eye aria-hidden="true" />
|
|
134
|
+
Preview
|
|
135
|
+
</Button>
|
|
136
|
+
)}
|
|
137
|
+
<Button variant="ghost" size="sm" onClick={onDownload}>
|
|
138
|
+
<Download aria-hidden="true" />
|
|
139
|
+
Download
|
|
140
|
+
</Button>
|
|
141
|
+
<Button
|
|
142
|
+
variant="ghost"
|
|
143
|
+
size="sm"
|
|
144
|
+
className="text-destructive hover:bg-destructive/20 hover:text-destructive"
|
|
145
|
+
onClick={onDelete}
|
|
146
|
+
>
|
|
147
|
+
<Trash2 aria-hidden="true" />
|
|
148
|
+
Delete
|
|
149
|
+
</Button>
|
|
192
150
|
</div>
|
|
193
151
|
</div>
|
|
194
152
|
)
|
|
195
153
|
}
|
|
196
154
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
155
|
+
function PreviewModal({
|
|
156
|
+
file,
|
|
157
|
+
imageUrl,
|
|
158
|
+
onClose,
|
|
159
|
+
}: {
|
|
202
160
|
file: R2FileInfo | null
|
|
203
161
|
imageUrl: string | null
|
|
204
162
|
onClose: () => void
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function PreviewModal({ file, imageUrl, onClose }: PreviewModalProps) {
|
|
208
|
-
const name = file?.originalName ?? file?.key.split('/').pop() ?? ''
|
|
209
|
-
|
|
163
|
+
}) {
|
|
210
164
|
return (
|
|
211
|
-
<Modal open={
|
|
165
|
+
<Modal open={file !== null} onClose={onClose} size="lg">
|
|
212
166
|
<Modal.Header>
|
|
213
|
-
<Modal.Title>{
|
|
167
|
+
<Modal.Title>{file ? displayName(file) : ''}</Modal.Title>
|
|
214
168
|
</Modal.Header>
|
|
215
169
|
<Modal.Body>
|
|
216
|
-
{imageUrl && (
|
|
217
|
-
<img
|
|
170
|
+
{imageUrl && file && (
|
|
171
|
+
<img
|
|
172
|
+
src={imageUrl}
|
|
173
|
+
alt={displayName(file)}
|
|
174
|
+
className="mx-auto max-h-[70vh] max-w-full rounded-lg"
|
|
175
|
+
/>
|
|
218
176
|
)}
|
|
219
177
|
</Modal.Body>
|
|
220
178
|
</Modal>
|
|
221
179
|
)
|
|
222
180
|
}
|
|
223
181
|
|
|
224
|
-
// ============================================================================
|
|
225
|
-
// Main Page
|
|
226
|
-
// ============================================================================
|
|
227
|
-
|
|
228
182
|
export default function FileManagerPage({ className }: { className?: string }) {
|
|
229
|
-
const { upload, downloadFile, deleteFile, list,
|
|
183
|
+
const { upload, downloadFile, readFile, deleteFile, list, isUploading } = useR2Files()
|
|
184
|
+
const toast = useToast()
|
|
230
185
|
const [files, setFiles] = useState<R2FileInfo[]>([])
|
|
231
186
|
const [loading, setLoading] = useState(true)
|
|
187
|
+
const [loadError, setLoadError] = useState<string | null>(null)
|
|
232
188
|
const [previewFile, setPreviewFile] = useState<R2FileInfo | null>(null)
|
|
189
|
+
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
|
190
|
+
const [deleteTarget, setDeleteTarget] = useState<R2FileInfo | null>(null)
|
|
191
|
+
const [deleting, setDeleting] = useState(false)
|
|
192
|
+
const previewUrlRef = useRef<string | null>(null)
|
|
233
193
|
|
|
194
|
+
// `list` throws on failure; rendered as an error state rather than the
|
|
195
|
+
// empty grid an unreachable or unauthorized account is not.
|
|
234
196
|
const refreshFiles = useCallback(async () => {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
197
|
+
try {
|
|
198
|
+
setFiles(await list())
|
|
199
|
+
setLoadError(null)
|
|
200
|
+
} catch (error) {
|
|
201
|
+
setLoadError(error instanceof Error ? error.message : 'Please try again.')
|
|
202
|
+
} finally {
|
|
203
|
+
setLoading(false)
|
|
204
|
+
}
|
|
238
205
|
}, [list])
|
|
239
206
|
|
|
240
207
|
useEffect(() => {
|
|
241
|
-
refreshFiles()
|
|
208
|
+
void refreshFiles()
|
|
242
209
|
}, [refreshFiles])
|
|
243
210
|
|
|
211
|
+
useEffect(
|
|
212
|
+
() => () => {
|
|
213
|
+
if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current)
|
|
214
|
+
},
|
|
215
|
+
[],
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
const closePreview = () => {
|
|
219
|
+
if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current)
|
|
220
|
+
previewUrlRef.current = null
|
|
221
|
+
setPreviewUrl(null)
|
|
222
|
+
setPreviewFile(null)
|
|
223
|
+
}
|
|
224
|
+
|
|
244
225
|
const handleUpload = async (fileList: File[]) => {
|
|
226
|
+
let uploaded = 0
|
|
245
227
|
for (const file of fileList) {
|
|
246
|
-
await upload(file)
|
|
228
|
+
const result = await upload(file)
|
|
229
|
+
if (!result.success) {
|
|
230
|
+
toast.error(`Could not upload "${file.name}"`, result.error ?? 'Please try again.')
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
uploaded += 1
|
|
234
|
+
}
|
|
235
|
+
if (uploaded > 0) {
|
|
236
|
+
toast.success(
|
|
237
|
+
uploaded === 1 ? 'File uploaded' : `${uploaded} files uploaded`,
|
|
238
|
+
'Your file list is up to date.',
|
|
239
|
+
)
|
|
240
|
+
await refreshFiles()
|
|
247
241
|
}
|
|
248
|
-
await refreshFiles()
|
|
249
242
|
}
|
|
250
243
|
|
|
251
244
|
const handleDownload = async (file: R2FileInfo) => {
|
|
252
|
-
await downloadFile(file)
|
|
245
|
+
const result = await downloadFile(file)
|
|
246
|
+
if (!result.success) {
|
|
247
|
+
toast.error(`Could not download "${displayName(file)}"`, result.error ?? 'Please try again.')
|
|
248
|
+
}
|
|
253
249
|
}
|
|
254
250
|
|
|
255
|
-
const handleDelete = async (
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
251
|
+
const handleDelete = async () => {
|
|
252
|
+
if (!deleteTarget || deleting) return
|
|
253
|
+
setDeleting(true)
|
|
254
|
+
try {
|
|
255
|
+
const result = await deleteFile(deleteTarget)
|
|
256
|
+
if (!result.success) {
|
|
257
|
+
toast.error(
|
|
258
|
+
`Could not delete "${displayName(deleteTarget)}"`,
|
|
259
|
+
result.error ?? 'Please try again.',
|
|
260
|
+
)
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
toast.success('File deleted', `"${displayName(deleteTarget)}" was deleted.`)
|
|
264
|
+
setDeleteTarget(null)
|
|
265
|
+
await refreshFiles()
|
|
266
|
+
} finally {
|
|
267
|
+
setDeleting(false)
|
|
268
|
+
}
|
|
260
269
|
}
|
|
261
270
|
|
|
262
|
-
const handlePreview = (file: R2FileInfo) => {
|
|
263
|
-
|
|
271
|
+
const handlePreview = async (file: R2FileInfo) => {
|
|
272
|
+
try {
|
|
273
|
+
const response = await readFile(file)
|
|
274
|
+
const url = URL.createObjectURL(await response.blob())
|
|
275
|
+
if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current)
|
|
276
|
+
previewUrlRef.current = url
|
|
277
|
+
setPreviewUrl(url)
|
|
278
|
+
setPreviewFile(file)
|
|
279
|
+
} catch (error) {
|
|
280
|
+
toast.error(
|
|
281
|
+
`Could not preview "${displayName(file)}"`,
|
|
282
|
+
error instanceof Error ? error.message : 'Please try again.',
|
|
283
|
+
)
|
|
284
|
+
}
|
|
264
285
|
}
|
|
265
286
|
|
|
266
287
|
return (
|
|
267
|
-
<div className={`h-full
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
|
288
|
+
<div className={`h-full overflow-y-auto bg-background ${className ?? ''}`}>
|
|
289
|
+
<div className="sticky top-0 z-10 border-b border-border bg-card/60 backdrop-blur-md">
|
|
290
|
+
<div className="mx-auto max-w-4xl px-4 py-4 sm:px-6 lg:px-8">
|
|
271
291
|
<div className="flex items-center justify-between">
|
|
272
292
|
<div>
|
|
273
293
|
<h1 className="text-2xl font-bold text-foreground">Files</h1>
|
|
274
|
-
<p className="text-muted-foreground
|
|
294
|
+
<p className="mt-1 text-muted-foreground">
|
|
275
295
|
{files.length} {files.length === 1 ? 'file' : 'files'} stored
|
|
276
296
|
</p>
|
|
277
297
|
</div>
|
|
278
298
|
{files.length > 0 && (
|
|
279
299
|
<Badge variant="secondary">
|
|
280
|
-
{formatBytes(files.reduce((sum,
|
|
300
|
+
{formatBytes(files.reduce((sum, file) => sum + file.size, 0))} used
|
|
281
301
|
</Badge>
|
|
282
302
|
)}
|
|
283
303
|
</div>
|
|
284
304
|
</div>
|
|
285
305
|
</div>
|
|
286
306
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
{/* Upload zone */}
|
|
290
|
-
<DropZone onFiles={handleUpload} isUploading={isUploading} />
|
|
307
|
+
<div className="mx-auto max-w-4xl space-y-6 px-4 py-8 sm:px-6 lg:px-8">
|
|
308
|
+
<DropZone onFiles={(nextFiles) => void handleUpload(nextFiles)} isUploading={isUploading} />
|
|
291
309
|
|
|
292
|
-
{/* File list */}
|
|
293
310
|
{loading ? (
|
|
294
|
-
<div className="
|
|
295
|
-
<div className="
|
|
311
|
+
<div className="grid gap-3 sm:grid-cols-2" aria-label="Loading files" aria-busy="true">
|
|
312
|
+
<div className="h-36 animate-pulse rounded-xl border border-border bg-muted/40" />
|
|
313
|
+
<div className="h-36 animate-pulse rounded-xl border border-border bg-muted/40" />
|
|
296
314
|
</div>
|
|
315
|
+
) : loadError ? (
|
|
316
|
+
<EmptyState
|
|
317
|
+
title="Could not load files"
|
|
318
|
+
description={loadError}
|
|
319
|
+
icon={<TriangleAlert aria-hidden="true" />}
|
|
320
|
+
action={{
|
|
321
|
+
label: 'Retry',
|
|
322
|
+
onClick: () => {
|
|
323
|
+
setLoading(true)
|
|
324
|
+
void refreshFiles()
|
|
325
|
+
},
|
|
326
|
+
}}
|
|
327
|
+
/>
|
|
297
328
|
) : files.length === 0 ? (
|
|
298
329
|
<EmptyState
|
|
299
330
|
title="No files yet"
|
|
300
|
-
description="Upload your first file using the drop zone above"
|
|
301
|
-
icon={
|
|
302
|
-
<svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
303
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
|
304
|
-
</svg>
|
|
305
|
-
}
|
|
331
|
+
description="Upload your first file using the drop zone above."
|
|
332
|
+
icon={<FolderOpen aria-hidden="true" />}
|
|
306
333
|
/>
|
|
307
334
|
) : (
|
|
308
335
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
@@ -310,20 +337,26 @@ export default function FileManagerPage({ className }: { className?: string }) {
|
|
|
310
337
|
<FileCard
|
|
311
338
|
key={file.key}
|
|
312
339
|
file={file}
|
|
313
|
-
onDownload={() => handleDownload(file)}
|
|
314
|
-
onDelete={() =>
|
|
315
|
-
onPreview={() => handlePreview(file)}
|
|
340
|
+
onDownload={() => void handleDownload(file)}
|
|
341
|
+
onDelete={() => setDeleteTarget(file)}
|
|
342
|
+
onPreview={() => void handlePreview(file)}
|
|
316
343
|
/>
|
|
317
344
|
))}
|
|
318
345
|
</div>
|
|
319
346
|
)}
|
|
320
347
|
</div>
|
|
321
348
|
|
|
322
|
-
{
|
|
323
|
-
<
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
349
|
+
<PreviewModal file={previewFile} imageUrl={previewUrl} onClose={closePreview} />
|
|
350
|
+
<ConfirmModal
|
|
351
|
+
open={deleteTarget !== null}
|
|
352
|
+
onClose={() => {
|
|
353
|
+
if (!deleting) setDeleteTarget(null)
|
|
354
|
+
}}
|
|
355
|
+
onConfirm={() => void handleDelete()}
|
|
356
|
+
title={deleteTarget ? `Delete "${displayName(deleteTarget)}"?` : 'Delete file?'}
|
|
357
|
+
description="This cannot be undone."
|
|
358
|
+
confirmText="Delete"
|
|
359
|
+
loading={deleting}
|
|
327
360
|
/>
|
|
328
361
|
</div>
|
|
329
362
|
)
|