@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.
@@ -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 "@/components/ui/button"
41
+ import { Card, CardContent } from "@/components/ui/card"
42
+ import {
43
+ AlertDialog,
44
+ AlertDialogContent,
45
+ AlertDialogFooter,
46
+ AlertDialogHeader,
47
+ AlertDialogTitle,
48
+ } from "@/components/ui/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