import { Icon } from "./Icon"; import { useEffect, useState, type ReactNode } from "react"; import type { ImageData } from "../types"; /** Human-readable file size. */ export function formatSize(n: number) { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / 1024 / 1024).toFixed(1)} MB`; } const MIME_EXT: Record = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp", "image/bmp": "bmp", "image/svg+xml": "svg", "application/pdf": "pdf", "model/gltf-binary": "glb", }; /** File type label (extension) from a name or MIME type. */ export function fileType(name?: string, mime?: string): string { const ext = name?.match(/\.([a-z0-9]{1,6})$/i)?.[1]; if (ext) return ext.toUpperCase(); if (mime) return (MIME_EXT[mime] ?? mime.split("/").pop() ?? "").toUpperCase(); return ""; } export function basename(p: string) { return p.split(/[\\/]/).filter(Boolean).pop() ?? p; } /** Bytes of a base64 payload. */ /** URL of an image of a message: inline (data URL) or from the image store. */ export function imageSrc(img: ImageData) { return img.data ? `data:${img.media_type};base64,${img.data}` : `/api/blobs/${img.blob}`; } /** Size in bytes of an image of a message. */ export function imageSize(img: ImageData) { return img.data ? base64Size(img.data) : img.size; } export function base64Size(b64: string) { const pad = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0; return Math.floor((b64.length * 3) / 4) - pad; } interface Meta { size?: number; mime?: string; name?: string; } const cache = new Map>(); /** Size/type/name of a file URL in the app (project files via stat, others via HEAD). */ function loadMeta(url: string): Promise { let p = cache.get(url); if (!p) { const project = url.match(/^\/api\/files\/raw\?path=([^&]+)/); p = project ? fetch(`/api/files/stat?path=${project[1]}`, { credentials: "same-origin" }) .then((r) => (r.ok ? r.json() : {})) .then((j: any) => ({ size: j.size, mime: j.mime })) : fetch(url, { method: "HEAD", credentials: "same-origin" }).then((r) => { if (!r.ok) return {}; const len = r.headers.get("content-length"); const name = r.headers.get("content-disposition")?.match(/filename="?([^";]+)"?/)?.[1]; return { size: len ? Number(len) : undefined, mime: r.headers.get("content-type")?.split(";")[0], name }; }); p = p.catch(() => ({})); cache.set(url, p); } return p; } export function useFileMeta(url: string | undefined, known?: Meta): Meta { const [meta, setMeta] = useState(known ?? {}); useEffect(() => { if (!url || url.startsWith("data:") || known?.size !== undefined) return; let alive = true; // Bust the cache key per version param so rewritten files show the new size. loadMeta(url).then((m) => alive && setMeta((old) => ({ ...m, ...old, size: m.size ?? old.size }))); return () => { alive = false; }; }, [url, known?.size]); return meta; } /** * A file shown in the chat: preview with type (top left) and size (top right) * badges, file name below. */ export function FileFigure({ url, name, size, mime, children, className, }: { url?: string; name?: string; size?: number; mime?: string; children: ReactNode; className?: string; }) { const meta = useFileMeta(url, size !== undefined ? { size, mime, name } : undefined); const shownName = name ?? meta.name; const type = fileType(shownName, mime ?? meta.mime); const sz = size ?? meta.size; return (
{url ? ( {children} ) : ( children )} {type && {type}} {sz !== undefined && {formatSize(sz)}}
{shownName && (
{url ? ( {basename(shownName)} ) : ( basename(shownName) )}
)}
); } /** A non-previewable file: name with type and size as text. */ export function FileChip({ url, name, size, sizeText }: { url?: string; name: string; size?: number; sizeText?: string }) { const meta = useFileMeta(url, size !== undefined ? { size } : undefined); const sz = size ?? meta.size; const type = fileType(name, meta.mime); return ( {basename(name)} {type && {type}} {(sizeText || sz !== undefined) && {sizeText ?? formatSize(sz!)}} ); }