deepspace 0.20.0 → 0.22.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/{chunk-A7UFIOWO.js → chunk-7TDBFEB7.js} +7 -2
  3. package/dist/chunk-7TDBFEB7.js.map +1 -0
  4. package/dist/cli.js +288 -120
  5. package/dist/cli.js.map +1 -1
  6. package/dist/documentation-client-core.d.ts +3 -2
  7. package/dist/documentation-client-core.js +1 -1
  8. package/dist/documentation-default-renderer.cjs +48 -48
  9. package/dist/documentation-react.d.ts +2 -1
  10. package/dist/documentation-react.js +1 -1
  11. package/dist/documentation-runtime.js +7 -7
  12. package/dist/documentation-server-core.d.ts +3 -2
  13. package/dist/documentation-server-core.js +1 -1
  14. package/dist/documentation.d.ts +40 -12
  15. package/dist/documentation.js +40 -7
  16. package/dist/documentation.js.map +1 -1
  17. package/dist/index.d.ts +225 -21
  18. package/dist/index.js +239 -100
  19. package/dist/index.js.map +1 -1
  20. package/dist/{public-CFAj1hL2.d.ts → public-DenQ1TsZ.d.ts} +36 -11
  21. package/dist/server.d.ts +32 -4
  22. package/dist/server.js +177 -43
  23. package/dist/server.js.map +1 -1
  24. package/dist/testing-mcp.d.ts +69 -0
  25. package/dist/testing-mcp.js +99 -0
  26. package/dist/testing-mcp.js.map +1 -0
  27. package/dist/testing.d.ts +17 -20
  28. package/dist/testing.js +47 -25
  29. package/dist/testing.js.map +1 -1
  30. package/dist/worker.d.ts +57 -5
  31. package/dist/worker.js +262 -62
  32. package/dist/worker.js.map +1 -1
  33. package/features/ai-chat/feature.json +2 -2
  34. package/features/ai-chat/src/AiChatPage.tsx +56 -21
  35. package/features/ai-chat/src/ChatPanel.tsx +33 -10
  36. package/features/ai-chat/src/__tests__/ChatPanel.lifecycle.test.tsx +156 -0
  37. package/features/cron/src/CronLogPage.tsx +107 -69
  38. package/features/file-attachments/feature.json +2 -1
  39. package/features/file-manager/feature.json +2 -1
  40. package/features/file-manager/src/FileManagerPage.tsx +227 -194
  41. package/features/integration-test/src/IntegrationTestPage.tsx +54 -34
  42. package/package.json +5 -1
  43. package/dist/chunk-A7UFIOWO.js.map +0 -1
@@ -1,48 +1,36 @@
1
- /**
2
- * File Manager Page
3
- *
4
- * Demonstrates:
5
- * - useR2Files for upload, list, download, delete
6
- * - Drag-and-drop upload zone
7
- * - Inline image preview
8
- * - File size formatting
9
- */
10
-
11
- import { useState, useEffect, useCallback, useRef } from 'react'
12
- import { useR2Files } from 'deepspace'
13
- import { EmptyState, Badge, Modal } from '@/components/ui'
14
- import type { R2FileInfo } from 'deepspace'
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 k = 1024
23
- const sizes = ['B', 'KB', 'MB', 'GB']
24
- const i = Math.floor(Math.log(bytes) / Math.log(k))
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 isImage(name: string): boolean {
29
- return /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)$/i.test(name)
24
+ function displayName(file: R2FileInfo): string {
25
+ return file.originalName ?? file.key.split('/').pop() ?? 'Unnamed'
30
26
  }
31
27
 
