@traky-app/ui 1.6.7 → 1.6.8
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/README.md +1 -1
- package/RELEASE.md +8 -2
- package/package.json +1 -1
- package/registry/traky/json/file-manager.json +25 -0
- package/registry/traky/json/lib-file-items.json +14 -0
- package/registry/traky/json/lib-file-manager-utils.json +14 -0
- package/registry/traky/lib/file-items.ts +99 -0
- package/registry/traky/lib/file-manager-utils.ts +116 -0
- package/registry/traky/registry.json +30 -0
- package/registry/traky/ui/file-manager.tsx +646 -0
- package/src/lib/file-items.ts +99 -0
- package/src/lib/file-manager-utils.test.tsx +71 -0
- package/src/lib/file-manager-utils.ts +116 -0
- package/src/ui/file-manager.tsx +646 -0
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* FileManager — CRUD de archivos con almacenamiento inyectado.
|
|
5
|
+
* El host pasa `uploadFile` / `deleteFile`.
|
|
6
|
+
*/
|
|
7
|
+
import { useCallback, useEffect, useId, useRef, useState } from "react"
|
|
8
|
+
import {
|
|
9
|
+
IconDownload,
|
|
10
|
+
IconFileTypePdf,
|
|
11
|
+
IconLoader2,
|
|
12
|
+
IconPhoto,
|
|
13
|
+
IconPlus,
|
|
14
|
+
IconRefresh,
|
|
15
|
+
IconUpload,
|
|
16
|
+
IconVideo,
|
|
17
|
+
IconX,
|
|
18
|
+
} from "@tabler/icons-react"
|
|
19
|
+
import { toast } from "sonner"
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
fileListSignature,
|
|
23
|
+
normalizeFileItems,
|
|
24
|
+
type FileItem,
|
|
25
|
+
type FileStorageProvider,
|
|
26
|
+
} from "../lib/file-items"
|
|
27
|
+
import { cn } from "../lib/utils"
|
|
28
|
+
import {
|
|
29
|
+
ACCEPTED_TYPES,
|
|
30
|
+
ACCEPTED_TYPES_WITH_VIDEO,
|
|
31
|
+
downloadFile,
|
|
32
|
+
getFileDisplayName,
|
|
33
|
+
getFileFullName,
|
|
34
|
+
isAcceptedFile,
|
|
35
|
+
isImageFile,
|
|
36
|
+
isPdfFile,
|
|
37
|
+
isVideoFile,
|
|
38
|
+
toFileItem,
|
|
39
|
+
} from "../lib/file-manager-utils"
|
|
40
|
+
import { Button } from "./button"
|
|
41
|
+
import { Card, CardContent } from "./card"
|
|
42
|
+
import {
|
|
43
|
+
AlertDialog,
|
|
44
|
+
AlertDialogContent,
|
|
45
|
+
AlertDialogFooter,
|
|
46
|
+
AlertDialogHeader,
|
|
47
|
+
AlertDialogTitle,
|
|
48
|
+
} from "./alert-dialog"
|
|
49
|
+
|
|
50
|
+
export type { FileItem, FileStorageProvider } from "../lib/file-items"
|
|
51
|
+
export {
|
|
52
|
+
normalizeFileItem,
|
|
53
|
+
normalizeFileItems,
|
|
54
|
+
fileListSignature,
|
|
55
|
+
} from "../lib/file-items"
|
|
56
|
+
|
|
57
|
+
export type FileManagerUploadResult = {
|
|
58
|
+
url: string
|
|
59
|
+
message?: string
|
|
60
|
+
provider?: FileStorageProvider
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type FileManagerDeleteResult = {
|
|
64
|
+
ok: boolean
|
|
65
|
+
message?: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type FileManagerProps = {
|
|
69
|
+
value?: FileItem[]
|
|
70
|
+
defaultValue?: FileItem[] | unknown[]
|
|
71
|
+
onChange?: (value: FileItem[]) => void
|
|
72
|
+
/** Create / replace: sube y devuelve URL pública. */
|
|
73
|
+
uploadFile?: (file: File) => Promise<FileManagerUploadResult>
|
|
74
|
+
/** Delete: borra el objeto remoto. URLs externas pueden devolver ok. */
|
|
75
|
+
deleteFile?: (url: string) => Promise<FileManagerDeleteResult>
|
|
76
|
+
className?: string
|
|
77
|
+
acceptVideos?: boolean
|
|
78
|
+
readOnly?: boolean
|
|
79
|
+
/** Cuadrícula más chica: el alta no ocupa todo el ancho. */
|
|
80
|
+
compact?: boolean
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function AddFileCard({
|
|
84
|
+
disabled,
|
|
85
|
+
dragActive,
|
|
86
|
+
onBrowse,
|
|
87
|
+
fullWidth = false,
|
|
88
|
+
}: {
|
|
89
|
+
disabled: boolean
|
|
90
|
+
dragActive: boolean
|
|
91
|
+
onBrowse: () => void
|
|
92
|
+
fullWidth?: boolean
|
|
93
|
+
}) {
|
|
94
|
+
return (
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
disabled={disabled}
|
|
98
|
+
onClick={onBrowse}
|
|
99
|
+
className={cn(
|
|
100
|
+
"flex w-full flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed text-center transition-colors",
|
|
101
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
102
|
+
fullWidth ? "min-h-[148px] gap-2.5 px-4 py-8" : "aspect-square gap-2 p-3",
|
|
103
|
+
dragActive
|
|
104
|
+
? "border-primary bg-primary/10"
|
|
105
|
+
: "border-border bg-muted/30 hover:border-primary/50 hover:bg-muted/50",
|
|
106
|
+
disabled && "cursor-not-allowed opacity-60",
|
|
107
|
+
)}
|
|
108
|
+
aria-label="Agregar imágenes o PDF"
|
|
109
|
+
>
|
|
110
|
+
{disabled ? (
|
|
111
|
+
<IconLoader2
|
|
112
|
+
className={cn(
|
|
113
|
+
"animate-spin text-primary",
|
|
114
|
+
fullWidth ? "size-8" : "size-7",
|
|
115
|
+
)}
|
|
116
|
+
aria-hidden
|
|
117
|
+
/>
|
|
118
|
+
) : (
|
|
119
|
+
<div
|
|
120
|
+
className={cn(
|
|
121
|
+
"flex items-center justify-center rounded-xl",
|
|
122
|
+
fullWidth ? "size-12" : "size-10",
|
|
123
|
+
dragActive
|
|
124
|
+
? "bg-primary/20 text-primary"
|
|
125
|
+
: "bg-muted text-muted-foreground",
|
|
126
|
+
)}
|
|
127
|
+
>
|
|
128
|
+
<IconPlus
|
|
129
|
+
className={fullWidth ? "size-6" : "size-5"}
|
|
130
|
+
stroke={1.75}
|
|
131
|
+
aria-hidden
|
|
132
|
+
/>
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
<div className="space-y-0.5 px-1">
|
|
136
|
+
<p
|
|
137
|
+
className={cn(
|
|
138
|
+
"font-semibold text-foreground",
|
|
139
|
+
fullWidth ? "text-sm" : "text-xs",
|
|
140
|
+
)}
|
|
141
|
+
>
|
|
142
|
+
{disabled ? "Subiendo…" : "Agregar"}
|
|
143
|
+
</p>
|
|
144
|
+
<p
|
|
145
|
+
className={cn(
|
|
146
|
+
"leading-snug text-muted-foreground",
|
|
147
|
+
fullWidth ? "text-xs" : "text-[10px]",
|
|
148
|
+
)}
|
|
149
|
+
>
|
|
150
|
+
Arrastra o haz clic para subir
|
|
151
|
+
</p>
|
|
152
|
+
</div>
|
|
153
|
+
<span
|
|
154
|
+
className={cn(
|
|
155
|
+
"inline-flex items-center gap-1 rounded-full border border-border bg-background/80 font-medium text-muted-foreground",
|
|
156
|
+
fullWidth ? "px-2.5 py-1 text-[10px]" : "px-2 py-0.5 text-[9px]",
|
|
157
|
+
)}
|
|
158
|
+
>
|
|
159
|
+
<IconUpload className={fullWidth ? "size-3" : "size-2.5"} aria-hidden />
|
|
160
|
+
IMG · PDF
|
|
161
|
+
</span>
|
|
162
|
+
</button>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const EMPTY_FILES: FileItem[] = []
|
|
167
|
+
|
|
168
|
+
function FileManager({
|
|
169
|
+
value,
|
|
170
|
+
defaultValue = EMPTY_FILES,
|
|
171
|
+
onChange,
|
|
172
|
+
uploadFile,
|
|
173
|
+
deleteFile,
|
|
174
|
+
className,
|
|
175
|
+
acceptVideos = false,
|
|
176
|
+
readOnly = false,
|
|
177
|
+
compact = false,
|
|
178
|
+
}: FileManagerProps) {
|
|
179
|
+
const inputId = useId()
|
|
180
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
181
|
+
const replaceInputRef = useRef<HTMLInputElement>(null)
|
|
182
|
+
const replaceIndexRef = useRef<number | null>(null)
|
|
183
|
+
const onChangeRef = useRef(onChange)
|
|
184
|
+
const uploadFileRef = useRef(uploadFile)
|
|
185
|
+
const deleteFileRef = useRef(deleteFile)
|
|
186
|
+
const [internalFiles, setInternalFiles] = useState<FileItem[]>(() =>
|
|
187
|
+
normalizeFileItems(defaultValue),
|
|
188
|
+
)
|
|
189
|
+
const [previewImage, setPreviewImage] = useState<FileItem | null>(null)
|
|
190
|
+
const [uploading, setUploading] = useState(false)
|
|
191
|
+
const [busyIndex, setBusyIndex] = useState<number | null>(null)
|
|
192
|
+
const [dragActive, setDragActive] = useState(false)
|
|
193
|
+
const dragCounter = useRef(0)
|
|
194
|
+
const filesSignatureRef = useRef(fileListSignature(value ?? defaultValue))
|
|
195
|
+
|
|
196
|
+
const isControlled = value !== undefined
|
|
197
|
+
const files = isControlled ? normalizeFileItems(value) : internalFiles
|
|
198
|
+
|
|
199
|
+
onChangeRef.current = onChange
|
|
200
|
+
uploadFileRef.current = uploadFile
|
|
201
|
+
deleteFileRef.current = deleteFile
|
|
202
|
+
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
if (isControlled) return
|
|
205
|
+
const next = normalizeFileItems(defaultValue)
|
|
206
|
+
const nextSignature = fileListSignature(next)
|
|
207
|
+
if (nextSignature === filesSignatureRef.current) return
|
|
208
|
+
filesSignatureRef.current = nextSignature
|
|
209
|
+
setInternalFiles(next)
|
|
210
|
+
}, [defaultValue, isControlled])
|
|
211
|
+
|
|
212
|
+
const commit = useCallback(
|
|
213
|
+
(next: FileItem[]) => {
|
|
214
|
+
filesSignatureRef.current = fileListSignature(next)
|
|
215
|
+
if (!isControlled) setInternalFiles(next)
|
|
216
|
+
if (!readOnly) onChangeRef.current?.(next)
|
|
217
|
+
},
|
|
218
|
+
[isControlled, readOnly],
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
const openFilePicker = useCallback(() => {
|
|
222
|
+
if (uploading || busyIndex !== null) return
|
|
223
|
+
inputRef.current?.click()
|
|
224
|
+
}, [uploading, busyIndex])
|
|
225
|
+
|
|
226
|
+
const processFiles = useCallback(
|
|
227
|
+
async (fileList: FileList | File[]) => {
|
|
228
|
+
const selected = Array.from(fileList).filter((file) =>
|
|
229
|
+
isAcceptedFile(file, acceptVideos),
|
|
230
|
+
)
|
|
231
|
+
if (selected.length === 0) {
|
|
232
|
+
toast.error(
|
|
233
|
+
acceptVideos
|
|
234
|
+
? "Solo se permiten imágenes, PDF o videos (MP4, WEBM)"
|
|
235
|
+
: "Solo se permiten imágenes (PNG, JPG, WEBP) o PDF",
|
|
236
|
+
)
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const runUpload = uploadFileRef.current
|
|
241
|
+
if (!runUpload) {
|
|
242
|
+
toast.error("La subida de archivos no está configurada")
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
setUploading(true)
|
|
247
|
+
const uploaded: FileItem[] = []
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
for (const file of selected) {
|
|
251
|
+
const res = await runUpload(file)
|
|
252
|
+
if (res.url) {
|
|
253
|
+
uploaded.push(toFileItem(file, res.url, res.provider))
|
|
254
|
+
} else {
|
|
255
|
+
toast.error(res.message || `No se pudo subir ${file.name}`)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (uploaded.length > 0) {
|
|
260
|
+
commit([...files, ...uploaded])
|
|
261
|
+
toast.success(
|
|
262
|
+
uploaded.length === 1
|
|
263
|
+
? "Archivo agregado"
|
|
264
|
+
: `${uploaded.length} archivos agregados`,
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
} catch {
|
|
268
|
+
toast.error("Error al subir archivos")
|
|
269
|
+
} finally {
|
|
270
|
+
setUploading(false)
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
[acceptVideos, commit, files],
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
const handleInputChange = async (
|
|
277
|
+
event: React.ChangeEvent<HTMLInputElement>,
|
|
278
|
+
) => {
|
|
279
|
+
const inputFiles = event.target.files
|
|
280
|
+
if (!inputFiles?.length) return
|
|
281
|
+
await processFiles(inputFiles)
|
|
282
|
+
event.target.value = ""
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const handleReplaceChange = async (
|
|
286
|
+
event: React.ChangeEvent<HTMLInputElement>,
|
|
287
|
+
) => {
|
|
288
|
+
const nextFile = event.target.files?.[0]
|
|
289
|
+
const index = replaceIndexRef.current
|
|
290
|
+
event.target.value = ""
|
|
291
|
+
replaceIndexRef.current = null
|
|
292
|
+
if (!nextFile || index == null) return
|
|
293
|
+
if (!isAcceptedFile(nextFile, acceptVideos)) {
|
|
294
|
+
toast.error(
|
|
295
|
+
acceptVideos
|
|
296
|
+
? "Solo se permiten imágenes, PDF o videos (MP4, WEBM)"
|
|
297
|
+
: "Solo se permiten imágenes (PNG, JPG, WEBP) o PDF",
|
|
298
|
+
)
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const runUpload = uploadFileRef.current
|
|
303
|
+
if (!runUpload) {
|
|
304
|
+
toast.error("La subida de archivos no está configurada")
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const previous = files[index]
|
|
309
|
+
setBusyIndex(index)
|
|
310
|
+
try {
|
|
311
|
+
const res = await runUpload(nextFile)
|
|
312
|
+
if (!res.url) {
|
|
313
|
+
toast.error(res.message || `No se pudo reemplazar ${nextFile.name}`)
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const next = files.map((item, i) =>
|
|
318
|
+
i === index ? toFileItem(nextFile, res.url, res.provider) : item,
|
|
319
|
+
)
|
|
320
|
+
commit(next)
|
|
321
|
+
|
|
322
|
+
if (previous?.url && previous.url !== res.url && deleteFileRef.current) {
|
|
323
|
+
const removed = await deleteFileRef.current(previous.url)
|
|
324
|
+
if (!removed.ok) {
|
|
325
|
+
toast.error(
|
|
326
|
+
removed.message ||
|
|
327
|
+
"El archivo nuevo se subió, pero el anterior no se borró",
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
toast.success("Archivo actualizado")
|
|
332
|
+
} catch {
|
|
333
|
+
toast.error("Error al actualizar el archivo")
|
|
334
|
+
} finally {
|
|
335
|
+
setBusyIndex(null)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const handleDragEnter = (event: React.DragEvent) => {
|
|
340
|
+
event.preventDefault()
|
|
341
|
+
event.stopPropagation()
|
|
342
|
+
dragCounter.current += 1
|
|
343
|
+
setDragActive(true)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const handleDragLeave = (event: React.DragEvent) => {
|
|
347
|
+
event.preventDefault()
|
|
348
|
+
event.stopPropagation()
|
|
349
|
+
dragCounter.current -= 1
|
|
350
|
+
if (dragCounter.current <= 0) {
|
|
351
|
+
dragCounter.current = 0
|
|
352
|
+
setDragActive(false)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const handleDragOver = (event: React.DragEvent) => {
|
|
357
|
+
event.preventDefault()
|
|
358
|
+
event.stopPropagation()
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const handleDrop = async (event: React.DragEvent) => {
|
|
362
|
+
event.preventDefault()
|
|
363
|
+
event.stopPropagation()
|
|
364
|
+
dragCounter.current = 0
|
|
365
|
+
setDragActive(false)
|
|
366
|
+
if (uploading || busyIndex !== null) return
|
|
367
|
+
const dropped = event.dataTransfer.files
|
|
368
|
+
if (!dropped?.length) return
|
|
369
|
+
await processFiles(dropped)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const handleRemove = async (index: number) => {
|
|
373
|
+
const target = files[index]
|
|
374
|
+
if (!target) return
|
|
375
|
+
setBusyIndex(index)
|
|
376
|
+
try {
|
|
377
|
+
if (target.url && deleteFileRef.current) {
|
|
378
|
+
const removed = await deleteFileRef.current(target.url)
|
|
379
|
+
if (!removed.ok) {
|
|
380
|
+
toast.error(removed.message || "No se pudo borrar el archivo")
|
|
381
|
+
return
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
commit(files.filter((_, i) => i !== index))
|
|
385
|
+
toast.success("Archivo eliminado")
|
|
386
|
+
} catch {
|
|
387
|
+
toast.error("Error al borrar el archivo")
|
|
388
|
+
} finally {
|
|
389
|
+
setBusyIndex(null)
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const handleOpenFile = (file: FileItem) => {
|
|
394
|
+
if (isImageFile(file)) {
|
|
395
|
+
setPreviewImage(file)
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
if (isPdfFile(file)) {
|
|
399
|
+
downloadFile(file)
|
|
400
|
+
return
|
|
401
|
+
}
|
|
402
|
+
window.open(file.url, "_blank", "noopener,noreferrer")
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const locked = uploading || busyIndex !== null
|
|
406
|
+
|
|
407
|
+
return (
|
|
408
|
+
<div data-slot="file-manager" className={cn("space-y-3", className)}>
|
|
409
|
+
{!readOnly ? (
|
|
410
|
+
<>
|
|
411
|
+
<input
|
|
412
|
+
ref={inputRef}
|
|
413
|
+
id={inputId}
|
|
414
|
+
type="file"
|
|
415
|
+
accept={acceptVideos ? ACCEPTED_TYPES_WITH_VIDEO : ACCEPTED_TYPES}
|
|
416
|
+
className="sr-only"
|
|
417
|
+
multiple
|
|
418
|
+
disabled={locked}
|
|
419
|
+
onChange={(e) => void handleInputChange(e)}
|
|
420
|
+
/>
|
|
421
|
+
<input
|
|
422
|
+
ref={replaceInputRef}
|
|
423
|
+
type="file"
|
|
424
|
+
accept={acceptVideos ? ACCEPTED_TYPES_WITH_VIDEO : ACCEPTED_TYPES}
|
|
425
|
+
className="sr-only"
|
|
426
|
+
disabled={locked}
|
|
427
|
+
onChange={(e) => void handleReplaceChange(e)}
|
|
428
|
+
/>
|
|
429
|
+
</>
|
|
430
|
+
) : null}
|
|
431
|
+
|
|
432
|
+
<div
|
|
433
|
+
className={cn(
|
|
434
|
+
"rounded-xl transition-shadow",
|
|
435
|
+
!readOnly &&
|
|
436
|
+
dragActive &&
|
|
437
|
+
"ring-2 ring-primary ring-offset-2 ring-offset-background",
|
|
438
|
+
)}
|
|
439
|
+
onDragEnter={readOnly ? undefined : handleDragEnter}
|
|
440
|
+
onDragLeave={readOnly ? undefined : handleDragLeave}
|
|
441
|
+
onDragOver={readOnly ? undefined : handleDragOver}
|
|
442
|
+
onDrop={readOnly ? undefined : (e) => void handleDrop(e)}
|
|
443
|
+
>
|
|
444
|
+
{!readOnly && files.length === 0 ? (
|
|
445
|
+
compact ? (
|
|
446
|
+
<div className="grid max-w-sm grid-cols-4 gap-2">
|
|
447
|
+
<AddFileCard
|
|
448
|
+
disabled={locked}
|
|
449
|
+
dragActive={dragActive}
|
|
450
|
+
onBrowse={openFilePicker}
|
|
451
|
+
/>
|
|
452
|
+
</div>
|
|
453
|
+
) : (
|
|
454
|
+
<AddFileCard
|
|
455
|
+
fullWidth
|
|
456
|
+
disabled={locked}
|
|
457
|
+
dragActive={dragActive}
|
|
458
|
+
onBrowse={openFilePicker}
|
|
459
|
+
/>
|
|
460
|
+
)
|
|
461
|
+
) : null}
|
|
462
|
+
|
|
463
|
+
{files.length > 0 || readOnly ? (
|
|
464
|
+
<div
|
|
465
|
+
className={cn(
|
|
466
|
+
"grid gap-2",
|
|
467
|
+
compact
|
|
468
|
+
? "max-w-lg grid-cols-4 sm:grid-cols-6"
|
|
469
|
+
: "grid-cols-2 gap-3 sm:grid-cols-3",
|
|
470
|
+
)}
|
|
471
|
+
>
|
|
472
|
+
{!readOnly ? (
|
|
473
|
+
<AddFileCard
|
|
474
|
+
disabled={locked}
|
|
475
|
+
dragActive={dragActive}
|
|
476
|
+
onBrowse={openFilePicker}
|
|
477
|
+
/>
|
|
478
|
+
) : null}
|
|
479
|
+
|
|
480
|
+
{files.map((file, index) => {
|
|
481
|
+
const image = isImageFile(file)
|
|
482
|
+
const pdf = isPdfFile(file)
|
|
483
|
+
const video = isVideoFile(file)
|
|
484
|
+
const displayName = getFileDisplayName(file)
|
|
485
|
+
const itemBusy = busyIndex === index
|
|
486
|
+
|
|
487
|
+
return (
|
|
488
|
+
<Card
|
|
489
|
+
key={`${file.url}-${index}`}
|
|
490
|
+
className="group relative cursor-pointer overflow-hidden border-border bg-card py-0 shadow-sm transition-colors hover:border-primary/40"
|
|
491
|
+
onClick={() => handleOpenFile(file)}
|
|
492
|
+
>
|
|
493
|
+
{!readOnly ? (
|
|
494
|
+
<div className="absolute top-2 right-2 z-10 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
|
495
|
+
<button
|
|
496
|
+
type="button"
|
|
497
|
+
className="flex size-6 items-center justify-center rounded-full bg-background/90 text-foreground shadow-sm"
|
|
498
|
+
aria-label={`Reemplazar ${file.name}`}
|
|
499
|
+
disabled={locked}
|
|
500
|
+
onClick={(e) => {
|
|
501
|
+
e.stopPropagation()
|
|
502
|
+
if (locked) return
|
|
503
|
+
replaceIndexRef.current = index
|
|
504
|
+
replaceInputRef.current?.click()
|
|
505
|
+
}}
|
|
506
|
+
>
|
|
507
|
+
{itemBusy ? (
|
|
508
|
+
<IconLoader2 className="size-3.5 animate-spin" />
|
|
509
|
+
) : (
|
|
510
|
+
<IconRefresh className="size-3.5" />
|
|
511
|
+
)}
|
|
512
|
+
</button>
|
|
513
|
+
<button
|
|
514
|
+
type="button"
|
|
515
|
+
className="flex size-6 items-center justify-center rounded-full bg-destructive text-destructive-foreground shadow-sm"
|
|
516
|
+
aria-label={`Eliminar ${file.name}`}
|
|
517
|
+
disabled={locked}
|
|
518
|
+
onClick={(e) => {
|
|
519
|
+
e.stopPropagation()
|
|
520
|
+
void handleRemove(index)
|
|
521
|
+
}}
|
|
522
|
+
>
|
|
523
|
+
{itemBusy ? (
|
|
524
|
+
<IconLoader2 className="size-3.5 animate-spin" />
|
|
525
|
+
) : (
|
|
526
|
+
<IconX className="size-3.5" />
|
|
527
|
+
)}
|
|
528
|
+
</button>
|
|
529
|
+
</div>
|
|
530
|
+
) : null}
|
|
531
|
+
|
|
532
|
+
<div className="relative aspect-square w-full overflow-hidden bg-muted/40">
|
|
533
|
+
{image ? (
|
|
534
|
+
<img
|
|
535
|
+
src={file.url}
|
|
536
|
+
alt={file.name}
|
|
537
|
+
className="size-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
|
|
538
|
+
/>
|
|
539
|
+
) : pdf ? (
|
|
540
|
+
<div className="flex h-full flex-col items-center justify-center gap-1.5 bg-gradient-to-b from-muted/30 to-muted/60">
|
|
541
|
+
<IconFileTypePdf
|
|
542
|
+
className="size-12 text-red-600 dark:text-red-400"
|
|
543
|
+
stroke={1.1}
|
|
544
|
+
/>
|
|
545
|
+
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
546
|
+
PDF
|
|
547
|
+
</span>
|
|
548
|
+
</div>
|
|
549
|
+
) : video ? (
|
|
550
|
+
<div className="flex h-full flex-col items-center justify-center gap-1.5 bg-gradient-to-b from-muted/30 to-muted/60">
|
|
551
|
+
<IconVideo
|
|
552
|
+
className="size-12 text-primary"
|
|
553
|
+
stroke={1.1}
|
|
554
|
+
/>
|
|
555
|
+
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
556
|
+
Video
|
|
557
|
+
</span>
|
|
558
|
+
</div>
|
|
559
|
+
) : (
|
|
560
|
+
<div className="flex h-full flex-col items-center justify-center gap-1 text-muted-foreground">
|
|
561
|
+
<IconPhoto className="size-8" stroke={1.25} />
|
|
562
|
+
<span className="text-xs uppercase">
|
|
563
|
+
{(file.name ?? "").split(".").pop() || "file"}
|
|
564
|
+
</span>
|
|
565
|
+
</div>
|
|
566
|
+
)}
|
|
567
|
+
|
|
568
|
+
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/75 via-black/25 to-transparent px-2 pt-8 pb-2">
|
|
569
|
+
<p
|
|
570
|
+
className="line-clamp-2 text-[11px] leading-tight font-medium text-white"
|
|
571
|
+
title={getFileFullName(file)}
|
|
572
|
+
>
|
|
573
|
+
{displayName}
|
|
574
|
+
</p>
|
|
575
|
+
<p className="mt-0.5 text-[9px] text-white/75">
|
|
576
|
+
{image
|
|
577
|
+
? "Clic para ampliar"
|
|
578
|
+
: pdf
|
|
579
|
+
? "Clic para descargar"
|
|
580
|
+
: video
|
|
581
|
+
? "Clic para reproducir"
|
|
582
|
+
: "Clic para abrir"}
|
|
583
|
+
</p>
|
|
584
|
+
</div>
|
|
585
|
+
</div>
|
|
586
|
+
|
|
587
|
+
<CardContent className="sr-only">
|
|
588
|
+
<span>{file.name}</span>
|
|
589
|
+
</CardContent>
|
|
590
|
+
</Card>
|
|
591
|
+
)
|
|
592
|
+
})}
|
|
593
|
+
</div>
|
|
594
|
+
) : null}
|
|
595
|
+
</div>
|
|
596
|
+
|
|
597
|
+
<AlertDialog
|
|
598
|
+
open={!!previewImage}
|
|
599
|
+
onOpenChange={(open) => !open && setPreviewImage(null)}
|
|
600
|
+
>
|
|
601
|
+
<AlertDialogContent className="flex max-h-[min(88vh,720px)] w-[min(92vw,640px)] max-w-[min(92vw,640px)] flex-col gap-0 overflow-hidden border-border p-0">
|
|
602
|
+
<AlertDialogHeader className="shrink-0 border-b border-border px-4 py-3 text-left">
|
|
603
|
+
<AlertDialogTitle
|
|
604
|
+
className="block max-w-full truncate pr-8 text-base"
|
|
605
|
+
title={previewImage ? getFileFullName(previewImage) : undefined}
|
|
606
|
+
>
|
|
607
|
+
{previewImage ? getFileDisplayName(previewImage, 56) : ""}
|
|
608
|
+
</AlertDialogTitle>
|
|
609
|
+
</AlertDialogHeader>
|
|
610
|
+
|
|
611
|
+
<div className="flex min-h-0 flex-1 items-center justify-center overflow-auto bg-muted/30 px-4 py-3">
|
|
612
|
+
{previewImage ? (
|
|
613
|
+
<img
|
|
614
|
+
src={previewImage.url}
|
|
615
|
+
alt={getFileDisplayName(previewImage, 80)}
|
|
616
|
+
className="max-h-[min(55vh,480px)] w-auto max-w-full rounded-lg object-contain"
|
|
617
|
+
/>
|
|
618
|
+
) : null}
|
|
619
|
+
</div>
|
|
620
|
+
|
|
621
|
+
<AlertDialogFooter className="shrink-0 border-t border-border px-4 py-3 sm:justify-between">
|
|
622
|
+
{previewImage ? (
|
|
623
|
+
<Button
|
|
624
|
+
type="button"
|
|
625
|
+
variant="outline"
|
|
626
|
+
className="gap-2"
|
|
627
|
+
onClick={() => downloadFile(previewImage)}
|
|
628
|
+
>
|
|
629
|
+
<IconDownload className="size-4" />
|
|
630
|
+
Descargar
|
|
631
|
+
</Button>
|
|
632
|
+
) : (
|
|
633
|
+
<span />
|
|
634
|
+
)}
|
|
635
|
+
<Button type="button" onClick={() => setPreviewImage(null)}>
|
|
636
|
+
Cerrar
|
|
637
|
+
</Button>
|
|
638
|
+
</AlertDialogFooter>
|
|
639
|
+
</AlertDialogContent>
|
|
640
|
+
</AlertDialog>
|
|
641
|
+
</div>
|
|
642
|
+
)
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
export { FileManager }
|
|
646
|
+
export default FileManager
|