@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
package/README.md
CHANGED
|
@@ -23,4 +23,4 @@ Publicación: `pnpm release:ui` (sync + tests) y luego release GitHub tag `vX.Y.
|
|
|
23
23
|
|
|
24
24
|
Guía completa: [REGISTRY.md](../../REGISTRY.md) en la raíz del monorepo.
|
|
25
25
|
|
|
26
|
-
**Versión actual del paquete:** `1.6.
|
|
26
|
+
**Versión actual del paquete:** `1.6.8` (`FileManager` + chat `realtimeStatus` / `featureEnabled` / `embedded`).
|
package/RELEASE.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Release `@traky-app/ui`
|
|
2
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
|
+
|
|
3
9
|
## 1.6.7 (pendiente de publicar)
|
|
4
10
|
|
|
5
11
|
- `InternalChatPanel`: prop `realtimeStatus` (`connected` \| `reconnecting` \| `offline`) — skeleton al reconectar y mensaje offline.
|
|
@@ -11,11 +17,11 @@
|
|
|
11
17
|
|
|
12
18
|
1. `pnpm registry:sync && pnpm --filter @traky-app/ui test`
|
|
13
19
|
2. Confirma `version` en `package.json` (debe coincidir con el tag).
|
|
14
|
-
3. **GitHub (recomendado):** tag `v1.6.
|
|
20
|
+
3. **GitHub (recomendado):** tag `v1.6.8` + release publicado → workflow `publish.yml`.
|
|
15
21
|
4. **Manual:** `cd packages/ui && npm publish --access public` (token / 2FA).
|
|
16
22
|
|
|
17
23
|
Después en **app-traky**:
|
|
18
24
|
|
|
19
25
|
```bash
|
|
20
|
-
pnpm add @traky-app/ui@1.6.
|
|
26
|
+
pnpm add @traky-app/ui@1.6.8 --save-exact
|
|
21
27
|
```
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "lib-file-items",
|
|
4
|
+
"type": "registry:lib",
|
|
5
|
+
"author": "Traky (https://traky.app)",
|
|
6
|
+
"files": [
|
|
7
|
+
{
|
|
8
|
+
"path": "lib/file-items.ts",
|
|
9
|
+
"content": "export const STORAGE_FILE_PROVIDERS = [\"traky\", \"legacy\"] as const\n\nexport type FileStorageProvider = (typeof STORAGE_FILE_PROVIDERS)[number]\n\nexport type FileItem = {\n name: string\n url: string\n type: string\n /** `traky` = objeto nuevo; `legacy` = externo. El host decide el valor. */\n provider?: FileStorageProvider\n}\n\nfunction inferMimeFromName(name: string) {\n const lower = name.toLowerCase()\n if (/\\.pdf$/i.test(lower)) return \"application/pdf\"\n if (/\\.(webm)$/i.test(lower)) return \"video/webm\"\n if (/\\.(mp4|mov|mkv)$/i.test(lower)) return \"video/mp4\"\n if (/\\.(png|jpe?g|webp|gif|bmp|svg)$/i.test(lower)) return \"image/jpeg\"\n return \"\"\n}\n\nfunction resolveFileProvider(explicit?: unknown): FileStorageProvider | undefined {\n if (explicit === \"traky\" || explicit === \"legacy\") return explicit\n return undefined\n}\n\n/** Normaliza entradas (strings, objetos incompletos o sin `type`). */\nexport function normalizeFileItem(file: unknown): FileItem | null {\n if (typeof file === \"string\" && file.trim()) {\n const url = file.trim()\n const name = url.split(\"/\").pop()?.split(\"?\")[0] || \"archivo\"\n return {\n name,\n url,\n type: inferMimeFromName(name),\n }\n }\n\n if (!file || typeof file !== \"object\") return null\n\n const item = file as Record<string, unknown>\n const urlCandidate = [item.url, item.fileUrl, item.href, item.path, item.src].find(\n (value) => typeof value === \"string\" && value.trim(),\n )\n if (typeof urlCandidate !== \"string\") return null\n\n const url = urlCandidate.trim()\n const name =\n (typeof item.name === \"string\" && item.name.trim()) ||\n (typeof item.fileName === \"string\" && item.fileName.trim()) ||\n url.split(\"/\").pop()?.split(\"?\")[0] ||\n \"archivo\"\n const type =\n (typeof item.type === \"string\" && item.type) ||\n (typeof item.mimeType === \"string\" && item.mimeType) ||\n inferMimeFromName(name) ||\n inferMimeFromName(url)\n\n return {\n name,\n url,\n type,\n provider: resolveFileProvider(item.provider),\n }\n}\n\nexport function normalizeFileItems(files: unknown): FileItem[] {\n if (typeof files === \"string\") {\n const trimmed = files.trim()\n if (!trimmed) return []\n\n if (trimmed.startsWith(\"[\") || trimmed.startsWith(\"{\")) {\n try {\n return normalizeFileItems(JSON.parse(trimmed) as unknown)\n } catch {\n // JSON inválido: tratar como URL suelta abajo.\n }\n }\n\n const single = normalizeFileItem(trimmed)\n return single ? [single] : []\n }\n\n if (files && typeof files === \"object\" && !Array.isArray(files)) {\n const single = normalizeFileItem(files)\n return single ? [single] : []\n }\n\n if (!Array.isArray(files)) return []\n return files\n .map(normalizeFileItem)\n .filter((file): file is FileItem => file !== null)\n}\n\nexport function fileListSignature(files: unknown): string {\n return normalizeFileItems(files)\n .map((file) => `${file.url}|${file.name}|${file.type}`)\n .join(\"\\n\")\n}\n",
|
|
10
|
+
"type": "registry:lib",
|
|
11
|
+
"target": ""
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "lib-file-manager-utils",
|
|
4
|
+
"type": "registry:lib",
|
|
5
|
+
"author": "Traky (https://traky.app)",
|
|
6
|
+
"files": [
|
|
7
|
+
{
|
|
8
|
+
"path": "lib/file-manager-utils.ts",
|
|
9
|
+
"content": "import type { FileItem, FileStorageProvider } from \"./file-items\"\n\nexport const ACCEPTED_TYPES = \".png,.jpg,.jpeg,.webp,.gif,.pdf\"\nexport const ACCEPTED_TYPES_WITH_VIDEO =\n \".png,.jpg,.jpeg,.webp,.gif,.pdf,.mp4,.webm,.mov,.mkv\"\nexport const ACCEPTED_MIME = [\n \"image/png\",\n \"image/jpeg\",\n \"image/webp\",\n \"image/gif\",\n \"application/pdf\",\n]\nexport const VIDEO_MIME = [\n \"video/mp4\",\n \"video/webm\",\n \"video/quicktime\",\n \"video/x-matroska\",\n]\n\nexport function isImageFile(file: FileItem) {\n const type = file.type ?? \"\"\n const name = file.name ?? \"\"\n const url = file.url ?? \"\"\n return (\n type.startsWith(\"image/\") ||\n /\\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(name) ||\n /\\.(png|jpe?g|gif|webp|bmp|svg)(\\?|$)/i.test(url)\n )\n}\n\nexport function isPdfFile(file: FileItem) {\n const type = file.type ?? \"\"\n const name = file.name ?? \"\"\n const url = file.url ?? \"\"\n return (\n type.includes(\"pdf\") || /\\.pdf$/i.test(name) || /\\.pdf(\\?|$)/i.test(url)\n )\n}\n\nexport function isVideoFile(file: FileItem) {\n const type = file.type ?? \"\"\n const name = file.name ?? \"\"\n const url = file.url ?? \"\"\n return (\n type.startsWith(\"video/\") ||\n /\\.(mp4|webm|mov|mkv)$/i.test(name) ||\n /\\.(mp4|webm|mov|mkv)(\\?|$)/i.test(url)\n )\n}\n\nexport function isAcceptedFile(file: File, acceptVideos: boolean) {\n const type = file.type ?? \"\"\n if (ACCEPTED_MIME.includes(type)) return true\n if (acceptVideos && (VIDEO_MIME.includes(type) || type.startsWith(\"video/\"))) {\n return true\n }\n if (/\\.(png|jpe?g|webp|gif|pdf)$/i.test(file.name)) return true\n if (acceptVideos && /\\.(mp4|webm|mov|mkv)$/i.test(file.name)) return true\n return false\n}\n\nexport function getFileDisplayName(file: FileItem, maxLength = 40): string {\n let label = file.name?.trim() ?? \"\"\n const looksLikeUrl =\n /^https?:\\/\\//i.test(label) || (label.length > 72 && !label.includes(\" \"))\n\n if (!label || looksLikeUrl) {\n try {\n const pathname = decodeURIComponent(new URL(file.url ?? \"\").pathname)\n const fromUrl = pathname.split(\"/\").filter(Boolean).pop() ?? \"\"\n if (fromUrl) label = fromUrl\n } catch {\n // ignore invalid URL\n }\n }\n\n if (!label) return \"Archivo\"\n if (label.length <= maxLength) return label\n\n const dot = label.lastIndexOf(\".\")\n const ext = dot > 0 && label.length - dot <= 8 ? label.slice(dot) : \"\"\n const stem = ext ? label.slice(0, dot) : label\n const budget = maxLength - ext.length - 1\n if (budget < 8) return `${label.slice(0, maxLength - 1)}…`\n return `${stem.slice(0, budget)}…${ext}`\n}\n\nexport function getFileFullName(file: FileItem): string {\n const label = file.name?.trim()\n if (label && !/^https?:\\/\\//i.test(label)) return label\n return getFileDisplayName(file, 200)\n}\n\nexport function downloadFile(file: FileItem) {\n const link = document.createElement(\"a\")\n link.href = file.url\n link.download = file.name || \"archivo\"\n link.target = \"_blank\"\n link.rel = \"noopener noreferrer\"\n document.body.appendChild(link)\n link.click()\n link.remove()\n}\n\nexport function toFileItem(\n file: File,\n url: string,\n provider?: FileStorageProvider,\n): FileItem {\n return {\n name: file.name,\n url,\n type: file.type,\n ...(provider ? { provider } : {}),\n }\n}\n",
|
|
10
|
+
"type": "registry:lib",
|
|
11
|
+
"target": ""
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
export const STORAGE_FILE_PROVIDERS = ["traky", "legacy"] as const
|
|
2
|
+
|
|
3
|
+
export type FileStorageProvider = (typeof STORAGE_FILE_PROVIDERS)[number]
|
|
4
|
+
|
|
5
|
+
export type FileItem = {
|
|
6
|
+
name: string
|
|
7
|
+
url: string
|
|
8
|
+
type: string
|
|
9
|
+
/** `traky` = objeto nuevo; `legacy` = externo. El host decide el valor. */
|
|
10
|
+
provider?: FileStorageProvider
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function inferMimeFromName(name: string) {
|
|
14
|
+
const lower = name.toLowerCase()
|
|
15
|
+
if (/\.pdf$/i.test(lower)) return "application/pdf"
|
|
16
|
+
if (/\.(webm)$/i.test(lower)) return "video/webm"
|
|
17
|
+
if (/\.(mp4|mov|mkv)$/i.test(lower)) return "video/mp4"
|
|
18
|
+
if (/\.(png|jpe?g|webp|gif|bmp|svg)$/i.test(lower)) return "image/jpeg"
|
|
19
|
+
return ""
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolveFileProvider(explicit?: unknown): FileStorageProvider | undefined {
|
|
23
|
+
if (explicit === "traky" || explicit === "legacy") return explicit
|
|
24
|
+
return undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Normaliza entradas (strings, objetos incompletos o sin `type`). */
|
|
28
|
+
export function normalizeFileItem(file: unknown): FileItem | null {
|
|
29
|
+
if (typeof file === "string" && file.trim()) {
|
|
30
|
+
const url = file.trim()
|
|
31
|
+
const name = url.split("/").pop()?.split("?")[0] || "archivo"
|
|
32
|
+
return {
|
|
33
|
+
name,
|
|
34
|
+
url,
|
|
35
|
+
type: inferMimeFromName(name),
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!file || typeof file !== "object") return null
|
|
40
|
+
|
|
41
|
+
const item = file as Record<string, unknown>
|
|
42
|
+
const urlCandidate = [item.url, item.fileUrl, item.href, item.path, item.src].find(
|
|
43
|
+
(value) => typeof value === "string" && value.trim(),
|
|
44
|
+
)
|
|
45
|
+
if (typeof urlCandidate !== "string") return null
|
|
46
|
+
|
|
47
|
+
const url = urlCandidate.trim()
|
|
48
|
+
const name =
|
|
49
|
+
(typeof item.name === "string" && item.name.trim()) ||
|
|
50
|
+
(typeof item.fileName === "string" && item.fileName.trim()) ||
|
|
51
|
+
url.split("/").pop()?.split("?")[0] ||
|
|
52
|
+
"archivo"
|
|
53
|
+
const type =
|
|
54
|
+
(typeof item.type === "string" && item.type) ||
|
|
55
|
+
(typeof item.mimeType === "string" && item.mimeType) ||
|
|
56
|
+
inferMimeFromName(name) ||
|
|
57
|
+
inferMimeFromName(url)
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
name,
|
|
61
|
+
url,
|
|
62
|
+
type,
|
|
63
|
+
provider: resolveFileProvider(item.provider),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function normalizeFileItems(files: unknown): FileItem[] {
|
|
68
|
+
if (typeof files === "string") {
|
|
69
|
+
const trimmed = files.trim()
|
|
70
|
+
if (!trimmed) return []
|
|
71
|
+
|
|
72
|
+
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
|
73
|
+
try {
|
|
74
|
+
return normalizeFileItems(JSON.parse(trimmed) as unknown)
|
|
75
|
+
} catch {
|
|
76
|
+
// JSON inválido: tratar como URL suelta abajo.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const single = normalizeFileItem(trimmed)
|
|
81
|
+
return single ? [single] : []
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (files && typeof files === "object" && !Array.isArray(files)) {
|
|
85
|
+
const single = normalizeFileItem(files)
|
|
86
|
+
return single ? [single] : []
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!Array.isArray(files)) return []
|
|
90
|
+
return files
|
|
91
|
+
.map(normalizeFileItem)
|
|
92
|
+
.filter((file): file is FileItem => file !== null)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function fileListSignature(files: unknown): string {
|
|
96
|
+
return normalizeFileItems(files)
|
|
97
|
+
.map((file) => `${file.url}|${file.name}|${file.type}`)
|
|
98
|
+
.join("\n")
|
|
99
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { FileItem, FileStorageProvider } from "./file-items"
|
|
2
|
+
|
|
3
|
+
export const ACCEPTED_TYPES = ".png,.jpg,.jpeg,.webp,.gif,.pdf"
|
|
4
|
+
export const ACCEPTED_TYPES_WITH_VIDEO =
|
|
5
|
+
".png,.jpg,.jpeg,.webp,.gif,.pdf,.mp4,.webm,.mov,.mkv"
|
|
6
|
+
export const ACCEPTED_MIME = [
|
|
7
|
+
"image/png",
|
|
8
|
+
"image/jpeg",
|
|
9
|
+
"image/webp",
|
|
10
|
+
"image/gif",
|
|
11
|
+
"application/pdf",
|
|
12
|
+
]
|
|
13
|
+
export const VIDEO_MIME = [
|
|
14
|
+
"video/mp4",
|
|
15
|
+
"video/webm",
|
|
16
|
+
"video/quicktime",
|
|
17
|
+
"video/x-matroska",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
export function isImageFile(file: FileItem) {
|
|
21
|
+
const type = file.type ?? ""
|
|
22
|
+
const name = file.name ?? ""
|
|
23
|
+
const url = file.url ?? ""
|
|
24
|
+
return (
|
|
25
|
+
type.startsWith("image/") ||
|
|
26
|
+
/\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(name) ||
|
|
27
|
+
/\.(png|jpe?g|gif|webp|bmp|svg)(\?|$)/i.test(url)
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isPdfFile(file: FileItem) {
|
|
32
|
+
const type = file.type ?? ""
|
|
33
|
+
const name = file.name ?? ""
|
|
34
|
+
const url = file.url ?? ""
|
|
35
|
+
return (
|
|
36
|
+
type.includes("pdf") || /\.pdf$/i.test(name) || /\.pdf(\?|$)/i.test(url)
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isVideoFile(file: FileItem) {
|
|
41
|
+
const type = file.type ?? ""
|
|
42
|
+
const name = file.name ?? ""
|
|
43
|
+
const url = file.url ?? ""
|
|
44
|
+
return (
|
|
45
|
+
type.startsWith("video/") ||
|
|
46
|
+
/\.(mp4|webm|mov|mkv)$/i.test(name) ||
|
|
47
|
+
/\.(mp4|webm|mov|mkv)(\?|$)/i.test(url)
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isAcceptedFile(file: File, acceptVideos: boolean) {
|
|
52
|
+
const type = file.type ?? ""
|
|
53
|
+
if (ACCEPTED_MIME.includes(type)) return true
|
|
54
|
+
if (acceptVideos && (VIDEO_MIME.includes(type) || type.startsWith("video/"))) {
|
|
55
|
+
return true
|
|
56
|
+
}
|
|
57
|
+
if (/\.(png|jpe?g|webp|gif|pdf)$/i.test(file.name)) return true
|
|
58
|
+
if (acceptVideos && /\.(mp4|webm|mov|mkv)$/i.test(file.name)) return true
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getFileDisplayName(file: FileItem, maxLength = 40): string {
|
|
63
|
+
let label = file.name?.trim() ?? ""
|
|
64
|
+
const looksLikeUrl =
|
|
65
|
+
/^https?:\/\//i.test(label) || (label.length > 72 && !label.includes(" "))
|
|
66
|
+
|
|
67
|
+
if (!label || looksLikeUrl) {
|
|
68
|
+
try {
|
|
69
|
+
const pathname = decodeURIComponent(new URL(file.url ?? "").pathname)
|
|
70
|
+
const fromUrl = pathname.split("/").filter(Boolean).pop() ?? ""
|
|
71
|
+
if (fromUrl) label = fromUrl
|
|
72
|
+
} catch {
|
|
73
|
+
// ignore invalid URL
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!label) return "Archivo"
|
|
78
|
+
if (label.length <= maxLength) return label
|
|
79
|
+
|
|
80
|
+
const dot = label.lastIndexOf(".")
|
|
81
|
+
const ext = dot > 0 && label.length - dot <= 8 ? label.slice(dot) : ""
|
|
82
|
+
const stem = ext ? label.slice(0, dot) : label
|
|
83
|
+
const budget = maxLength - ext.length - 1
|
|
84
|
+
if (budget < 8) return `${label.slice(0, maxLength - 1)}…`
|
|
85
|
+
return `${stem.slice(0, budget)}…${ext}`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function getFileFullName(file: FileItem): string {
|
|
89
|
+
const label = file.name?.trim()
|
|
90
|
+
if (label && !/^https?:\/\//i.test(label)) return label
|
|
91
|
+
return getFileDisplayName(file, 200)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function downloadFile(file: FileItem) {
|
|
95
|
+
const link = document.createElement("a")
|
|
96
|
+
link.href = file.url
|
|
97
|
+
link.download = file.name || "archivo"
|
|
98
|
+
link.target = "_blank"
|
|
99
|
+
link.rel = "noopener noreferrer"
|
|
100
|
+
document.body.appendChild(link)
|
|
101
|
+
link.click()
|
|
102
|
+
link.remove()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function toFileItem(
|
|
106
|
+
file: File,
|
|
107
|
+
url: string,
|
|
108
|
+
provider?: FileStorageProvider,
|
|
109
|
+
): FileItem {
|
|
110
|
+
return {
|
|
111
|
+
name: file.name,
|
|
112
|
+
url,
|
|
113
|
+
type: file.type,
|
|
114
|
+
...(provider ? { provider } : {}),
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -303,6 +303,16 @@
|
|
|
303
303
|
}
|
|
304
304
|
]
|
|
305
305
|
},
|
|
306
|
+
{
|
|
307
|
+
"name": "file-manager",
|
|
308
|
+
"type": "registry:ui",
|
|
309
|
+
"files": [
|
|
310
|
+
{
|
|
311
|
+
"path": "json/file-manager.json",
|
|
312
|
+
"type": "registry:file"
|
|
313
|
+
}
|
|
314
|
+
]
|
|
315
|
+
},
|
|
306
316
|
{
|
|
307
317
|
"name": "geo-search-select",
|
|
308
318
|
"type": "registry:ui",
|
|
@@ -793,6 +803,26 @@
|
|
|
793
803
|
}
|
|
794
804
|
]
|
|
795
805
|
},
|
|
806
|
+
{
|
|
807
|
+
"name": "lib-file-items",
|
|
808
|
+
"type": "registry:lib",
|
|
809
|
+
"files": [
|
|
810
|
+
{
|
|
811
|
+
"path": "json/lib-file-items.json",
|
|
812
|
+
"type": "registry:file"
|
|
813
|
+
}
|
|
814
|
+
]
|
|
815
|
+
},
|
|
816
|
+
{
|
|
817
|
+
"name": "lib-file-manager-utils",
|
|
818
|
+
"type": "registry:lib",
|
|
819
|
+
"files": [
|
|
820
|
+
{
|
|
821
|
+
"path": "json/lib-file-manager-utils.json",
|
|
822
|
+
"type": "registry:file"
|
|
823
|
+
}
|
|
824
|
+
]
|
|
825
|
+
},
|
|
796
826
|
{
|
|
797
827
|
"name": "lib-geo-city-codes",
|
|
798
828
|
"type": "registry:lib",
|