{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "file-thumbnail", "type": "registry:ui", "title": "File Thumbnail", "description": "Read-only square preview of a File. Images render via object-cover; PDFs render their first page via pdfjs; a spinner shows while either resolves. Adapts its corner radius to the global shape setting.", "dependencies": [ "pdfjs-dist", "tw-animate-css" ], "registryDependencies": [ "https://zeron-ui.vercel.app/r/surfaces.json", "https://zeron-ui.vercel.app/r/utils.json" ], "files": [ { "path": "packages/ui/src/components/file-thumbnail.tsx", "content": "\"use client\";\nimport { useEffect, useState } from \"react\";\nimport { cn } from \"@lib/utils\";\n// ─── Lazy pdfjs loader ────────────────────────────────────────────────────\n// Imports pdfjs-dist on first PDF, caches the module, and points the worker\n// at the matching CDN build. Consumers don't need bundler-side worker config.\ntype PdfjsModule = typeof import(\"pdfjs-dist\");\nlet pdfjsPromise: Promise | null = null;\nasync function loadPdfjs(): Promise {\n if (!pdfjsPromise) {\n pdfjsPromise = import(\"pdfjs-dist\").then((mod) => {\n if (!mod.GlobalWorkerOptions.workerSrc) {\n mod.GlobalWorkerOptions.workerSrc = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${mod.version}/build/pdf.worker.min.mjs`;\n }\n return mod;\n });\n }\n return pdfjsPromise;\n}\nasync function renderPdfFirstPage(file: File, targetWidth: number): Promise {\n const pdfjs = await loadPdfjs();\n const buffer = await file.arrayBuffer();\n const pdf = await pdfjs.getDocument({ data: buffer }).promise;\n const page = await pdf.getPage(1);\n const baseViewport = page.getViewport({ scale: 1 });\n const scale = (targetWidth * 2) / baseViewport.width; // 2× for retina\n const viewport = page.getViewport({ scale });\n const canvas = document.createElement(\"canvas\");\n canvas.width = viewport.width;\n canvas.height = viewport.height;\n await page.render({ canvas, viewport }).promise;\n return canvas.toDataURL(\"image/png\");\n}\n// ─── File thumbnail ───────────────────────────────────────────────────────\n// Read-only square preview of a File. Images use object-cover via\n// `URL.createObjectURL`; PDFs render the first page via pdfjs; while either is\n// resolving a spinner is shown. Self-contained (border + surface + sizing) so\n// it can be reused both inside the composer's preview row and to render\n// already-sent attachments in a chat transcript.\ninterface FileThumbnailProps {\n file: File;\n /** Side length of the square thumbnail in pixels. */\n size: number;\n className?: string;\n}\nfunction FileThumbnail({ file, size, className }: FileThumbnailProps) {\n const isImage = file.type.startsWith(\"image/\");\n const isPdf = file.type === \"application/pdf\";\n // Create blob URL inside an effect (NOT useMemo) so the cleanup-revoke\n // and the URL-creation stay in sync. In React 18 StrictMode dev, a\n // useMemo-created URL gets revoked by the simulated effect-cleanup but\n // useMemo doesn't re-run on the simulated re-mount (no re-render happens),\n // leaving the DOM with a stale, revoked `blob:` URL — broken image.\n // Putting both in the same effect means the simulated re-mount creates a\n // fresh URL and updates state. The one-frame \"before URL\" state is\n // covered by the muted surface (no fallback icon shown for images), so the\n // transition is visually clean.\n const [imageUrl, setImageUrl] = useState(null);\n useEffect(() => {\n if (!isImage) {\n // Clear stale state if the `file` prop swaps type on the same mount —\n // otherwise a revoked blob URL would keep winning over the new preview.\n setImageUrl(null);\n return;\n }\n const url = URL.createObjectURL(file);\n setImageUrl(url);\n return () => URL.revokeObjectURL(url);\n }, [isImage, file]);\n // PDFs need async rendering — loading flash is unavoidable for the first\n // ~100–300ms while pdfjs loads. Falls back to the generic icon on error\n // (corrupt/password-protected file, CDN worker blocked).\n const [pdfUrl, setPdfUrl] = useState(null);\n const [pdfError, setPdfError] = useState(false);\n useEffect(() => {\n setPdfError(false);\n if (!isPdf) {\n setPdfUrl(null);\n return;\n }\n let cancelled = false;\n renderPdfFirstPage(file, size)\n .then((url) => {\n if (!cancelled)\n setPdfUrl(url);\n })\n .catch(() => {\n if (!cancelled)\n setPdfError(true);\n });\n return () => {\n cancelled = true;\n };\n }, [file, isPdf, size]);\n const previewUrl = imageUrl ?? pdfUrl;\n // Spinner only while a preview is genuinely pending; anything that can't\n // produce one (failed PDF, unsupported type) gets the generic icon instead.\n const isPending = (isImage && !imageUrl) || (isPdf && !pdfUrl && !pdfError);\n return (
\n {previewUrl ? (\n // eslint-disable-next-line @next/next/no-img-element\n {file.name}) : isPending ? (\n // Circular spinner while we wait for the preview to be ready.\n // Used for both images (brief URL-creation gap) and PDFs (longer\n // pdfjs render). The thin ring is mostly subtle (border-border)\n // with one quadrant accented (border-t-muted-foreground) so the\n // `animate-spin` rotation reads as a moving arc.\n
\n
\n
) : (\n // Generic document glyph for files with no renderable preview.\n // Inline SVG (not the icon system) so the thumbnail stays\n // self-contained for registry consumers.\n
\n \n \n \n \n
)}\n
);\n}\nexport { FileThumbnail, loadPdfjs, renderPdfFirstPage };\nexport type { FileThumbnailProps };\n", "type": "registry:ui", "target": "components/ui/file-thumbnail.tsx" } ] }