@traky-app/ui 1.6.7 → 1.6.9

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 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.7` (chat: `realtimeStatus`, `featureEnabled`, `embedded`).
26
+ **Versión actual del paquete:** `1.6.9` (`cobe` 2.0.1, FileManager, chat).
package/RELEASE.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Release `@traky-app/ui`
2
2
 
3
+ ## 1.6.9 (pendiente de publicar)
4
+
5
+ - `cobe` 2.0.1: Globe usa `globe.update()` por frame (ya no `onRender`).
6
+ - `WorldGlobe`: globo listo para la página web (`@traky-app/ui/world-globe`).
7
+ - Registro `globe.json` y `world-globe.json` regenerados (`pnpm registry:sync`).
8
+
9
+ ## 1.6.8
10
+
11
+ - `FileManager`: galería CRUD de imágenes/PDF con `uploadFile` / `deleteFile` inyectados.
12
+ - Libs: `file-items`, `file-manager-utils`.
13
+ - Registro `file-manager.json` regenerado (`pnpm registry:sync`).
14
+
3
15
  ## 1.6.7 (pendiente de publicar)
4
16
 
5
17
  - `InternalChatPanel`: prop `realtimeStatus` (`connected` \| `reconnecting` \| `offline`) — skeleton al reconectar y mensaje offline.
@@ -11,11 +23,11 @@
11
23
 
12
24
  1. `pnpm registry:sync && pnpm --filter @traky-app/ui test`
13
25
  2. Confirma `version` en `package.json` (debe coincidir con el tag).
14
- 3. **GitHub (recomendado):** tag `v1.6.7` + release publicado → workflow `publish.yml`.
26
+ 3. **GitHub (recomendado):** tag `v1.6.9` + release publicado → workflow `publish.yml`.
15
27
  4. **Manual:** `cd packages/ui && npm publish --access public` (token / 2FA).
16
28
 
17
29
  Después en **app-traky**:
18
30
 
19
31
  ```bash
20
- pnpm add @traky-app/ui@1.6.7 --save-exact
32
+ pnpm add @traky-app/ui@1.6.9 --save-exact
21
33
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@traky-app/ui",
3
- "version": "1.6.7",
3
+ "version": "1.6.9",
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",
@@ -66,7 +66,7 @@
66
66
  "class-variance-authority": "^0.7.1",
67
67
  "clsx": "^2.1.1",
68
68
  "cmdk": "^1.1.1",
69
- "cobe": "^0.6.5",
69
+ "cobe": "^2.0.1",
70
70
  "embla-carousel-react": "^8.6.0",
71
71
  "input-otp": "^1.5.0",
72
72
  "maplibre-gl": "^6.9.1",
