@traky-app/ui 1.6.6 → 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 +3 -1
- package/RELEASE.md +27 -0
- package/package.json +3 -2
- package/registry/traky/json/file-manager.json +25 -0
- package/registry/traky/json/internal-chat-panel.json +2 -1
- 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/registry/traky/ui/internal-chat-panel.tsx +44 -6
- 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
- package/src/ui/internal-chat-panel.tsx +44 -6
package/README.md
CHANGED
|
@@ -19,6 +19,8 @@ registry/traky/
|
|
|
19
19
|
pnpm registry:build
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
Publicación: release
|
|
22
|
+
Publicación: `pnpm release:ui` (sync + tests) y luego release GitHub tag `vX.Y.Z` o `pnpm publish:ui` en local. Ver [RELEASE.md](./RELEASE.md).
|
|
23
23
|
|
|
24
24
|
Guía completa: [REGISTRY.md](../../REGISTRY.md) en la raíz del monorepo.
|
|
25
|
+
|
|
26
|
+
**Versión actual del paquete:** `1.6.8` (`FileManager` + chat `realtimeStatus` / `featureEnabled` / `embedded`).
|
package/RELEASE.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Release `@traky-app/ui`
|
|
2
|
+
|
|
3
|
+
## 1.6.8 (pendiente de publicar)
|
|
4
|
+
|
|
5
|
+
- `FileManager`: galería CRUD de imágenes/PDF con `uploadFile` / `deleteFile` inyectados.
|
|
6
|
+
- Libs: `file-items`, `file-manager-utils`.
|
|
7
|
+
- Registro `file-manager.json` regenerado (`pnpm registry:sync`).
|
|
8
|
+
|
|
9
|
+
## 1.6.7 (pendiente de publicar)
|
|
10
|
+
|
|
11
|
+
- `InternalChatPanel`: prop `realtimeStatus` (`connected` \| `reconnecting` \| `offline`) — skeleton al reconectar y mensaje offline.
|
|
12
|
+
- `InternalChatPanel`: prop `featureEnabled` (default `true`) — no monta trigger ni drawer cuando el host apaga el chat.
|
|
13
|
+
- Drawer: `embedded` + `modal={false}` para páginas dedicadas admin (sin overlay bloqueante).
|
|
14
|
+
- Registro `internal-chat-panel.json` regenerado (`pnpm registry:sync`).
|
|
15
|
+
|
|
16
|
+
## Publicar
|
|
17
|
+
|
|
18
|
+
1. `pnpm registry:sync && pnpm --filter @traky-app/ui test`
|
|
19
|
+
2. Confirma `version` en `package.json` (debe coincidir con el tag).
|
|
20
|
+
3. **GitHub (recomendado):** tag `v1.6.8` + release publicado → workflow `publish.yml`.
|
|
21
|
+
4. **Manual:** `cd packages/ui && npm publish --access public` (token / 2FA).
|
|
22
|
+
|
|
23
|
+
Después en **app-traky**:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pnpm add @traky-app/ui@1.6.8 --save-exact
|
|
27
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@traky-app/ui",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.8",
|
|
4
4
|
"description": "Componentes Traky UI para importar desde @traky-app/ui, más el registro copy-paste.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"src",
|
|
9
9
|
"registry",
|
|
10
10
|
"schema",
|
|
11
|
-
"README.md"
|
|
11
|
+
"README.md",
|
|
12
|
+
"RELEASE.md"
|
|
12
13
|
],
|
|
13
14
|
"exports": {
|
|
14
15
|
"./registry/*": "./registry/*",
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "file-manager",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"author": "Traky (https://traky.app)",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"@tabler/icons-react",
|
|
8
|
+
"sonner"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"alert-dialog",
|
|
12
|
+
"button",
|
|
13
|
+
"card",
|
|
14
|
+
"lib-file-items",
|
|
15
|
+
"lib-file-manager-utils"
|
|
16
|
+
],
|
|
17
|
+
"files": [
|
|
18
|
+
{
|
|
19
|
+
"path": "ui/file-manager.tsx",
|
|
20
|
+
"content": "\"use client\"\n\n/**\n * FileManager — CRUD de archivos con almacenamiento inyectado.\n * El host pasa `uploadFile` / `deleteFile`.\n */\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\"\nimport {\n IconDownload,\n IconFileTypePdf,\n IconLoader2,\n IconPhoto,\n IconPlus,\n IconRefresh,\n IconUpload,\n IconVideo,\n IconX,\n} from \"@tabler/icons-react\"\nimport { toast } from \"sonner\"\n\nimport {\n fileListSignature,\n normalizeFileItems,\n type FileItem,\n type FileStorageProvider,\n} from \"@/lib/file-items\"\nimport { cn } from \"@/lib/utils\"\nimport {\n ACCEPTED_TYPES,\n ACCEPTED_TYPES_WITH_VIDEO,\n downloadFile,\n getFileDisplayName,\n getFileFullName,\n isAcceptedFile,\n isImageFile,\n isPdfFile,\n isVideoFile,\n toFileItem,\n} from \"@/lib/file-manager-utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { Card, CardContent } from \"@/components/ui/card\"\nimport {\n AlertDialog,\n AlertDialogContent,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\"\n\nexport type { FileItem, FileStorageProvider } from \"@/lib/file-items\"\nexport {\n normalizeFileItem,\n normalizeFileItems,\n fileListSignature,\n} from \"@/lib/file-items\"\n\nexport type FileManagerUploadResult = {\n url: string\n message?: string\n provider?: FileStorageProvider\n}\n\nexport type FileManagerDeleteResult = {\n ok: boolean\n message?: string\n}\n\nexport type FileManagerProps = {\n value?: FileItem[]\n defaultValue?: FileItem[] | unknown[]\n onChange?: (value: FileItem[]) => void\n /** Create / replace: sube y devuelve URL pública. */\n uploadFile?: (file: File) => Promise<FileManagerUploadResult>\n /** Delete: borra el objeto remoto. URLs externas pueden devolver ok. */\n deleteFile?: (url: string) => Promise<FileManagerDeleteResult>\n className?: string\n acceptVideos?: boolean\n readOnly?: boolean\n /** Cuadrícula más chica: el alta no ocupa todo el ancho. */\n compact?: boolean\n}\n\nfunction AddFileCard({\n disabled,\n dragActive,\n onBrowse,\n fullWidth = false,\n}: {\n disabled: boolean\n dragActive: boolean\n onBrowse: () => void\n fullWidth?: boolean\n}) {\n return (\n <button\n type=\"button\"\n disabled={disabled}\n onClick={onBrowse}\n className={cn(\n \"flex w-full flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed text-center transition-colors\",\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n fullWidth ? \"min-h-[148px] gap-2.5 px-4 py-8\" : \"aspect-square gap-2 p-3\",\n dragActive\n ? \"border-primary bg-primary/10\"\n : \"border-border bg-muted/30 hover:border-primary/50 hover:bg-muted/50\",\n disabled && \"cursor-not-allowed opacity-60\",\n )}\n aria-label=\"Agregar imágenes o PDF\"\n >\n {disabled ? (\n <IconLoader2\n className={cn(\n \"animate-spin text-primary\",\n fullWidth ? \"size-8\" : \"size-7\",\n )}\n aria-hidden\n />\n ) : (\n <div\n className={cn(\n \"flex items-center justify-center rounded-xl\",\n fullWidth ? \"size-12\" : \"size-10\",\n dragActive\n ? \"bg-primary/20 text-primary\"\n : \"bg-muted text-muted-foreground\",\n )}\n >\n <IconPlus\n className={fullWidth ? \"size-6\" : \"size-5\"}\n stroke={1.75}\n aria-hidden\n />\n </div>\n )}\n <div className=\"space-y-0.5 px-1\">\n <p\n className={cn(\n \"font-semibold text-foreground\",\n fullWidth ? \"text-sm\" : \"text-xs\",\n )}\n >\n {disabled ? \"Subiendo…\" : \"Agregar\"}\n </p>\n <p\n className={cn(\n \"leading-snug text-muted-foreground\",\n fullWidth ? \"text-xs\" : \"text-[10px]\",\n )}\n >\n Arrastra o haz clic para subir\n </p>\n </div>\n <span\n className={cn(\n \"inline-flex items-center gap-1 rounded-full border border-border bg-background/80 font-medium text-muted-foreground\",\n fullWidth ? \"px-2.5 py-1 text-[10px]\" : \"px-2 py-0.5 text-[9px]\",\n )}\n >\n <IconUpload className={fullWidth ? \"size-3\" : \"size-2.5\"} aria-hidden />\n IMG · PDF\n </span>\n </button>\n )\n}\n\nconst EMPTY_FILES: FileItem[] = []\n\nfunction FileManager({\n value,\n defaultValue = EMPTY_FILES,\n onChange,\n uploadFile,\n deleteFile,\n className,\n acceptVideos = false,\n readOnly = false,\n compact = false,\n}: FileManagerProps) {\n const inputId = useId()\n const inputRef = useRef<HTMLInputElement>(null)\n const replaceInputRef = useRef<HTMLInputElement>(null)\n const replaceIndexRef = useRef<number | null>(null)\n const onChangeRef = useRef(onChange)\n const uploadFileRef = useRef(uploadFile)\n const deleteFileRef = useRef(deleteFile)\n const [internalFiles, setInternalFiles] = useState<FileItem[]>(() =>\n normalizeFileItems(defaultValue),\n )\n const [previewImage, setPreviewImage] = useState<FileItem | null>(null)\n const [uploading, setUploading] = useState(false)\n const [busyIndex, setBusyIndex] = useState<number | null>(null)\n const [dragActive, setDragActive] = useState(false)\n const dragCounter = useRef(0)\n const filesSignatureRef = useRef(fileListSignature(value ?? defaultValue))\n\n const isControlled = value !== undefined\n const files = isControlled ? normalizeFileItems(value) : internalFiles\n\n onChangeRef.current = onChange\n uploadFileRef.current = uploadFile\n deleteFileRef.current = deleteFile\n\n useEffect(() => {\n if (isControlled) return\n const next = normalizeFileItems(defaultValue)\n const nextSignature = fileListSignature(next)\n if (nextSignature === filesSignatureRef.current) return\n filesSignatureRef.current = nextSignature\n setInternalFiles(next)\n }, [defaultValue, isControlled])\n\n const commit = useCallback(\n (next: FileItem[]) => {\n filesSignatureRef.current = fileListSignature(next)\n if (!isControlled) setInternalFiles(next)\n if (!readOnly) onChangeRef.current?.(next)\n },\n [isControlled, readOnly],\n )\n\n const openFilePicker = useCallback(() => {\n if (uploading || busyIndex !== null) return\n inputRef.current?.click()\n }, [uploading, busyIndex])\n\n const processFiles = useCallback(\n async (fileList: FileList | File[]) => {\n const selected = Array.from(fileList).filter((file) =>\n isAcceptedFile(file, acceptVideos),\n )\n if (selected.length === 0) {\n toast.error(\n acceptVideos\n ? \"Solo se permiten imágenes, PDF o videos (MP4, WEBM)\"\n : \"Solo se permiten imágenes (PNG, JPG, WEBP) o PDF\",\n )\n return\n }\n\n const runUpload = uploadFileRef.current\n if (!runUpload) {\n toast.error(\"La subida de archivos no está configurada\")\n return\n }\n\n setUploading(true)\n const uploaded: FileItem[] = []\n\n try {\n for (const file of selected) {\n const res = await runUpload(file)\n if (res.url) {\n uploaded.push(toFileItem(file, res.url, res.provider))\n } else {\n toast.error(res.message || `No se pudo subir ${file.name}`)\n }\n }\n\n if (uploaded.length > 0) {\n commit([...files, ...uploaded])\n toast.success(\n uploaded.length === 1\n ? \"Archivo agregado\"\n : `${uploaded.length} archivos agregados`,\n )\n }\n } catch {\n toast.error(\"Error al subir archivos\")\n } finally {\n setUploading(false)\n }\n },\n [acceptVideos, commit, files],\n )\n\n const handleInputChange = async (\n event: React.ChangeEvent<HTMLInputElement>,\n ) => {\n const inputFiles = event.target.files\n if (!inputFiles?.length) return\n await processFiles(inputFiles)\n event.target.value = \"\"\n }\n\n const handleReplaceChange = async (\n event: React.ChangeEvent<HTMLInputElement>,\n ) => {\n const nextFile = event.target.files?.[0]\n const index = replaceIndexRef.current\n event.target.value = \"\"\n replaceIndexRef.current = null\n if (!nextFile || index == null) return\n if (!isAcceptedFile(nextFile, acceptVideos)) {\n toast.error(\n acceptVideos\n ? \"Solo se permiten imágenes, PDF o videos (MP4, WEBM)\"\n : \"Solo se permiten imágenes (PNG, JPG, WEBP) o PDF\",\n )\n return\n }\n\n const runUpload = uploadFileRef.current\n if (!runUpload) {\n toast.error(\"La subida de archivos no está configurada\")\n return\n }\n\n const previous = files[index]\n setBusyIndex(index)\n try {\n const res = await runUpload(nextFile)\n if (!res.url) {\n toast.error(res.message || `No se pudo reemplazar ${nextFile.name}`)\n return\n }\n\n const next = files.map((item, i) =>\n i === index ? toFileItem(nextFile, res.url, res.provider) : item,\n )\n commit(next)\n\n if (previous?.url && previous.url !== res.url && deleteFileRef.current) {\n const removed = await deleteFileRef.current(previous.url)\n if (!removed.ok) {\n toast.error(\n removed.message ||\n \"El archivo nuevo se subió, pero el anterior no se borró\",\n )\n }\n }\n toast.success(\"Archivo actualizado\")\n } catch {\n toast.error(\"Error al actualizar el archivo\")\n } finally {\n setBusyIndex(null)\n }\n }\n\n const handleDragEnter = (event: React.DragEvent) => {\n event.preventDefault()\n event.stopPropagation()\n dragCounter.current += 1\n setDragActive(true)\n }\n\n const handleDragLeave = (event: React.DragEvent) => {\n event.preventDefault()\n event.stopPropagation()\n dragCounter.current -= 1\n if (dragCounter.current <= 0) {\n dragCounter.current = 0\n setDragActive(false)\n }\n }\n\n const handleDragOver = (event: React.DragEvent) => {\n event.preventDefault()\n event.stopPropagation()\n }\n\n const handleDrop = async (event: React.DragEvent) => {\n event.preventDefault()\n event.stopPropagation()\n dragCounter.current = 0\n setDragActive(false)\n if (uploading || busyIndex !== null) return\n const dropped = event.dataTransfer.files\n if (!dropped?.length) return\n await processFiles(dropped)\n }\n\n const handleRemove = async (index: number) => {\n const target = files[index]\n if (!target) return\n setBusyIndex(index)\n try {\n if (target.url && deleteFileRef.current) {\n const removed = await deleteFileRef.current(target.url)\n if (!removed.ok) {\n toast.error(removed.message || \"No se pudo borrar el archivo\")\n return\n }\n }\n commit(files.filter((_, i) => i !== index))\n toast.success(\"Archivo eliminado\")\n } catch {\n toast.error(\"Error al borrar el archivo\")\n } finally {\n setBusyIndex(null)\n }\n }\n\n const handleOpenFile = (file: FileItem) => {\n if (isImageFile(file)) {\n setPreviewImage(file)\n return\n }\n if (isPdfFile(file)) {\n downloadFile(file)\n return\n }\n window.open(file.url, \"_blank\", \"noopener,noreferrer\")\n }\n\n const locked = uploading || busyIndex !== null\n\n return (\n <div data-slot=\"file-manager\" className={cn(\"space-y-3\", className)}>\n {!readOnly ? (\n <>\n <input\n ref={inputRef}\n id={inputId}\n type=\"file\"\n accept={acceptVideos ? ACCEPTED_TYPES_WITH_VIDEO : ACCEPTED_TYPES}\n className=\"sr-only\"\n multiple\n disabled={locked}\n onChange={(e) => void handleInputChange(e)}\n />\n <input\n ref={replaceInputRef}\n type=\"file\"\n accept={acceptVideos ? ACCEPTED_TYPES_WITH_VIDEO : ACCEPTED_TYPES}\n className=\"sr-only\"\n disabled={locked}\n onChange={(e) => void handleReplaceChange(e)}\n />\n </>\n ) : null}\n\n <div\n className={cn(\n \"rounded-xl transition-shadow\",\n !readOnly &&\n dragActive &&\n \"ring-2 ring-primary ring-offset-2 ring-offset-background\",\n )}\n onDragEnter={readOnly ? undefined : handleDragEnter}\n onDragLeave={readOnly ? undefined : handleDragLeave}\n onDragOver={readOnly ? undefined : handleDragOver}\n onDrop={readOnly ? undefined : (e) => void handleDrop(e)}\n >\n {!readOnly && files.length === 0 ? (\n compact ? (\n <div className=\"grid max-w-sm grid-cols-4 gap-2\">\n <AddFileCard\n disabled={locked}\n dragActive={dragActive}\n onBrowse={openFilePicker}\n />\n </div>\n ) : (\n <AddFileCard\n fullWidth\n disabled={locked}\n dragActive={dragActive}\n onBrowse={openFilePicker}\n />\n )\n ) : null}\n\n {files.length > 0 || readOnly ? (\n <div\n className={cn(\n \"grid gap-2\",\n compact\n ? \"max-w-lg grid-cols-4 sm:grid-cols-6\"\n : \"grid-cols-2 gap-3 sm:grid-cols-3\",\n )}\n >\n {!readOnly ? (\n <AddFileCard\n disabled={locked}\n dragActive={dragActive}\n onBrowse={openFilePicker}\n />\n ) : null}\n\n {files.map((file, index) => {\n const image = isImageFile(file)\n const pdf = isPdfFile(file)\n const video = isVideoFile(file)\n const displayName = getFileDisplayName(file)\n const itemBusy = busyIndex === index\n\n return (\n <Card\n key={`${file.url}-${index}`}\n className=\"group relative cursor-pointer overflow-hidden border-border bg-card py-0 shadow-sm transition-colors hover:border-primary/40\"\n onClick={() => handleOpenFile(file)}\n >\n {!readOnly ? (\n <div className=\"absolute top-2 right-2 z-10 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100\">\n <button\n type=\"button\"\n className=\"flex size-6 items-center justify-center rounded-full bg-background/90 text-foreground shadow-sm\"\n aria-label={`Reemplazar ${file.name}`}\n disabled={locked}\n onClick={(e) => {\n e.stopPropagation()\n if (locked) return\n replaceIndexRef.current = index\n replaceInputRef.current?.click()\n }}\n >\n {itemBusy ? (\n <IconLoader2 className=\"size-3.5 animate-spin\" />\n ) : (\n <IconRefresh className=\"size-3.5\" />\n )}\n </button>\n <button\n type=\"button\"\n className=\"flex size-6 items-center justify-center rounded-full bg-destructive text-destructive-foreground shadow-sm\"\n aria-label={`Eliminar ${file.name}`}\n disabled={locked}\n onClick={(e) => {\n e.stopPropagation()\n void handleRemove(index)\n }}\n >\n {itemBusy ? (\n <IconLoader2 className=\"size-3.5 animate-spin\" />\n ) : (\n <IconX className=\"size-3.5\" />\n )}\n </button>\n </div>\n ) : null}\n\n <div className=\"relative aspect-square w-full overflow-hidden bg-muted/40\">\n {image ? (\n <img\n src={file.url}\n alt={file.name}\n className=\"size-full object-cover transition-transform duration-300 group-hover:scale-[1.03]\"\n />\n ) : pdf ? (\n <div className=\"flex h-full flex-col items-center justify-center gap-1.5 bg-gradient-to-b from-muted/30 to-muted/60\">\n <IconFileTypePdf\n className=\"size-12 text-red-600 dark:text-red-400\"\n stroke={1.1}\n />\n <span className=\"text-[10px] font-semibold uppercase tracking-wide text-muted-foreground\">\n PDF\n </span>\n </div>\n ) : video ? (\n <div className=\"flex h-full flex-col items-center justify-center gap-1.5 bg-gradient-to-b from-muted/30 to-muted/60\">\n <IconVideo\n className=\"size-12 text-primary\"\n stroke={1.1}\n />\n <span className=\"text-[10px] font-semibold uppercase tracking-wide text-muted-foreground\">\n Video\n </span>\n </div>\n ) : (\n <div className=\"flex h-full flex-col items-center justify-center gap-1 text-muted-foreground\">\n <IconPhoto className=\"size-8\" stroke={1.25} />\n <span className=\"text-xs uppercase\">\n {(file.name ?? \"\").split(\".\").pop() || \"file\"}\n </span>\n </div>\n )}\n\n <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\">\n <p\n className=\"line-clamp-2 text-[11px] leading-tight font-medium text-white\"\n title={getFileFullName(file)}\n >\n {displayName}\n </p>\n <p className=\"mt-0.5 text-[9px] text-white/75\">\n {image\n ? \"Clic para ampliar\"\n : pdf\n ? \"Clic para descargar\"\n : video\n ? \"Clic para reproducir\"\n : \"Clic para abrir\"}\n </p>\n </div>\n </div>\n\n <CardContent className=\"sr-only\">\n <span>{file.name}</span>\n </CardContent>\n </Card>\n )\n })}\n </div>\n ) : null}\n </div>\n\n <AlertDialog\n open={!!previewImage}\n onOpenChange={(open) => !open && setPreviewImage(null)}\n >\n <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\">\n <AlertDialogHeader className=\"shrink-0 border-b border-border px-4 py-3 text-left\">\n <AlertDialogTitle\n className=\"block max-w-full truncate pr-8 text-base\"\n title={previewImage ? getFileFullName(previewImage) : undefined}\n >\n {previewImage ? getFileDisplayName(previewImage, 56) : \"\"}\n </AlertDialogTitle>\n </AlertDialogHeader>\n\n <div className=\"flex min-h-0 flex-1 items-center justify-center overflow-auto bg-muted/30 px-4 py-3\">\n {previewImage ? (\n <img\n src={previewImage.url}\n alt={getFileDisplayName(previewImage, 80)}\n className=\"max-h-[min(55vh,480px)] w-auto max-w-full rounded-lg object-contain\"\n />\n ) : null}\n </div>\n\n <AlertDialogFooter className=\"shrink-0 border-t border-border px-4 py-3 sm:justify-between\">\n {previewImage ? (\n <Button\n type=\"button\"\n variant=\"outline\"\n className=\"gap-2\"\n onClick={() => downloadFile(previewImage)}\n >\n <IconDownload className=\"size-4\" />\n Descargar\n </Button>\n ) : (\n <span />\n )}\n <Button type=\"button\" onClick={() => setPreviewImage(null)}>\n Cerrar\n </Button>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n </div>\n )\n}\n\nexport { FileManager }\nexport default FileManager\n",
|
|
21
|
+
"type": "registry:ui",
|
|
22
|
+
"target": ""
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|