diff --git a/components/multi-file-viewer-uploader/README.md b/components/multi-file-viewer-uploader/README.md new file mode 100644 index 0000000..333381b --- /dev/null +++ b/components/multi-file-viewer-uploader/README.md @@ -0,0 +1,194 @@ +# Multi File Viewer + +A Retool custom component for uploading and previewing multiple files, including images, PDFs, video, audio, text, JSON, CSV, and Excel files. + +## What `{{ multiFileViewer1 }}` returns + +When you reference the component directly in Retool: + +```js +{{ multiFileViewer1 }} +``` + +it returns the component state object, which looks like this shape: + +```js +{ + pluginType: "DynamicWidget_MultiFileUploaderAndViewer_MultiFileViewer", + heightType: "auto", + files: [], + selectedFile: { + id: "", + name: "", + url: "", + previewUrl: "", + mimeType: "", + label: "", + kind: "unknown" + }, + acceptedKind: "", + collectionUuid: "...", + allowMixedFileTypes: true, + id: "multiFileViewer1" +} +``` + +Depending on uploads and selection, `files` and `selectedFile` will contain real values. + +## Recommended Retool usage + +### Get the full component object + +```js +{{ multiFileViewer1 }} +``` + +### Get all uploaded files + +```js +{{ multiFileViewer1.files }} +``` + +### Get the currently selected file + +```js +{{ multiFileViewer1.selectedFile }} +``` + +### Get the selected file name + +```js +{{ multiFileViewer1.selectedFile.name }} +``` + +### Get the selected file MIME type + +```js +{{ multiFileViewer1.selectedFile.mimeType }} +``` + +### Get the selected file preview URL + +```js +{{ multiFileViewer1.selectedFile.previewUrl }} +``` + +### Get the selected file kind + +```js +{{ multiFileViewer1.selectedFile.kind }} +``` + +### Check whether mixed file types are enabled + +```js +{{ multiFileViewer1.allowMixedFileTypes }} +``` + +### Get the accepted upload kind for single-type mode + +```js +{{ multiFileViewer1.acceptedKind }} +``` + +## File object structure + +Each item inside `multiFileViewer1.files` follows this shape: + +```js +{ + id: "file-id", + name: "example.pdf", + url: "blob:...", + previewUrl: "blob:...", + mimeType: "application/pdf", + label: "PDF", + kind: "pdf", + objectUrl: "blob:...", + base64: "..." +} +``` + +## Selected file object structure + +`multiFileViewer1.selectedFile` follows this shape: + +```js +{ + id: "file-id", + name: "example.pdf", + url: "blob:...", + previewUrl: "blob:...", + mimeType: "application/pdf", + label: "PDF", + kind: "pdf" +} +``` + +## Common examples + +### Send the selected file to a query + +```js +{{ + { + name: multiFileViewer1.selectedFile.name, + type: multiFileViewer1.selectedFile.mimeType, + kind: multiFileViewer1.selectedFile.kind, + previewUrl: multiFileViewer1.selectedFile.previewUrl + } +}} +``` + +### Send all files to a query + +```js +{{ multiFileViewer1.files }} +``` + +### Get only file names + +```js +{{ multiFileViewer1.files.map(file => file.name) }} +``` + +### Get only base64 values + +```js +{{ multiFileViewer1.files.map(file => file.base64) }} +``` + +### Get the first uploaded file + +```js +{{ multiFileViewer1.files[0] }} +``` + +## Notes + +- `files` is an array of uploaded files. +- `selectedFile` is the file currently chosen in the sidebar. +- `acceptedKind` is used when mixed file uploads are turned off. +- `allowMixedFileTypes` is the checkbox-controlled setting for mixed uploads. +- `heightType: "auto"` means the component is configured to work with auto height in Retool. +- `previewUrl` and `url` are usually blob URLs for local uploaded files. + +## Best practice + +In most Retool queries and transformers, use nested access instead of the whole object when possible. + +Good: + +```js +{{ multiFileViewer1.selectedFile }} +{{ multiFileViewer1.files }} +{{ multiFileViewer1.allowMixedFileTypes }} +``` + +Less useful on its own: + +```js +{{ multiFileViewer1 }} +``` + +because that returns the entire component state wrapper, not just the selected file. diff --git a/components/multi-file-viewer-uploader/cover.png b/components/multi-file-viewer-uploader/cover.png new file mode 100644 index 0000000..8fc47b7 Binary files /dev/null and b/components/multi-file-viewer-uploader/cover.png differ diff --git a/components/multi-file-viewer-uploader/metadata.json b/components/multi-file-viewer-uploader/metadata.json new file mode 100644 index 0000000..ceba0d0 --- /dev/null +++ b/components/multi-file-viewer-uploader/metadata.json @@ -0,0 +1,14 @@ +{ + "id": "multi-file-viewer", + "title": "Multi File Viewer", + "author": "@widlestudiollp", + "shortDescription": "A responsive Retool custom component for uploading and previewing images, PDFs, spreadsheets, CSV, text, JSON, audio, video, and office files.", + "tags": [ + "File Upload", + "File Preview", + "Retool", + "PDF", + "Spreadsheets", + "Media Viewer" + ] +} \ No newline at end of file diff --git a/components/multi-file-viewer-uploader/package.json b/components/multi-file-viewer-uploader/package.json new file mode 100644 index 0000000..88cf35e --- /dev/null +++ b/components/multi-file-viewer-uploader/package.json @@ -0,0 +1,48 @@ +{ + "name": "my-react-app", + "version": "0.1.0", + "private": true, + "dependencies": { + "@tryretool/custom-component-support": "latest", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "xlsx": "^0.18.5" + }, + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "dev": "npx retool-ccl dev", + "deploy": "npx retool-ccl deploy", + "test": "vitest" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@types/react": "^18.2.55", + "@typescript-eslint/eslint-plugin": "^7.3.1", + "@typescript-eslint/parser": "^7.3.1", + "eslint": "^8.57.0", + "eslint-plugin-react": "^7.34.1", + "postcss-modules": "^6.0.0", + "prettier": "^3.0.3", + "vitest": "^4.0.17" + }, + "retoolCustomComponentLibraryConfig": { + "name": "MultileFileUploaderAndViewer", + "label": "Multile file uploader and viewer", + "description": "We can view multiple files that we uploaded", + "entryPoint": "src/index.tsx", + "outputPath": "dist" + } +} \ No newline at end of file diff --git a/components/multi-file-viewer-uploader/src/components/index.css b/components/multi-file-viewer-uploader/src/components/index.css new file mode 100644 index 0000000..f506e84 --- /dev/null +++ b/components/multi-file-viewer-uploader/src/components/index.css @@ -0,0 +1,497 @@ +.mfv-root { + width: 100%; + display: flex; + flex-direction: column; + background: #ffffff; + border: 1px solid #dbe3f0; + border-radius: 16px; + overflow: hidden; + box-sizing: border-box; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +.mfv-topbar { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + padding: 18px 20px 14px; + border-bottom: 1px solid #e5e7eb; + background: linear-gradient(180deg, #f8fbff 0%, #f8fafc 100%); + flex-wrap: wrap; +} + +.mfv-topbar-text { + min-width: 0; + flex: 1 1 280px; +} + +.mfv-title { + font-size: 18px; + font-weight: 800; + color: #0f172a; + line-height: 1.25; +} + +.mfv-subtitle { + margin-top: 4px; + font-size: 12.5px; + color: #64748b; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.mfv-topbar-right { + display: flex; + gap: 8px; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + min-width: 0; + flex: 1 1 260px; +} + +.mfv-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 30px; + max-width: 180px; + padding: 0 12px; + border-radius: 999px; + border: 1px solid #dbe3f0; + background: #ffffff; + font-size: 13px; + font-weight: 700; + color: #475569; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + box-sizing: border-box; +} + +.mfv-clear-btn { + min-height: 32px; + max-width: 180px; + padding: 0 12px; + border-radius: 999px; + border: 1px solid #dbe3f0; + background: #ffffff; + color: #334155; + font-weight: 700; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; +} + +.mfv-clear-btn:hover { + background: #f8fafc; + border-color: #cbd5e1; +} + +/* Upload area */ + +.mfv-upload-zone { + margin: 12px 20px; + border: 2px dashed #cbd5e1; + border-radius: 14px; + background: #f8fafc; + padding: 20px; + display: flex; + align-items: flex-start; + gap: 14px; + cursor: pointer; + transition: all 0.15s ease; + box-sizing: border-box; +} + +.mfv-upload-zone:hover { + border-color: #94a3b8; + background: #f8fbff; +} + +.mfv-upload-zone.is-dragover { + border-color: #2563eb; + background: #eff6ff; +} + +.mfv-hidden-input { + display: none; +} + +.mfv-upload-icon { + font-size: 24px; + line-height: 1; + flex: 0 0 auto; +} + +.mfv-upload-copy { + min-width: 0; + flex: 1 1 auto; +} + +.mfv-upload-title { + font-size: 13px; + font-weight: 700; + color: #334155; + line-height: 1.4; + overflow-wrap: anywhere; +} + +.mfv-upload-subtitle { + font-size: 11.5px; + color: #94a3b8; + margin-top: 3px; + line-height: 1.5; + overflow-wrap: anywhere; +} + +/* Main body */ + +.mfv-body { + display: grid; + min-height: 0; + width: 100%; + box-sizing: border-box; +} + +.mfv-body.with-sidebar { + grid-template-columns: minmax(240px, 280px) minmax(0, 1fr); +} + +.mfv-body.no-sidebar { + grid-template-columns: minmax(0, 1fr); +} + +.mfv-sidebar { + border-right: 1px solid #e5e7eb; + padding: 16px 14px; + background: #fafafa; + box-sizing: border-box; + min-width: 0; +} + +.mfv-sidebar-label { + font-size: 12px; + font-weight: 800; + letter-spacing: 0.08em; + color: #94a3b8; + margin-bottom: 12px; +} + +.mfv-file-row-wrap { + position: relative; + margin-bottom: 8px; +} + +.mfv-file-row { + width: 100%; + display: flex; + align-items: center; + gap: 10px; + padding: 10px 42px 10px 12px; + border: 1px solid #e2e8f0; + border-radius: 14px; + cursor: pointer; + text-align: left; + background: #ffffff; + transition: all 0.15s ease; + box-sizing: border-box; + min-width: 0; +} + +.mfv-file-row:hover { + border-color: #cbd5e1; + background: #f8fafc; +} + +.mfv-file-row.active { + border-color: #2563eb; + background: #eff6ff; + box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.08); +} + +.mfv-icon-wrap { + width: 38px; + height: 38px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + background: #f1f5f9; + font-size: 18px; + flex: 0 0 38px; +} + +.mfv-file-meta { + min-width: 0; + flex: 1 1 auto; + overflow: hidden; +} + +.mfv-file-name { + font-size: 13px; + font-weight: 700; + color: #0f172a; + line-height: 1.35; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mfv-file-label { + font-size: 11.5px; + color: #64748b; + margin-top: 3px; + line-height: 1.35; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mfv-remove-btn { + position: absolute; + top: 6px; + right: 6px; + width: 22px; + height: 22px; + border-radius: 999px; + border: 1px solid #e2e8f0; + background: #ffffff; + color: #64748b; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + z-index: 2; +} + +.mfv-remove-btn:hover { + background: #f8fafc; + color: #0f172a; +} + +.mfv-viewer { + min-width: 0; + padding: 16px; + background: #ffffff; + box-sizing: border-box; +} + +.mfv-center { + min-height: 240px; + width: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.mfv-fallback-card { + width: min(100%, 460px); + text-align: center; + border: 1px solid #e5e7eb; + border-radius: 16px; + padding: 22px 24px; + background: #ffffff; + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04); + box-sizing: border-box; +} + +.mfv-fallback-title { + font-size: 18px; + font-weight: 800; + color: #0f172a; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.mfv-fallback-subtitle { + font-size: 13px; + color: #475569; + margin-top: 10px; + line-height: 1.6; + overflow-wrap: anywhere; +} + +.mfv-hero-image-wrap { + width: 100%; + border-radius: 16px; + overflow: hidden; + border: 1px solid #e5e7eb; + background: #f8fafc; + box-sizing: border-box; +} + +.mfv-hero-image { + display: block; + width: 100%; + height: auto; + max-height: min(70vh, 720px); + object-fit: contain; + background: #f8fafc; +} + +.mfv-embed-wrap { + width: 100%; + border: 1px solid #e5e7eb; + border-radius: 14px; + overflow: hidden; + background: #ffffff; + box-sizing: border-box; +} + +.mfv-iframe { + width: 100%; + height: clamp(420px, 65vh, 900px); + border: 0; + display: block; + background: #ffffff; +} + +.mfv-media { + width: 100%; + height: auto; + max-height: min(70vh, 720px); + display: block; + background: #000000; +} + +.mfv-audio-card { + width: min(100%, 560px); + border: 1px solid #e5e7eb; + border-radius: 16px; + padding: 20px; + background: #ffffff; + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04); + box-sizing: border-box; +} + +.mfv-audio-title { + font-size: 16px; + font-weight: 800; + color: #0f172a; + margin-bottom: 10px; + line-height: 1.4; + overflow-wrap: anywhere; +} + +.mfv-audio { + width: 100%; +} + +.mfv-code { + margin: 0; + width: 100%; + box-sizing: border-box; + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: anywhere; + font-size: 12.5px; + line-height: 1.6; + color: #0f172a; + background: #f8fafc; + border: 1px solid #e5e7eb; + border-radius: 14px; + padding: 16px; + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; +} + +.mfv-table-wrap { + width: 100%; + overflow: auto; + border: 1px solid #e5e7eb; + border-radius: 14px; + background: #ffffff; + box-sizing: border-box; +} + +.mfv-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; +} + +.mfv-table th { + position: sticky; + top: 0; + background: #f8fafc; + color: #334155; + font-weight: 800; + text-align: left; + padding: 10px 12px; + border-bottom: 1px solid #e5e7eb; + white-space: nowrap; +} + +.mfv-table td { + padding: 10px 12px; + border-bottom: 1px solid #f1f5f9; + color: #0f172a; + vertical-align: top; + overflow-wrap: anywhere; +} + +/* Responsive behavior */ + +@media (max-width: 1100px) { + .mfv-body.with-sidebar { + grid-template-columns: minmax(220px, 260px) minmax(0, 1fr); + } + + .mfv-pill, + .mfv-clear-btn { + max-width: 150px; + } +} + +@media (max-width: 860px) { + .mfv-topbar { + padding: 16px; + } + + .mfv-upload-zone { + margin: 12px 16px; + padding: 16px; + } + + .mfv-viewer { + padding: 14px; + } + + .mfv-body.with-sidebar { + grid-template-columns: 1fr; + } + + .mfv-sidebar { + border-right: 0; + border-bottom: 1px solid #e5e7eb; + } + + .mfv-pill, + .mfv-clear-btn { + max-width: none; + } +} + +@media (max-width: 640px) { + .mfv-topbar-right { + justify-content: flex-start; + } + + .mfv-upload-zone { + flex-direction: row; + align-items: flex-start; + } + + .mfv-iframe { + height: clamp(320px, 55vh, 720px); + } + + .mfv-fallback-card, + .mfv-audio-card { + padding: 18px; + } +} \ No newline at end of file diff --git a/components/multi-file-viewer-uploader/src/components/index.tsx b/components/multi-file-viewer-uploader/src/components/index.tsx new file mode 100644 index 0000000..cc6d8d0 --- /dev/null +++ b/components/multi-file-viewer-uploader/src/components/index.tsx @@ -0,0 +1,743 @@ +import React, { FC, useMemo, useRef, useState } from "react"; +import { Retool } from "@tryretool/custom-component-support"; +import * as XLSX from "xlsx"; +import "./index.css"; + +type FileKind = + | "image" + | "pdf" + | "video" + | "audio" + | "text" + | "json" + | "csv" + | "excel" + | "office" + | "unknown"; + +type ViewerFile = { + id: string; + name: string; + url: string; + previewUrl?: string; + mimeType?: string; + label: string; + kind: FileKind; + objectUrl?: string; + base64?: string; +}; + +const KIND_ICONS: Record = { + image: "🖼️", + pdf: "📄", + video: "🎬", + audio: "🎵", + text: "📝", + json: "🧩", + csv: "📊", + excel: "📗", + office: "📎", + unknown: "📁", +}; + +const MultiFileViewer: FC = () => { + Retool.useComponentSettings({ + defaultWidth: 9, + defaultHeight: 28, + }); + + const [files, setFiles] = Retool.useStateArray({ + name: "files", + initialValue: [], + inspector: "hidden", + }); + + const [_selectedFile, setSelectedFile] = Retool.useStateObject({ + name: "selectedFile", + initialValue: { + id: "", + name: "", + url: "", + previewUrl: "", + mimeType: "", + label: "", + kind: "unknown", + }, + inspector: "hidden", + }); + + const [_selectedFileData, setSelectedFileData] = Retool.useStateObject({ + name: "selectedFileData", + initialValue: { + name: "", + type: "", + base64: "", + }, + inspector: "hidden", + }); + + const [_allFileData, setAllFileData] = Retool.useStateArray({ + name: "allFileData", + initialValue: [], + inspector: "hidden", + }); + + const [acceptedKind, setAcceptedKind] = Retool.useStateString({ + name: "acceptedKind", + initialValue: "", + inspector: "hidden", + }); + + const [allowMixedFileTypes] = Retool.useStateBoolean({ + name: "allowMixedFileTypes", + initialValue: false, + label: "Allow different file types", + inspector: "checkbox", + }); + + const [selectedFileId, setSelectedFileId] = useState(""); + const [dragOver, setDragOver] = useState(false); + const [textPreview, setTextPreview] = useState(""); + const [jsonPreview, setJsonPreview] = useState(""); + const [sheetPreview, setSheetPreview] = useState(null); + + const onFileSelect = Retool.useEventCallback({ name: "fileSelect" }); + const onFilesChange = Retool.useEventCallback({ name: "filesChange" }); + + const fileInputRef = useRef(null); + + const visibleFiles = useMemo( + () => (Array.isArray(files) ? (files as ViewerFile[]) : []), + [files] + ); + + const activeFile = useMemo( + () => visibleFiles.find((f) => f.id === selectedFileId) ?? null, + [visibleFiles, selectedFileId] + ); + + const showSidebar = visibleFiles.length > 0; + + const truncate = (value: string, max = 40) => + value.length > max ? `${value.slice(0, max)}…` : value; + + const detectKind = (file: File): FileKind => { + const type = (file.type || "").toLowerCase(); + const name = file.name.toLowerCase(); + + if (type.startsWith("image/")) return "image"; + if (type === "application/pdf" || name.endsWith(".pdf")) return "pdf"; + if (type.startsWith("video/")) return "video"; + if (type.startsWith("audio/")) return "audio"; + if (type.includes("json") || name.endsWith(".json")) return "json"; + if (type.includes("csv") || name.endsWith(".csv")) return "csv"; + + if ( + type.includes("spreadsheet") || + type.includes("excel") || + name.endsWith(".xlsx") || + name.endsWith(".xls") + ) { + return "excel"; + } + + if ( + type.includes("word") || + type.includes("presentation") || + name.endsWith(".doc") || + name.endsWith(".docx") || + name.endsWith(".ppt") || + name.endsWith(".pptx") + ) { + return "office"; + } + + if ( + type.startsWith("text/") || + name.endsWith(".txt") || + name.endsWith(".md") || + name.endsWith(".log") + ) { + return "text"; + } + + return "unknown"; + }; + + const normalizeLabel = (kind: FileKind) => { + switch (kind) { + case "image": + return "Image"; + case "pdf": + return "PDF"; + case "video": + return "Video"; + case "audio": + return "Audio"; + case "text": + return "Text"; + case "json": + return "JSON"; + case "csv": + return "CSV"; + case "excel": + return "Excel"; + case "office": + return "Office"; + default: + return "File"; + } + }; + + const fileToBase64 = (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = () => { + const result = typeof reader.result === "string" ? reader.result : ""; + const base64 = result.includes(",") ? result.split(",")[1] : result; + resolve(base64); + }; + + reader.onerror = () => reject(new Error(`Failed to read ${file.name}`)); + reader.readAsDataURL(file); + }); + + const buildAllFileData = (items: ViewerFile[]) => + items.map((file) => ({ + id: file.id, + name: file.name, + type: file.mimeType || "", + kind: file.kind, + base64: file.base64 || "", + label: file.label, + })); + + const decodeBase64Utf8 = (base64: string) => { + try { + const binary = atob(base64); + const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0)); + return new TextDecoder().decode(bytes); + } catch { + return ""; + } + }; + + const readPreviewFromSourceFile = async (file: File, kind: FileKind) => { + setTextPreview(""); + setJsonPreview(""); + setSheetPreview(null); + + if (kind === "text") { + const text = await file.text(); + setTextPreview(text); + return; + } + + if (kind === "json") { + const text = await file.text(); + try { + const parsed = JSON.parse(text); + setJsonPreview(JSON.stringify(parsed, null, 2)); + } catch { + setJsonPreview(text); + } + return; + } + + if (kind === "csv") { + const text = await file.text(); + const wb = XLSX.read(text, { type: "string" }); + const ws = wb.Sheets[wb.SheetNames[0]]; + const rows = XLSX.utils.sheet_to_json(ws, { header: 1 }) as any[][]; + setSheetPreview(rows); + return; + } + + if (kind === "excel") { + const buffer = await file.arrayBuffer(); + const wb = XLSX.read(buffer, { type: "array" }); + const ws = wb.Sheets[wb.SheetNames[0]]; + const rows = XLSX.utils.sheet_to_json(ws, { header: 1 }) as any[][]; + setSheetPreview(rows); + } + }; + + const hydratePreviewFromStoredFile = (file: ViewerFile) => { + setTextPreview(""); + setJsonPreview(""); + setSheetPreview(null); + + if (!file.base64) return; + + if (file.kind === "text") { + setTextPreview(decodeBase64Utf8(file.base64)); + return; + } + + if (file.kind === "json") { + const decoded = decodeBase64Utf8(file.base64); + try { + const parsed = JSON.parse(decoded); + setJsonPreview(JSON.stringify(parsed, null, 2)); + } catch { + setJsonPreview(decoded); + } + return; + } + + if (file.kind === "csv") { + try { + const decoded = decodeBase64Utf8(file.base64); + const wb = XLSX.read(decoded, { type: "string" }); + const ws = wb.Sheets[wb.SheetNames[0]]; + const rows = XLSX.utils.sheet_to_json(ws, { header: 1 }) as any[][]; + setSheetPreview(rows); + } catch { + setSheetPreview(null); + } + return; + } + + if (file.kind === "excel") { + try { + const binary = atob(file.base64); + const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0)); + const wb = XLSX.read(bytes, { type: "array" }); + const ws = wb.Sheets[wb.SheetNames[0]]; + const rows = XLSX.utils.sheet_to_json(ws, { header: 1 }) as any[][]; + setSheetPreview(rows); + } catch { + setSheetPreview(null); + } + } + }; + + const selectFile = async (file: ViewerFile) => { + setSelectedFileId(file.id); + + setSelectedFile({ + id: file.id, + name: file.name, + url: file.url, + previewUrl: file.previewUrl || "", + mimeType: file.mimeType || "", + label: file.label, + kind: file.kind, + }); + + setSelectedFileData({ + name: file.name, + type: file.mimeType || "", + base64: file.base64 || "", + }); + + hydratePreviewFromStoredFile(file); + onFileSelect(); + }; + + const handleFiles = async (fileList: FileList | null) => { + if (!fileList || fileList.length === 0) return; + + const incoming = Array.from(fileList); + const firstIncomingKind = detectKind(incoming[0]); + const sessionKind = acceptedKind || firstIncomingKind; + + const acceptedFiles = allowMixedFileTypes + ? incoming + : incoming.filter((file) => detectKind(file) === sessionKind); + + if (acceptedFiles.length === 0) return; + + if (!acceptedKind && !allowMixedFileTypes) { + setAcceptedKind(sessionKind); + } + + const mapped: ViewerFile[] = await Promise.all( + acceptedFiles.map(async (file) => { + const kind = detectKind(file); + const objectUrl = URL.createObjectURL(file); + const base64 = await fileToBase64(file); + + return { + id: `${file.name}-${file.size}-${file.lastModified}-${Math.random() + .toString(36) + .slice(2)}`, + name: file.name, + url: objectUrl, + previewUrl: objectUrl, + mimeType: file.type, + label: normalizeLabel(kind), + kind, + objectUrl, + base64, + }; + }) + ); + + const nextFiles = [...visibleFiles, ...mapped]; + setFiles(nextFiles); + setAllFileData(buildAllFileData(nextFiles)); + + const first = mapped[0]; + const sourceFile = acceptedFiles[0]; + + if (first) { + setSelectedFileId(first.id); + + setSelectedFile({ + id: first.id, + name: first.name, + url: first.url, + previewUrl: first.previewUrl || "", + mimeType: first.mimeType || "", + label: first.label, + kind: first.kind, + }); + + setSelectedFileData({ + name: first.name, + type: first.mimeType || "", + base64: first.base64 || "", + }); + + if (sourceFile) { + await readPreviewFromSourceFile(sourceFile, first.kind); + } + + onFileSelect(); + } + + onFilesChange(); + }; + + const removeLocalFile = (id: string) => { + const target = visibleFiles.find((f) => f.id === id); + if (target?.objectUrl) { + URL.revokeObjectURL(target.objectUrl); + } + + const nextFiles = visibleFiles.filter((f) => f.id !== id); + setFiles(nextFiles); + setAllFileData(buildAllFileData(nextFiles)); + + if (selectedFileId === id) { + const next = nextFiles[0]; + + if (next) { + setSelectedFileId(next.id); + setSelectedFile({ + id: next.id, + name: next.name, + url: next.url, + previewUrl: next.previewUrl || "", + mimeType: next.mimeType || "", + label: next.label, + kind: next.kind, + }); + setSelectedFileData({ + name: next.name, + type: next.mimeType || "", + base64: next.base64 || "", + }); + hydratePreviewFromStoredFile(next); + } else { + setSelectedFileId(""); + setSelectedFile({ + id: "", + name: "", + url: "", + previewUrl: "", + mimeType: "", + label: "", + kind: "unknown", + }); + setSelectedFileData({ + name: "", + type: "", + base64: "", + }); + setAllFileData([]); + setAcceptedKind(""); + setTextPreview(""); + setJsonPreview(""); + setSheetPreview(null); + } + } + + onFilesChange(); + }; + + const clearAll = () => { + visibleFiles.forEach((file) => { + if (file.objectUrl) URL.revokeObjectURL(file.objectUrl); + }); + + setFiles([]); + setSelectedFileId(""); + setAcceptedKind(""); + setSelectedFile({ + id: "", + name: "", + url: "", + previewUrl: "", + mimeType: "", + label: "", + kind: "unknown", + }); + setSelectedFileData({ + name: "", + type: "", + base64: "", + }); + setAllFileData([]); + setTextPreview(""); + setJsonPreview(""); + setSheetPreview(null); + onFilesChange(); + }; + + const renderSheet = (rows: any[][]) => { + if (!rows.length) { + return ; + } + + return ( +
+ + + {rows.map((row, rIdx) => ( + + {row.map((cell, cIdx) => + rIdx === 0 ? ( + + ) : ( + + ) + )} + + ))} + +
{String(cell ?? "")}{String(cell ?? "")}
+
+ ); + }; + + const renderPreview = () => { + if (!activeFile) { + return ( + + ); + } + + const previewUrl = activeFile.previewUrl || activeFile.url; + const kind = activeFile.kind; + + if (kind === "image") { + return ( +
+ {activeFile.name} +
+ ); + } + + if (kind === "pdf") { + return ( +
+