@traky-app/ui 1.6.6 → 1.6.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/RELEASE.md +27 -0
- package/package.json +3 -2
- package/registry/traky/json/file-manager.json +25 -0
- package/registry/traky/json/internal-chat-panel.json +2 -1
- package/registry/traky/json/lib-file-items.json +14 -0
- package/registry/traky/json/lib-file-manager-utils.json +14 -0
- package/registry/traky/lib/file-items.ts +99 -0
- package/registry/traky/lib/file-manager-utils.ts +116 -0
- package/registry/traky/registry.json +30 -0
- package/registry/traky/ui/file-manager.tsx +646 -0
- package/registry/traky/ui/internal-chat-panel.tsx +44 -6
- package/src/lib/file-items.ts +99 -0
- package/src/lib/file-manager-utils.test.tsx +71 -0
- package/src/lib/file-manager-utils.ts +116 -0
- package/src/ui/file-manager.tsx +646 -0
- package/src/ui/internal-chat-panel.tsx +44 -6
|
@@ -82,6 +82,7 @@ import {
|
|
|
82
82
|
InputGroupTextarea,
|
|
83
83
|
} from "@/components/ui/input-group";
|
|
84
84
|
import { Input } from "@/components/ui/input";
|
|
85
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
85
86
|
import {
|
|
86
87
|
Message,
|
|
87
88
|
MessageAvatar,
|
|
@@ -219,6 +220,8 @@ export type InternalChatPanelLabels = {
|
|
|
219
220
|
supportChat: string;
|
|
220
221
|
supportChatTooltip: string;
|
|
221
222
|
supportRoomDescription: string;
|
|
223
|
+
reconnectingMessages: string;
|
|
224
|
+
connectionOffline: string;
|
|
222
225
|
};
|
|
223
226
|
|
|
224
227
|
const DEFAULT_LABELS: InternalChatPanelLabels = {
|
|
@@ -240,6 +243,10 @@ const DEFAULT_LABELS: InternalChatPanelLabels = {
|
|
|
240
243
|
noRoomTitle: "Sin sala",
|
|
241
244
|
noRoomDescription: "Crea una sala con el botón + a la izquierda.",
|
|
242
245
|
loadingMessages: "Cargando mensajes…",
|
|
246
|
+
reconnectingMessages:
|
|
247
|
+
"Reconectando el chat… Si tarda, cierra y vuelve a abrir el panel.",
|
|
248
|
+
connectionOffline:
|
|
249
|
+
"Sin conexión en tiempo real. Cierra el chat un momento o recarga la página.",
|
|
243
250
|
loadMessagesErrorTitle: "No se pudieron cargar los mensajes",
|
|
244
251
|
loadMessagesErrorFallback:
|
|
245
252
|
"Revisa que la API esté en marcha y que el módulo de chat interno esté migrado.",
|
|
@@ -1283,6 +1290,10 @@ export type InternalChatPanelProps = {
|
|
|
1283
1290
|
* y no bloquea clics en el resto del admin (sin overlay a pantalla completa).
|
|
1284
1291
|
*/
|
|
1285
1292
|
embedded?: boolean;
|
|
1293
|
+
/** Tras pestaña suspendida o pérdida del WebSocket. */
|
|
1294
|
+
realtimeStatus?: "connected" | "reconnecting" | "offline";
|
|
1295
|
+
/** Si es false, no monta trigger ni drawer (flag del host). */
|
|
1296
|
+
featureEnabled?: boolean;
|
|
1286
1297
|
};
|
|
1287
1298
|
|
|
1288
1299
|
function InternalChatPanel({
|
|
@@ -1326,9 +1337,27 @@ function InternalChatPanel({
|
|
|
1326
1337
|
defaultOpen = false,
|
|
1327
1338
|
hideTrigger = false,
|
|
1328
1339
|
embedded = false,
|
|
1340
|
+
realtimeStatus = "connected",
|
|
1341
|
+
featureEnabled = true,
|
|
1329
1342
|
}: InternalChatPanelProps) {
|
|
1343
|
+
if (!featureEnabled) {
|
|
1344
|
+
return null;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1330
1347
|
const labels = { ...DEFAULT_LABELS, ...labelsPartial };
|
|
1331
1348
|
|
|
1349
|
+
const displayMessagesStatus: InternalChatMessagesStatus =
|
|
1350
|
+
realtimeStatus === "reconnecting"
|
|
1351
|
+
? "loading"
|
|
1352
|
+
: realtimeStatus === "offline" && messagesStatus === "ready"
|
|
1353
|
+
? "error"
|
|
1354
|
+
: messagesStatus;
|
|
1355
|
+
|
|
1356
|
+
const displayMessagesError =
|
|
1357
|
+
realtimeStatus === "offline" && messagesStatus === "ready"
|
|
1358
|
+
? labels.connectionOffline
|
|
1359
|
+
: messagesError;
|
|
1360
|
+
|
|
1332
1361
|
const [open, setOpen] = useState(defaultOpen);
|
|
1333
1362
|
const [draft, setDraft] = useState("");
|
|
1334
1363
|
const [createOpen, setCreateOpen] = useState(false);
|
|
@@ -2063,12 +2092,21 @@ function InternalChatPanel({
|
|
|
2063
2092
|
</EmptyDescription>
|
|
2064
2093
|
</EmptyHeader>
|
|
2065
2094
|
</Empty>
|
|
2066
|
-
) :
|
|
2067
|
-
<div className="flex h-full flex-col items-center justify-center gap-
|
|
2095
|
+
) : displayMessagesStatus === "loading" ? (
|
|
2096
|
+
<div className="flex h-full flex-col items-center justify-center gap-4 px-6 text-muted-foreground">
|
|
2068
2097
|
<IconLoader2 className="size-6 animate-spin" aria-hidden />
|
|
2069
|
-
<span className="text-sm">
|
|
2098
|
+
<span className="text-center text-sm">
|
|
2099
|
+
{realtimeStatus === "reconnecting"
|
|
2100
|
+
? labels.reconnectingMessages
|
|
2101
|
+
: labels.loadingMessages}
|
|
2102
|
+
</span>
|
|
2103
|
+
<div className="flex w-full max-w-sm flex-col gap-2">
|
|
2104
|
+
<Skeleton className="h-10 w-3/4 self-end rounded-xl" />
|
|
2105
|
+
<Skeleton className="h-14 w-full rounded-xl" />
|
|
2106
|
+
<Skeleton className="h-10 w-2/3 rounded-xl" />
|
|
2107
|
+
</div>
|
|
2070
2108
|
</div>
|
|
2071
|
-
) :
|
|
2109
|
+
) : displayMessagesStatus === "error" ? (
|
|
2072
2110
|
<Empty className="h-full border-0">
|
|
2073
2111
|
<EmptyHeader>
|
|
2074
2112
|
<EmptyMedia variant="icon">
|
|
@@ -2076,11 +2114,11 @@ function InternalChatPanel({
|
|
|
2076
2114
|
</EmptyMedia>
|
|
2077
2115
|
<EmptyTitle>{labels.loadMessagesErrorTitle}</EmptyTitle>
|
|
2078
2116
|
<EmptyDescription className="text-left">
|
|
2079
|
-
{
|
|
2117
|
+
{displayMessagesError ?? labels.loadMessagesErrorFallback}
|
|
2080
2118
|
</EmptyDescription>
|
|
2081
2119
|
</EmptyHeader>
|
|
2082
2120
|
</Empty>
|
|
2083
|
-
) :
|
|
2121
|
+
) : displayMessagesStatus === "empty" ? (
|
|
2084
2122
|
<Empty className="h-full border-0">
|
|
2085
2123
|
<EmptyHeader>
|
|
2086
2124
|
<EmptyMedia variant="icon">
|
|
@@ -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,71 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
getFileDisplayName,
|
|
4
|
+
isAcceptedFile,
|
|
5
|
+
isImageFile,
|
|
6
|
+
isPdfFile,
|
|
7
|
+
toFileItem,
|
|
8
|
+
} from "./file-manager-utils"
|
|
9
|
+
import { normalizeFileItems } from "./file-items"
|
|
10
|
+
|
|
11
|
+
describe("file-manager-utils", () => {
|
|
12
|
+
it("detecta imagen, pdf y tipos aceptados", () => {
|
|
13
|
+
expect(
|
|
14
|
+
isImageFile({ name: "a.png", url: "https://cdn.example/a.png", type: "" }),
|
|
15
|
+
).toBe(true)
|
|
16
|
+
expect(
|
|
17
|
+
isPdfFile({
|
|
18
|
+
name: "doc.pdf",
|
|
19
|
+
url: "https://cdn.example/doc.pdf",
|
|
20
|
+
type: "",
|
|
21
|
+
}),
|
|
22
|
+
).toBe(true)
|
|
23
|
+
expect(
|
|
24
|
+
isAcceptedFile(
|
|
25
|
+
new File(["x"], "foto.jpg", { type: "image/jpeg" }),
|
|
26
|
+
false,
|
|
27
|
+
),
|
|
28
|
+
).toBe(true)
|
|
29
|
+
expect(
|
|
30
|
+
isAcceptedFile(new File(["x"], "clip.mp4", { type: "video/mp4" }), false),
|
|
31
|
+
).toBe(false)
|
|
32
|
+
expect(
|
|
33
|
+
isAcceptedFile(new File(["x"], "clip.mp4", { type: "video/mp4" }), true),
|
|
34
|
+
).toBe(true)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("arma FileItem desde el archivo subido", () => {
|
|
38
|
+
const file = new File(["x"], "bodega.webp", { type: "image/webp" })
|
|
39
|
+
expect(toFileItem(file, "https://cdn.example/bodega.webp", "traky")).toEqual({
|
|
40
|
+
name: "bodega.webp",
|
|
41
|
+
url: "https://cdn.example/bodega.webp",
|
|
42
|
+
type: "image/webp",
|
|
43
|
+
provider: "traky",
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("acorta nombres largos conservando extensión", () => {
|
|
48
|
+
expect(
|
|
49
|
+
getFileDisplayName(
|
|
50
|
+
{
|
|
51
|
+
name: "foto-de-bodega-muy-larga-para-mostrar.png",
|
|
52
|
+
url: "https://cdn.example/x.png",
|
|
53
|
+
type: "image/png",
|
|
54
|
+
},
|
|
55
|
+
20,
|
|
56
|
+
),
|
|
57
|
+
).toMatch(/…\.png$/)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it("normaliza JSON y conserva provider explícito", () => {
|
|
61
|
+
const files = normalizeFileItems({
|
|
62
|
+
url: "https://cdn.example.com/foto.jpg",
|
|
63
|
+
name: "foto.jpg",
|
|
64
|
+
provider: "legacy",
|
|
65
|
+
})
|
|
66
|
+
expect(files[0]).toMatchObject({
|
|
67
|
+
url: "https://cdn.example.com/foto.jpg",
|
|
68
|
+
provider: "legacy",
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -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
|
+
}
|