@@ -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
+ }
@@ -9,7 +9,7 @@
9
9
  "files": [
10
10
  {
11
11
  "path": "ui/globe.tsx",
12
- "content": "\"use client\"\n\nimport { useEffect, useMemo, useRef } from \"react\"\nimport createGlobe, { type COBEOptions } from \"cobe\"\nimport { useMotionValue, useSpring } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst MOVEMENT_DAMPING = 1400\nconst MARKER_HOLD_MS = 2200\nconst MARKER_TRANSITION_MS = 1800\n\nfunction easeInOutCubic(t: number): number {\n return t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2\n}\n\nfunction lerp(a: number, b: number, t: number): number {\n return a + (b - a) * t\n}\n\nfunction lerpAngle(a: number, b: number, t: number): number {\n let diff = b - a\n while (diff > Math.PI) diff -= 2 * Math.PI\n while (diff < -Math.PI) diff += 2 * Math.PI\n return a + diff * t\n}\n\nconst GLOBE_CONFIG: COBEOptions = {\n width: 800,\n height: 800,\n onRender: () => {},\n devicePixelRatio: 2,\n phi: 0,\n theta: 0.3,\n dark: 0,\n diffuse: 0.4,\n mapSamples: 16000,\n mapBrightness: 1.2,\n baseColor: [1, 1, 1],\n markerColor: [251 / 255, 100 / 255, 21 / 255],\n glowColor: [1, 1, 1],\n markers: [\n { location: [14.5995, 120.9842], size: 0.03 },\n { location: [19.076, 72.8777], size: 0.1 },\n { location: [23.8103, 90.4125], size: 0.05 },\n { location: [30.0444, 31.2357], size: 0.07 },\n { location: [39.9042, 116.4074], size: 0.08 },\n { location: [-23.5505, -46.6333], size: 0.1 },\n { location: [19.4326, -99.1332], size: 0.1 },\n { location: [40.7128, -74.006], size: 0.1 },\n { location: [34.6937, 135.5022], size: 0.05 },\n { location: [41.0082, 28.9784], size: 0.06 },\n ],\n}\n\nexport function Globe({\n className,\n config = GLOBE_CONFIG,\n autoRotate = true,\n interactive = true,\n panBetweenMarkers = false,\n cycleMarkers = false,\n markerHoldMs = MARKER_HOLD_MS,\n markerTransitionMs = MARKER_TRANSITION_MS,\n}: {\n className?: string\n config?: COBEOptions\n autoRotate?: boolean\n interactive?: boolean\n /** @deprecated Usa cycleMarkers */\n panBetweenMarkers?: boolean\n /** Centra cada marker uno por uno con pausa y transición suave. */\n cycleMarkers?: boolean\n markerHoldMs?: number\n markerTransitionMs?: number\n}) {\n const canvasRef = useRef<HTMLCanvasElement>(null)\n const phiRef = useRef(config.phi ?? 0)\n const widthRef = useRef(0)\n const pointerInteracting = useRef<number | null>(null)\n const pointerInteractionMovement = useRef(0)\n\n const shouldCycleMarkers = cycleMarkers || panBetweenMarkers\n\n const fixedTheta = config.theta ?? 0.22\n\n const markerViews = useMemo((): Array<{ phi: number }> | null => {\n if (!shouldCycleMarkers || !config.markers?.length) return null\n return config.markers.map((marker) => ({\n phi: Math.PI - ((marker.location[1] * Math.PI) / 180 - Math.PI / 2),\n }))\n }, [shouldCycleMarkers, config.markers])\n\n const markerPhiRange = useMemo((): [number, number] | null => {\n if (!panBetweenMarkers || cycleMarkers || !config.markers?.length) return null\n const phis = config.markers.map(\n (marker) => Math.PI - ((marker.location[1] * Math.PI) / 180 - Math.PI / 2),\n )\n return [Math.min(...phis), Math.max(...phis)]\n }, [panBetweenMarkers, cycleMarkers, config.markers])\n\n const r = useMotionValue(0)\n const rs = useSpring(r, {\n mass: 1,\n damping: 30,\n stiffness: 100,\n })\n\n const updatePointerInteraction = (value: number | null) => {\n if (!interactive) return\n pointerInteracting.current = value\n if (canvasRef.current) {\n canvasRef.current.style.cursor = value !== null ? \"grabbing\" : \"grab\"\n }\n }\n\n const updateMovement = (clientX: number) => {\n if (!interactive) return\n if (pointerInteracting.current !== null) {\n const delta = clientX - pointerInteracting.current\n pointerInteractionMovement.current = delta\n r.set(r.get() + delta / MOVEMENT_DAMPING)\n }\n }\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n\n const onResize = () => {\n widthRef.current = canvas.offsetWidth\n }\n\n onResize()\n window.addEventListener(\"resize\", onResize)\n\n const resizeObserver = new ResizeObserver(onResize)\n resizeObserver.observe(canvas)\n\n const globe = createGlobe(canvas, {\n ...config,\n width: widthRef.current * 2,\n height: widthRef.current * 2,\n onRender: (state) => {\n if (shouldCycleMarkers && markerViews && markerViews.length > 0) {\n const segment = markerHoldMs + markerTransitionMs\n const total = markerViews.length * segment\n const elapsed = Date.now() % total\n const index = Math.floor(elapsed / segment)\n const local = elapsed % segment\n const from = markerViews[index % markerViews.length]\n const to = markerViews[(index + 1) % markerViews.length]\n\n if (local < markerHoldMs) {\n state.phi = from.phi\n } else {\n const t = easeInOutCubic((local - markerHoldMs) / markerTransitionMs)\n state.phi = lerpAngle(from.phi, to.phi, t)\n }\n state.theta = fixedTheta\n } else if (panBetweenMarkers && markerPhiRange) {\n const [phiMin, phiMax] = markerPhiRange\n const t = Date.now() / 5200\n state.phi = phiMin + ((Math.sin(t) + 1) / 2) * (phiMax - phiMin)\n } else if (autoRotate && !pointerInteracting.current) {\n phiRef.current += 0.005\n state.phi = interactive ? phiRef.current + rs.get() : (config.phi ?? 0)\n } else {\n state.phi = interactive ? phiRef.current + rs.get() : (config.phi ?? 0)\n }\n\n state.theta = shouldCycleMarkers ? fixedTheta : (config.theta ?? state.theta)\n state.scale = config.scale ?? 1\n state.width = widthRef.current * 2\n state.height = widthRef.current * 2\n },\n })\n\n setTimeout(() => {\n canvas.style.opacity = \"1\"\n }, 0)\n\n return () => {\n globe.destroy()\n window.removeEventListener(\"resize\", onResize)\n resizeObserver.disconnect()\n }\n }, [\n rs,\n config,\n autoRotate,\n interactive,\n panBetweenMarkers,\n cycleMarkers,\n shouldCycleMarkers,\n markerViews,\n markerPhiRange,\n markerHoldMs,\n markerTransitionMs,\n ])\n\n return (\n <div\n className={cn(\n \"absolute inset-0 mx-auto aspect-square w-full max-w-150\",\n className\n )}\n >\n <canvas\n className={cn(\n \"size-full opacity-0 transition-opacity duration-500 contain-[layout_paint_size]\",\n interactive ? \"cursor-grab\" : \"cursor-default\",\n )}\n ref={canvasRef}\n onPointerDown={\n interactive\n ? (e) => {\n pointerInteracting.current = e.clientX\n updatePointerInteraction(e.clientX)\n }\n : undefined\n }\n onPointerUp={interactive ? () => updatePointerInteraction(null) : undefined}\n onPointerOut={interactive ? () => updatePointerInteraction(null) : undefined}\n onMouseMove={interactive ? (e) => updateMovement(e.clientX) : undefined}\n onTouchMove={\n interactive\n ? (e) => e.touches[0] && updateMovement(e.touches[0].clientX)\n : undefined\n }\n />\n </div>\n )\n}\n",
12
+ "content": "\"use client\"\n\nimport { useEffect, useMemo, useRef } from \"react\"\nimport createGlobe, { type COBEOptions } from \"cobe\"\nimport { useMotionValue, useSpring } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst MOVEMENT_DAMPING = 1400\nconst MARKER_HOLD_MS = 2200\nconst MARKER_TRANSITION_MS = 1800\n\nfunction easeInOutCubic(t: number): number {\n return t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2\n}\n\nfunction lerp(a: number, b: number, t: number): number {\n return a + (b - a) * t\n}\n\nfunction lerpAngle(a: number, b: number, t: number): number {\n let diff = b - a\n while (diff > Math.PI) diff -= 2 * Math.PI\n while (diff < -Math.PI) diff += 2 * Math.PI\n return a + diff * t\n}\n\nconst GLOBE_CONFIG: COBEOptions = {\n width: 800,\n height: 800,\n devicePixelRatio: 2,\n phi: 0,\n theta: 0.3,\n dark: 0,\n diffuse: 0.4,\n mapSamples: 16000,\n mapBrightness: 1.2,\n baseColor: [1, 1, 1],\n markerColor: [251 / 255, 100 / 255, 21 / 255],\n glowColor: [1, 1, 1],\n markers: [\n { location: [14.5995, 120.9842], size: 0.03 },\n { location: [19.076, 72.8777], size: 0.1 },\n { location: [23.8103, 90.4125], size: 0.05 },\n { location: [30.0444, 31.2357], size: 0.07 },\n { location: [39.9042, 116.4074], size: 0.08 },\n { location: [-23.5505, -46.6333], size: 0.1 },\n { location: [19.4326, -99.1332], size: 0.1 },\n { location: [40.7128, -74.006], size: 0.1 },\n { location: [34.6937, 135.5022], size: 0.05 },\n { location: [41.0082, 28.9784], size: 0.06 },\n ],\n}\n\nexport function Globe({\n className,\n config = GLOBE_CONFIG,\n autoRotate = true,\n interactive = true,\n panBetweenMarkers = false,\n cycleMarkers = false,\n centerMarkerLatitude = false,\n playing = true,\n markerHoldMs = MARKER_HOLD_MS,\n markerTransitionMs = MARKER_TRANSITION_MS,\n}: {\n className?: string\n config?: COBEOptions\n autoRotate?: boolean\n interactive?: boolean\n /** @deprecated Usa cycleMarkers */\n panBetweenMarkers?: boolean\n /** Centra cada marker uno por uno con pausa y transición suave. */\n cycleMarkers?: boolean\n /** Inclina el globo para dejar la latitud de cada marker en el centro. */\n centerMarkerLatitude?: boolean\n /** Si es false, el globo se queda en el fotograma actual y no avanza el tour. */\n playing?: boolean\n markerHoldMs?: number\n markerTransitionMs?: number\n}) {\n const canvasRef = useRef<HTMLCanvasElement>(null)\n const playingRef = useRef(playing)\n playingRef.current = playing\n const phiRef = useRef(config.phi ?? 0)\n const widthRef = useRef(0)\n const pointerInteracting = useRef<number | null>(null)\n const pointerInteractionMovement = useRef(0)\n\n const shouldCycleMarkers = cycleMarkers || panBetweenMarkers\n\n const fixedTheta = config.theta ?? 0.22\n\n const markerViews = useMemo((): Array<{ phi: number; theta: number }> | null => {\n if (!shouldCycleMarkers || !config.markers?.length) return null\n return config.markers.map((marker) => ({\n phi: Math.PI - ((marker.location[1] * Math.PI) / 180 - Math.PI / 2),\n theta: centerMarkerLatitude\n ? (marker.location[0] * Math.PI) / 180\n : fixedTheta,\n }))\n }, [shouldCycleMarkers, centerMarkerLatitude, fixedTheta, config.markers])\n\n const markerPhiRange = useMemo((): [number, number] | null => {\n if (!panBetweenMarkers || cycleMarkers || !config.markers?.length) return null\n const phis = config.markers.map(\n (marker) => Math.PI - ((marker.location[1] * Math.PI) / 180 - Math.PI / 2),\n )\n return [Math.min(...phis), Math.max(...phis)]\n }, [panBetweenMarkers, cycleMarkers, config.markers])\n\n const r = useMotionValue(0)\n const rs = useSpring(r, {\n mass: 1,\n damping: 30,\n stiffness: 100,\n })\n\n const updatePointerInteraction = (value: number | null) => {\n if (!interactive) return\n pointerInteracting.current = value\n if (canvasRef.current) {\n canvasRef.current.style.cursor = value !== null ? \"grabbing\" : \"grab\"\n }\n }\n\n const updateMovement = (clientX: number) => {\n if (!interactive) return\n if (pointerInteracting.current !== null) {\n const delta = clientX - pointerInteracting.current\n pointerInteractionMovement.current = delta\n r.set(r.get() + delta / MOVEMENT_DAMPING)\n }\n }\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n\n const host = canvas.parentElement\n const onResize = () => {\n widthRef.current = canvas.offsetWidth\n }\n\n onResize()\n window.addEventListener(\"resize\", onResize)\n\n const resizeObserver = new ResizeObserver(onResize)\n resizeObserver.observe(canvas)\n\n const globe = createGlobe(canvas, {\n ...config,\n devicePixelRatio: config.devicePixelRatio ?? 2,\n width: widthRef.current,\n height: widthRef.current,\n })\n\n let frame = 0\n let elapsedMs = 0\n let lastNow = 0\n const render = () => {\n const now = Date.now()\n if (playingRef.current) {\n if (lastNow !== 0) elapsedMs += now - lastNow\n lastNow = now\n } else {\n lastNow = 0\n }\n\n const cssSize = widthRef.current\n let phi = interactive ? phiRef.current + rs.get() : (config.phi ?? 0)\n let theta = config.theta ?? 0.3\n\n if (shouldCycleMarkers && markerViews && markerViews.length > 0) {\n const segment = markerHoldMs + markerTransitionMs\n const elapsed = elapsedMs\n const index = Math.floor(elapsed / segment) % markerViews.length\n const local = elapsed % segment\n const from = markerViews[index % markerViews.length]\n const to = markerViews[(index + 1) % markerViews.length]\n if (from && to) {\n if (local < markerHoldMs) {\n phi = from.phi\n theta = from.theta\n } else {\n const t = easeInOutCubic((local - markerHoldMs) / markerTransitionMs)\n phi = lerpAngle(from.phi, to.phi, t)\n theta = lerp(from.theta, to.theta, t)\n }\n }\n } else if (panBetweenMarkers && markerPhiRange) {\n const [phiMin, phiMax] = markerPhiRange\n const t = Date.now() / 5200\n phi = phiMin + ((Math.sin(t) + 1) / 2) * (phiMax - phiMin)\n } else if (autoRotate && playingRef.current && !pointerInteracting.current) {\n phiRef.current += 0.005\n phi = interactive ? phiRef.current + rs.get() : (config.phi ?? 0)\n }\n\n globe.update({\n phi,\n theta,\n scale: config.scale ?? 1,\n width: cssSize,\n height: cssSize,\n })\n frame = requestAnimationFrame(render)\n }\n frame = requestAnimationFrame(render)\n\n setTimeout(() => {\n canvas.style.opacity = \"1\"\n }, 0)\n\n return () => {\n cancelAnimationFrame(frame)\n globe.destroy()\n const wrapper = canvas.parentElement\n if (wrapper && host && wrapper !== host) {\n host.insertBefore(canvas, wrapper)\n wrapper.remove()\n }\n window.removeEventListener(\"resize\", onResize)\n resizeObserver.disconnect()\n }\n }, [\n rs,\n config,\n autoRotate,\n interactive,\n panBetweenMarkers,\n cycleMarkers,\n centerMarkerLatitude,\n shouldCycleMarkers,\n markerViews,\n markerPhiRange,\n markerHoldMs,\n markerTransitionMs,\n ])\n\n return (\n <div\n className={cn(\n \"absolute inset-0 mx-auto aspect-square w-full max-w-150\",\n className\n )}\n >\n <canvas\n className={cn(\n \"size-full opacity-0 transition-opacity duration-500 contain-[layout_paint_size]\",\n interactive ? \"cursor-grab\" : \"cursor-default\",\n )}\n ref={canvasRef}\n onPointerDown={\n interactive\n ? (e) => {\n pointerInteracting.current = e.clientX\n updatePointerInteraction(e.clientX)\n }\n : undefined\n }\n onPointerUp={interactive ? () => updatePointerInteraction(null) : undefined}\n onPointerOut={interactive ? () => updatePointerInteraction(null) : undefined}\n onMouseMove={interactive ? (e) => updateMovement(e.clientX) : undefined}\n onTouchMove={\n interactive\n ? (e) => e.touches[0] && updateMovement(e.touches[0].clientX)\n : undefined\n }\n />\n </div>\n )\n}\n",
13
13
  "type": "registry:ui",
14
14
  "target": ""
15
15
  }
@@ -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,20 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "world-globe",
4
+ "type": "registry:ui",
5
+ "author": "Traky (https://traky.app)",
6
+ "dependencies": [
7
+ "cobe"
8
+ ],
9
+ "registryDependencies": [
10
+ "globe"
11
+ ],
12
+ "files": [
13
+ {
14
+ "path": "ui/world-globe.tsx",
15
+ "content": "\"use client\"\n\nimport { useEffect, useMemo, useRef, useState, type CSSProperties } from \"react\"\nimport type { COBEOptions } from \"cobe\"\n\nimport { useTheme } from \"@/components/theme-provider\"\nimport { cn } from \"@/lib/utils\"\nimport { Globe } from \"@/components/ui/globe\"\n\n/** Tour: Norteamérica → Suramérica → Europa → África → Asia → Oceanía. */\nconst MARKERS = [\n { id: \"cascade\", location: [49.2827, -123.1207] as [number, number], label: \"Cascade LLC\" },\n { id: \"logistic\", location: [41.8781, -87.6298] as [number, number], label: \"Logistic LLC\" },\n { id: \"alianza\", location: [19.4326, -99.1332] as [number, number], label: \"Alianza SA\" },\n { id: \"bolivar\", location: [4.711, -74.0721] as [number, number], label: \"Bolívar SAS\" },\n { id: \"minerva\", location: [-23.5505, -46.6333] as [number, number], label: \"Minerva SA\" },\n { id: \"cordillera\", location: [-33.4489, -70.6693] as [number, number], label: \"Cordillera SA\" },\n { id: \"iberica\", location: [40.4168, -3.7038] as [number, number], label: \"Ibérica SA\" },\n { id: \"avrora\", location: [55.7558, 37.6173] as [number, number], label: \"Avrora LLC\" },\n { id: \"meridian\", location: [6.5244, 3.3792] as [number, number], label: \"Meridian Ltd\" },\n { id: \"savanna\", location: [-26.2041, 28.0473] as [number, number], label: \"Savanna SA\" },\n { id: \"deccan\", location: [19.076, 72.8777] as [number, number], label: \"Deccan Ltd\" },\n { id: \"straits\", location: [1.3521, 103.8198] as [number, number], label: \"Straits Ltd\" },\n { id: \"huayun\", location: [31.2304, 121.4737] as [number, number], label: \"华运 Ltd\" },\n { id: \"harbour\", location: [-33.8688, 151.2093] as [number, number], label: \"Harbour Ltd\" },\n]\n\nconst ARCS = [\n {\n id: \"alianza-logistic\",\n from: [19.4326, -99.1332] as [number, number],\n to: [41.8781, -87.6298] as [number, number],\n unit: \"AWB\",\n fromValue: 120,\n toValue: 240,\n duration: 28000,\n },\n {\n id: \"iberica-avrora\",\n from: [40.4168, -3.7038] as [number, number],\n to: [55.7558, 37.6173] as [number, number],\n unit: \"BL\",\n fromValue: 100,\n toValue: 220,\n duration: 32000,\n },\n {\n id: \"meridian-savanna\",\n from: [6.5244, 3.3792] as [number, number],\n to: [-26.2041, 28.0473] as [number, number],\n unit: \"WR\",\n fromValue: 160,\n toValue: 280,\n duration: 36000,\n },\n {\n id: \"avrora-huayun\",\n from: [55.7558, 37.6173] as [number, number],\n to: [31.2304, 121.4737] as [number, number],\n unit: \"MAWB\",\n fromValue: 140,\n toValue: 260,\n duration: 30000,\n },\n {\n id: \"harbour-huayun\",\n from: [-33.8688, 151.2093] as [number, number],\n to: [31.2304, 121.4737] as [number, number],\n unit: \"AWB\",\n fromValue: 110,\n toValue: 230,\n duration: 34000,\n },\n {\n id: \"avrora-cascade\",\n from: [55.7558, 37.6173] as [number, number],\n to: [49.2827, -123.1207] as [number, number],\n unit: \"BL\",\n fromValue: 130,\n toValue: 250,\n duration: 31000,\n },\n {\n id: \"bolivar-minerva\",\n from: [4.711, -74.0721] as [number, number],\n to: [-23.5505, -46.6333] as [number, number],\n unit: \"WR\",\n fromValue: 150,\n toValue: 270,\n duration: 33000,\n },\n {\n id: \"deccan-straits\",\n from: [19.076, 72.8777] as [number, number],\n to: [1.3521, 103.8198] as [number, number],\n unit: \"AWB\",\n fromValue: 170,\n toValue: 260,\n duration: 29000,\n },\n]\n\n/** Azul oscuro de marca #000b27. Misma línea en tema claro y oscuro. */\nconst ARC_COLOR: [number, number, number] = [0, 0.043, 0.153]\n\nconst WORLD_GLOBE_CONFIG: COBEOptions = {\n width: 800,\n height: 800,\n devicePixelRatio: 2,\n phi: 0,\n theta: 0.2,\n dark: 0,\n diffuse: 1.5,\n mapSamples: 16000,\n mapBrightness: 10,\n baseColor: [1, 1, 1],\n markerColor: [0.647, 0.867, 0.392],\n glowColor: [0.94, 0.93, 0.91],\n markerElevation: 0.01,\n opacity: 1,\n markers: MARKERS.map((marker) => ({\n location: marker.location,\n size: 0.025,\n id: marker.id,\n })),\n arcs: ARCS.map((arc) => ({\n from: arc.from,\n to: arc.to,\n id: arc.id,\n })),\n arcColor: ARC_COLOR,\n arcWidth: 0.5,\n arcHeight: 0.25,\n}\n\nconst GLOW_LIGHT: [number, number, number] = [0.97, 0.97, 0.98]\nconst GLOW_DARK: [number, number, number] = [0.07, 0.075, 0.08]\n\nfunction anchoredStyle(anchor: string, visible: string): CSSProperties {\n return {\n positionAnchor: anchor,\n bottom: \"anchor(top)\",\n left: \"anchor(center)\",\n translate: \"-50% 0\",\n marginBottom: 8,\n opacity: `var(${visible}, 0)`,\n filter: `blur(var(${visible}, 10px))`,\n transition: \"opacity 0.8s, filter 0.8s\",\n }\n}\n\nfunction glowForTheme(theme: string): [number, number, number] {\n if (theme === \"dark\") return GLOW_DARK\n if (theme === \"light\") return GLOW_LIGHT\n if (typeof document !== \"undefined\" && document.documentElement.classList.contains(\"dark\")) {\n return GLOW_DARK\n }\n return GLOW_LIGHT\n}\n\n/** Globo listo para la página web. No edita empresas ni envíos. */\nexport function WorldGlobe({ className }: { className?: string }) {\n const rootRef = useRef<HTMLDivElement>(null)\n const playingRef = useRef(false)\n const [playing, setPlaying] = useState(false)\n const { theme } = useTheme()\n const glowColor = glowForTheme(theme)\n const config = useMemo(\n () => ({ ...WORLD_GLOBE_CONFIG, glowColor }),\n [glowColor],\n )\n const [lanes, setLanes] = useState(() =>\n ARCS.map((arc) => ({\n id: arc.id,\n unit: arc.unit,\n value: arc.fromValue,\n })),\n )\n\n useEffect(() => {\n const node = rootRef.current\n if (!node) return\n\n let onScreen = false\n const sync = () => {\n const next = onScreen && document.visibilityState === \"visible\"\n playingRef.current = next\n setPlaying(next)\n }\n const observer = new IntersectionObserver(\n ([entry]) => {\n onScreen = entry?.isIntersecting ?? false\n sync()\n },\n { threshold: 0.35 },\n )\n observer.observe(node)\n document.addEventListener(\"visibilitychange\", sync)\n return () => {\n observer.disconnect()\n document.removeEventListener(\"visibilitychange\", sync)\n }\n }, [])\n\n useEffect(() => {\n let frame = 0\n let elapsedMs = 0\n let lastNow = 0\n const shown = ARCS.map((arc) => arc.fromValue)\n\n const tick = (now: number) => {\n if (playingRef.current) {\n if (lastNow !== 0) elapsedMs += now - lastNow\n lastNow = now\n } else {\n lastNow = 0\n }\n\n let changed = false\n const next = ARCS.map((arc, index) => {\n const elapsed = elapsedMs / arc.duration\n const ping = elapsed % 2\n const t = ping < 1 ? ping : 2 - ping\n const eased = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2\n const value = Math.round(arc.fromValue + (arc.toValue - arc.fromValue) * eased)\n if (value !== shown[index]) {\n shown[index] = value\n changed = true\n }\n return value\n })\n if (changed) {\n setLanes((current) =>\n current.map((item, index) => ({ ...item, value: next[index] ?? item.value })),\n )\n }\n frame = requestAnimationFrame(tick)\n }\n\n frame = requestAnimationFrame(tick)\n return () => cancelAnimationFrame(frame)\n }, [])\n\n return (\n <div\n ref={rootRef}\n className={cn(\n \"relative mx-auto aspect-square w-full max-w-lg bg-background\",\n className,\n )}\n >\n <Globe\n config={config}\n autoRotate={false}\n interactive={false}\n cycleMarkers\n centerMarkerLatitude\n playing={playing}\n markerHoldMs={2400}\n markerTransitionMs={1800}\n className=\"max-w-full\"\n />\n {MARKERS.map((marker) => (\n <div\n key={marker.id}\n className=\"pointer-events-none absolute z-10 w-max max-w-36 rounded-sm bg-primary px-1.5 py-px font-mono text-[10px] tracking-normal text-[#000b27]\"\n style={anchoredStyle(`--cobe-${marker.id}`, `--cobe-visible-${marker.id}`)}\n >\n <span className=\"block truncate\">{marker.label}</span>\n <span className=\"absolute top-full left-1/2 -translate-x-1/2 border-[5px] border-transparent border-t-primary\" />\n </div>\n ))}\n {lanes.map((item) => (\n <div\n key={item.id}\n className=\"pointer-events-none absolute z-10 rounded bg-black px-2 py-[3px] font-mono text-[0.5rem] whitespace-nowrap text-white\"\n style={{\n positionAnchor: `--cobe-arc-${item.id}`,\n bottom: \"anchor(top)\",\n left: \"anchor(center)\",\n translate: \"-50% 0\",\n opacity: `var(--cobe-visible-arc-${item.id}, 0)`,\n filter: `blur(calc((1 - var(--cobe-visible-arc-${item.id}, 0)) * 8px))`,\n transition: \"opacity 0.3s, filter 0.3s\",\n }}\n >\n {item.value} {item.unit}/s\n </div>\n ))}\n </div>\n )\n}\n",
16
+ "type": "registry:ui",
17
+ "target": ""
18
+ }
19
+ ]
20
+ }
@@ -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",
@@ -763,6 +773,16 @@
763
773
  }
764
774
  ]
765
775
  },
776
+ {
777
+ "name": "world-globe",
778
+ "type": "registry:ui",
779
+ "files": [
780
+ {
781
+ "path": "json/world-globe.json",
782
+ "type": "registry:file"
783
+ }
784
+ ]
785
+ },
766
786
  {
767
787
  "name": "lib-utils",
768
788
  "type": "registry:lib",
@@ -793,6 +813,26 @@
793
813
  }
794
814
  ]
795
815
  },
816
+ {
817
+ "name": "lib-file-items",
818
+ "type": "registry:lib",
819
+ "files": [
820
+ {
821
+ "path": "json/lib-file-items.json",
822
+ "type": "registry:file"
823
+ }
824
+ ]
825
+ },
826
+ {
827
+ "name": "lib-file-manager-utils",
828
+ "type": "registry:lib",
829
+ "files": [
830
+ {
831
+ "path": "json/lib-file-manager-utils.json",
832
+ "type": "registry:file"
833
+ }
834
+ ]
835
+ },
796
836
  {
797
837
  "name": "lib-geo-city-codes",
798
838
  "type": "registry:lib",