diff --git a/components/.DS_Store b/components/.DS_Store index 81e4416..a9e49eb 100644 Binary files a/components/.DS_Store and b/components/.DS_Store differ diff --git a/components/global-file-viewer/README.md b/components/global-file-viewer/README.md new file mode 100644 index 0000000..a3372c6 --- /dev/null +++ b/components/global-file-viewer/README.md @@ -0,0 +1,86 @@ +## Username + +widlestudiollp + +## Project Name + +Smart File Viewer + +## About + +Smart File Viewer is an intelligent, auto-detecting file viewer component for Retool that supports multiple file formats and input sources. It automatically detects the file type and renders it using the appropriate viewer without requiring manual configuration. + +The component supports files from uploads, URLs (including Google Drive/Docs), and Base64 strings. It provides a seamless experience for previewing documents, media, and structured data directly inside Retool apps. + +## Preview + +![Smart File Viewer Preview](cover.png) + +## How it works + +The component receives input via a text field (URL/Base64) or file upload. It then processes the input using a detection engine that determines the file type based on MIME type, file extension, or Base64 signature. + +### File detection logic + +* Base64 prefix → Determines MIME type (PDF, Image, etc.) +* File extension → Fallback detection (`.docx`, `.xlsx`, `.csv`, etc.) +* Google URLs → Converted into embeddable preview links +* Blob conversion → Ensures consistent rendering across formats + +### Rendering logic + +* PDF → Rendered using `react-pdf` with pagination +* Word → Converted to HTML using `mammoth` +* Excel → Parsed into table using `xlsx` +* CSV → Custom parser → table view +* JSON → Structured table +* Text → Code-style preview +* Image → Responsive image preview +* Video → Native HTML5 player +* Google Docs/Drive → Embedded iframe preview + +### Example input + +```json +[ + { "name": "Smith", "role": "Developer" }, + { "name": "John", "role": "Designer" } +] +``` + +Or Base64: + +``` +data:application/pdf;base64,JVBERi0xLjQKJ... +``` + +Or URL: + +``` +https://drive.google.com/file/d/FILE_ID/view +``` + +## Build process + +The component is built using React and integrates with Retool through `@tryretool/custom-component-support`. It uses multiple libraries for handling different file types and rendering them efficiently. + +### Key implementation details + +* Uses `useMemo` for optimized file type detection +* Uses `useEffect` for async file loading and parsing +* Converts files to Blob URLs for consistent rendering +* Handles Google Drive/Docs links via URL transformation +* Implements custom parsers for CSV and JSON +* Supports dynamic container resizing using `ResizeObserver` +* Includes proper cleanup for Blob URLs to prevent memory leaks + +### Extensibility + +The component is designed to be flexible and extensible. Developers can: + +* Add support for additional file types (e.g., audio, markdown) +* Replace Word rendering engine for better fidelity +* Add drag-and-drop upload support +* Extend parsing logic for large datasets +* Customize UI themes and controls +* Add caching or file size optimizations diff --git a/components/global-file-viewer/cover.png b/components/global-file-viewer/cover.png new file mode 100644 index 0000000..71b16a4 Binary files /dev/null and b/components/global-file-viewer/cover.png differ diff --git a/components/global-file-viewer/metadata.json b/components/global-file-viewer/metadata.json new file mode 100644 index 0000000..adba99a --- /dev/null +++ b/components/global-file-viewer/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "smart-file-viewer", + "title": "Smart File Viewer", + "author": "@widlestudiollp", + "shortDescription": "An intelligent auto-detect file viewer component that supports multiple file formats (PDF, Word, Excel, CSV, JSON, media) from uploads, URLs, and Base64, rendering them with the appropriate viewer automatically.", + "tags": ["File Viewer", "Document Viewer", "Auto Detection", "PDF", "Media", "Data Preview"] +} \ No newline at end of file diff --git a/components/global-file-viewer/package.json b/components/global-file-viewer/package.json new file mode 100644 index 0000000..c9d0c06 --- /dev/null +++ b/components/global-file-viewer/package.json @@ -0,0 +1,53 @@ +{ + "name": "my-react-app", + "version": "0.1.0", + "private": true, + "dependencies": { + "@tryretool/custom-component-support": "latest", + "dompurify": "^3.3.3", + "mammoth": "^1.12.0", + "papaparse": "^5.5.3", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-pdf": "^10.4.1", + "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/papaparse": "^5.5.2", + "@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": "FileViewerComponent", + "label": "File viewer component", + "description": "A file viewer component.", + "entryPoint": "src/index.tsx", + "outputPath": "dist" + } +} diff --git a/components/global-file-viewer/src/components/fileViewerComponent.tsx b/components/global-file-viewer/src/components/fileViewerComponent.tsx new file mode 100644 index 0000000..f3c4082 --- /dev/null +++ b/components/global-file-viewer/src/components/fileViewerComponent.tsx @@ -0,0 +1,592 @@ +import React, { FC, useState, useMemo, useEffect, useCallback, useRef } from "react"; +import { Retool } from "@tryretool/custom-component-support"; +import { Document, Page, pdfjs } from "react-pdf"; +import * as XLSX from "xlsx"; + +import "react-pdf/dist/Page/AnnotationLayer.css"; +import "react-pdf/dist/Page/TextLayer.css"; + +pdfjs.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`; + +type FileType = "image" | "video" | "pdf" | "word" | "excel" | "csv" | "json" | "text" | "other"; + +function transformGoogleUrl(url: string): string | null { + try { + const u = new URL(url); + const host = u.hostname; + + const driveFile = url.match(/drive\.google\.com\/file\/d\/([^/?&#]+)/); + if (driveFile) return `https://drive.google.com/file/d/${driveFile[1]}/preview`; + + const driveOpen = url.match(/drive\.google\.com\/open\?id=([^&#]+)/); + if (driveOpen) return `https://drive.google.com/file/d/${driveOpen[1]}/preview`; + + const docsDoc = url.match(/docs\.google\.com\/document\/d\/([^/?&#]+)/); + if (docsDoc) return `https://docs.google.com/document/d/${docsDoc[1]}/preview`; + + const docsSheet = url.match(/docs\.google\.com\/spreadsheets\/d\/([^/?&#]+)/); + if (docsSheet) return `https://docs.google.com/spreadsheets/d/${docsSheet[1]}/preview`; + + const docsSlides = url.match(/docs\.google\.com\/presentation\/d\/([^/?&#]+)/); + if (docsSlides) return `https://docs.google.com/presentation/d/${docsSlides[1]}/embed`; + + const docsForms = url.match(/docs\.google\.com\/forms\/d\/([^/?&#]+)/); + if (docsForms) return `https://docs.google.com/forms/d/${docsForms[1]}/viewform?embedded=true`; + + return null; + } catch { + return null; + } +} + +function isGoogleUrl(url: string): boolean { + return url.includes("drive.google.com") || url.includes("docs.google.com"); +} + +function detectFileType(input: string): FileType { + if (!input) return "other"; + const str = input.toLowerCase(); + if (str.startsWith("data:")) { + if (str.includes("image/")) return "image"; + if (str.includes("video/")) return "video"; + if (str.includes("pdf")) return "pdf"; + if ( + str.includes("word") || + str.includes("officedocument.wordprocessingml") || + str.includes("application/vnd.openxmlformats-officedocument.wordprocessingml.document") + ) return "word"; + if (str.includes("excel") || str.includes("spreadsheet")) return "excel"; + if (str.includes("text/csv")) return "csv"; + if (str.includes("application/json")) return "json"; + if (str.includes("text/plain")) return "text"; + return "other"; + } + if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)(\?|$)/i.test(str)) return "image"; + if (/\.(mp4|webm|mov|avi|mkv|m4v|ogg)(\?|$)/i.test(str)) return "video"; + if (/\.pdf(\?|$)/i.test(str)) return "pdf"; + if (/\.(doc|docx)(\?|$)/i.test(str)) return "word"; + if (/\.(ppt|pptx)(\?|$)/i.test(str)) return "word"; + if (/\.(xls|xlsx)(\?|$)/i.test(str)) return "excel"; + if (/\.csv(\?|$)/i.test(str)) return "csv"; + if (/\.json(\?|$)/i.test(str)) return "json"; + if (/\.(txt|log|md)(\?|$)/i.test(str)) return "text"; + if (/\.(mp3|wav|ogg|aac|flac|m4a)(\?|$)/i.test(str)) return "other"; + return "other"; +} + +function ensureBase64Prefix(b64: string, filenameHint?: string): string { + if (b64.startsWith("data:")) return b64; + + const c = b64.replace(/\s/g, ""); + + if (c.startsWith("JVBER")) return `data:application/pdf;base64,${c}`; + if (c.startsWith("iVBOR")) return `data:image/png;base64,${c}`; + if (c.startsWith("/9j/")) return `data:image/jpeg;base64,${c}`; + if (c.startsWith("R0lGOD")) return `data:image/gif;base64,${c}`; + + if (c.startsWith("UEsDB")) { + if (filenameHint?.toLowerCase().endsWith(".docx")) { + return `data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,${c}`; + } + if (filenameHint?.toLowerCase().endsWith(".xlsx")) { + return `data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,${c}`; + } + if (filenameHint?.toLowerCase().endsWith(".pptx")) { + return `data:application/vnd.openxmlformats-officedocument.presentationml.presentation;base64,${c}`; + } + + return `data:application/zip;base64,${c}`; + } + + return `data:application/octet-stream;base64,${c}`; +} + +async function toBlobUrl(file: string, type: FileType): Promise { + if (file.startsWith("data:")) { + if (type === "video" || type === "image") return file; + try { + const res = await fetch(file); + const blob = await res.blob(); + return URL.createObjectURL(blob); + } catch { return file; } + } + if (file.includes("google.com") && (file.includes("/preview") || file.includes("/embed") || file.includes("/viewform"))) { + return file; + } + try { + const res = await fetch(file); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const blob = await res.blob(); + return URL.createObjectURL(blob); + } catch (e) { + throw new Error(`Failed to fetch file: ${e}`); + } +} + +function parseCSV(text: string): Record[] { + const lines = text.split(/\r?\n/).filter((l) => l.trim()); + if (!lines.length) return []; + const parseRow = (row: string): string[] => { + const result: string[] = []; let cur = ""; let inQ = false; + for (let i = 0; i < row.length; i++) { + const ch = row[i]; + if (ch === '"') { if (inQ && row[i + 1] === '"') { cur += '"'; i++; } else inQ = !inQ; } + else if (ch === "," && !inQ) { result.push(cur.trim()); cur = ""; } + else cur += ch; + } + result.push(cur.trim()); return result; + }; + const headers = parseRow(lines[0]); + return lines.slice(1).map((line) => { + const vals = parseRow(line); + return headers.reduce((acc: Record, h, i) => { acc[h] = vals[i] ?? ""; return acc; }, {}); + }); +} + +async function parseFile(file: string, type: FileType): Promise[] | string | null> { + try { + const res = await fetch(file); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const buffer = await res.arrayBuffer(); + if (type === "csv") return parseCSV(new TextDecoder().decode(buffer)); + if (type === "excel") { + const wb = XLSX.read(buffer, { type: "array" }); + return XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]); + } + if (type === "json") { + const parsed = JSON.parse(new TextDecoder().decode(buffer)); + return Array.isArray(parsed) ? parsed : [parsed]; + } + if (type === "text") return new TextDecoder().decode(buffer); + if (type === "word") { + const mammoth = await import("mammoth"); + return (await mammoth.convertToHtml({ arrayBuffer: buffer })).value; + } + } catch (e) { console.error("Parse error:", e); throw e; } + return null; +} + +export const FileViewer: FC = () => { + Retool.useComponentSettings({ defaultWidth: 6, defaultHeight: 40 }); + const [inputValue, setInputValue] = useState(""); + const [uploadedDataUrl, setUploadedDataUrl] = useState(""); + const [loadedInputValue, setLoadedInputValue] = useState(""); + const [filenameHint, setFilenameHint] = useState(""); + const [file, setFile] = useState(""); + const [embedUrl, setEmbedUrl] = useState(""); + const [blobUrl, setBlobUrl] = useState(""); + const [data, setData] = useState[] | string | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [page, setPage] = useState(1); + const [pages, setPages] = useState(0); + const [containerWidth, setContainerWidth] = useState(800); + const containerRef = useRef(null); + + const type = useMemo(() => { + const fromDataUrl = detectFileType(file); + if (fromDataUrl !== "other") return fromDataUrl; + if (filenameHint) return detectFileType(filenameHint); + return "other"; + }, [file, filenameHint]); + + const isUploaded = uploadedDataUrl !== "" && file === uploadedDataUrl; + const isViewLoaded = loadedInputValue !== "" && loadedInputValue === inputValue.trim() && !isUploaded; + const isLoaded = isUploaded || isViewLoaded; + const canClickView = inputValue.trim() !== "" && !isLoaded; + + useEffect(() => { + if (!containerRef.current) return; + const ro = new ResizeObserver((entries) => { + const w = entries[0]?.contentRect.width; + if (w) setContainerWidth(w - 48); + }); + ro.observe(containerRef.current); + return () => ro.disconnect(); + }, []); + + useEffect(() => { + return () => { if (blobUrl?.startsWith("blob:")) URL.revokeObjectURL(blobUrl); }; + }, [blobUrl]); + + useEffect(() => { + if (!file) { setBlobUrl(""); setData(null); setEmbedUrl(""); return; } + + let cancelled = false; + let currentBlobUrl = ""; + + const load = async () => { + setLoading(true); setError(""); setPage(1); setPages(0); setData(null); setEmbedUrl(""); + + try { + if (file.startsWith("http") && isGoogleUrl(file)) { + const preview = transformGoogleUrl(file); + if (preview) { + if (!cancelled) { setEmbedUrl(preview); setBlobUrl(preview); } + return; + } + if (!cancelled) { setEmbedUrl(file); setBlobUrl(file); } + return; + } + + const url = await toBlobUrl(file, type); + if (cancelled) { if (url.startsWith("blob:")) URL.revokeObjectURL(url); return; } + currentBlobUrl = url; + setBlobUrl(url); + + if (["csv", "excel", "json", "text", "word"].includes(type)) { + const parsed = await parseFile(url, type); + if (!cancelled) setData(parsed); + } + } catch (e) { + if (!cancelled) setError(e instanceof Error ? e.message : "Failed to load file"); + } finally { + if (!cancelled) setLoading(false); + } + }; + + load(); + return () => { + cancelled = true; + if (currentBlobUrl?.startsWith("blob:")) URL.revokeObjectURL(currentBlobUrl); + }; + }, [file, type]); + + const handleView = useCallback(() => { + let value = inputValue.trim(); + if (!value) return; + if (!value.startsWith("http") && !value.startsWith("data:")) { + value = ensureBase64Prefix(value, filenameHint); + } + setUploadedDataUrl(""); + setLoadedInputValue(inputValue.trim()); + setFile(value); + }, [inputValue]); + + const handleUpload = useCallback((f: File) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === "string") { + const dataUrl = reader.result; + setInputValue(f.name); + setFilenameHint(f.name); + setUploadedDataUrl(dataUrl); + setFile(dataUrl); + } + }; + reader.onerror = () => setError("Failed to read file"); + reader.readAsDataURL(f); + }, []); + + const handleDownload = useCallback(() => { + if (embedUrl) { window.open(file, "_blank"); return; } + const url = blobUrl || file; + if (!url) return; + const a = document.createElement("a"); + a.href = url; a.download = inputValue || "download"; + document.body.appendChild(a); a.click(); document.body.removeChild(a); + }, [blobUrl, file, inputValue, embedUrl]); + + const handlePrint = useCallback(() => { + if (type === "word" && typeof data === "string") { + const win = window.open("", "_blank"); + if (win) { win.document.write(`${data}`); win.document.close(); win.print(); } + return; + } + const url = blobUrl || file; + if (!url) return; + const win = window.open(url, "_blank"); + if (win) win.onload = () => win.print(); + }, [blobUrl, file, type, data]); + + const handleClear = useCallback(() => { + if (blobUrl?.startsWith("blob:")) URL.revokeObjectURL(blobUrl); + setFile(""); setInputValue(""); setUploadedDataUrl(""); setLoadedInputValue(""); setFilenameHint(""); + setBlobUrl(""); setData(null); setError(""); + setPage(1); setPages(0); setEmbedUrl(""); + }, [blobUrl]); + + const C = { + primary: "#4F46E5", surface: "#FFFFFF", bg: "#F8FAFC", + border: "#E2E8F0", muted: "#64748B", danger: "#EF4444", + dangerBg: "#FEF2F2", text: "#1E293B", tableHead: "#F1F5F9", + }; + + const typeTag: Record = { + pdf: { bg: "#FEE2E2", color: "#EF4444" }, + image: { bg: "#EDE9FE", color: "#7C3AED" }, + video: { bg: "#DBEAFE", color: "#2563EB" }, + word: { bg: "#DBEAFE", color: "#1D4ED8" }, + excel: { bg: "#DCFCE7", color: "#16A34A" }, + csv: { bg: "#E0F2FE", color: "#0284C7" }, + json: { bg: "#FEF3C7", color: "#D97706" }, + text: { bg: "#F1F5F9", color: "#475569" }, + other: { bg: "#F1F5F9", color: "#475569" }, + }; + const tag = typeTag[type] ?? typeTag.other; + + const btnBase: React.CSSProperties = { + padding: "6px 12px", borderRadius: 7, fontSize: 12, fontWeight: 500, + cursor: "pointer", border: `1px solid ${C.border}`, background: C.surface, color: C.text, + }; + const thSt: React.CSSProperties = { + padding: "10px 14px", borderBottom: `1px solid ${C.border}`, background: C.tableHead, + textAlign: "left", position: "sticky", top: 0, fontSize: 11, fontWeight: 700, + color: C.muted, textTransform: "uppercase", letterSpacing: "0.05em", + }; + const tdSt: React.CSSProperties = { padding: "10px 14px", borderBottom: `1px solid #F1F5F9`, fontSize: 13 }; + + const renderContent = () => { + if (!file) return ( +
+
📂
+
No file loaded
+
+ Upload a file or paste a URL / Base64 string above +
+
+ ); + + if (loading) return ( +
+
+ +
Loading file…
+
+ ); + + if (error) return ( +
+
⚠️
+
Failed to load file
+
{error}
+
+ ); + + if (embedUrl) return ( +