32
- function fileIcon(name: string): string {
33
- if (isImage(name)) return 'image'
34
- if (/\.(pdf)$/i.test(name)) return 'pdf'
35
- if (/\.(doc|docx|txt|md)$/i.test(name)) return 'doc'
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 handleDragOver = useCallback((e: React.DragEvent) => {
56
- e.preventDefault()
57
- setIsDragging(true)
58
- }, [])
59
-
60
- const handleDragLeave = useCallback(() => {
61
- setIsDragging(false)
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={handleDragOver}
82
- onDragLeave={handleDragLeave}
55
+ onDragOver={(event) => {
56
+ event.preventDefault()
57
+ setIsDragging(true)
58
+ }}
59
+ onDragLeave={() => setIsDragging(false)}
83
60
  onDrop={handleDrop}
84
- onClick={handleClick}
85
- className={`
86
- relative border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors
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={handleInputChange}
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="flex flex-col items-center gap-2">
103
- <div className="w-8 h-8 border-2 border-primary/20 border-t-primary rounded-full animate-spin" />
104
- <p className="text-sm text-muted-foreground">Uploading...</p>
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-2">
108
- <svg className="w-10 h-10 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
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
- <span className="font-medium text-foreground">Click to upload</span> or drag and drop
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.originalName ?? file.key.split('/').pop() ?? 'Unnamed'
133
- const type = fileIcon(name)
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="group p-4 bg-card/60 rounded-xl border border-border hover:border-primary/30 transition-colors">
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
- className={`shrink-0 w-10 h-10 rounded-lg bg-muted/60 flex items-center justify-center ${iconColor[type] ?? ''} ${previewable ? 'cursor-pointer' : ''}`}
151
- onClick={previewable ? onPreview : undefined}
152
- >
153
- <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
154
- {type === 'image' ? (
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
- <div className="flex-1 min-w-0">
163
- <p className="text-sm font-medium text-foreground truncate">{name}</p>
164
- <div className="flex items-center gap-2 mt-1">
165
- <Badge variant="secondary" size="sm">{formatBytes(file.size)}</Badge>
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
- <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
173
- <button
174
- onClick={onDownload}
175
- className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-muted/60 rounded-lg transition-colors"
176
- title="Download"
177
- >
178
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
179
- <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" />
180
- </svg>
181
- </button>
182
- <button
183
- onClick={onDelete}
184
- className="p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/20 rounded-lg transition-colors"
185
- title="Delete"
186
- >
187
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
188
- <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" />
189
- </svg>
190
- </button>
191
- </div>
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
- // Preview Modal
199
- // ============================================================================
200
-
201
- interface PreviewModalProps {
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={!!file} onClose={onClose} size="lg">
165
+ <Modal open={file !== null} onClose={onClose} size="lg">
212
166
  <Modal.Header>
213
- <Modal.Title>{name}</Modal.Title>
167
+ <Modal.Title>{file ? displayName(file) : ''}</Modal.Title>
214
168
  </Modal.Header>
215
169
  <Modal.Body>
216
- {imageUrl && (
217
- <img src={imageUrl} alt={name} className="max-w-full max-h-[70vh] mx-auto rounded-lg" />
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, getUrl, isUploading } = useR2Files()
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
- const result = await list()
236
- setFiles(result)
237
- setLoading(false)
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 (file: R2FileInfo) => {
256
- const name = file.originalName ?? file.key.split('/').pop() ?? 'this file'
257
- if (!confirm(`Delete "${name}"?`)) return
258
- await deleteFile(file)
259
- await refreshFiles()
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
- setPreviewFile(file)
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 bg-background overflow-y-auto ${className ?? ''}`}>
268
- {/* Header */}
269
- <div className="bg-card/60 backdrop-blur-md border-b border-border sticky top-0 z-10">
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 mt-1">
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, f) => sum + f.size, 0))} used
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
- {/* Content */}
288
- <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
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="flex items-center justify-center py-16">
295
- <div className="w-8 h-8 border-2 border-primary/20 border-t-primary rounded-full animate-spin" />
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={() => handleDelete(file)}
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
- {/* Image preview modal */}
323
- <PreviewModal
324
- file={previewFile}
325
- imageUrl={previewFile ? getUrl(previewFile) : null}
326
- onClose={() => setPreviewFile(null)}
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
  )