| import { useMemo, useState } from "react"; |
| import { X, Table as TableIcon, FileText } from "lucide-react"; |
| import type { EmbedDataFile } from "../editor/embeds/embed-data-store"; |
| import { formatBytes } from "../utils/data-files"; |
|
|
| interface DataFileViewerProps { |
| file: EmbedDataFile; |
| onClose: () => void; |
| } |
|
|
| const MAX_PREVIEW_ROWS = 100; |
|
|
| function splitCsvLine(line: string, delim: string): string[] { |
| const out: string[] = []; |
| let cur = ""; |
| let inQuotes = false; |
| for (let i = 0; i < line.length; i++) { |
| const c = line[i]; |
| if (inQuotes) { |
| if (c === '"') { |
| if (line[i + 1] === '"') { |
| cur += '"'; |
| i++; |
| } else { |
| inQuotes = false; |
| } |
| } else { |
| cur += c; |
| } |
| } else if (c === '"') { |
| inQuotes = true; |
| } else if (c === delim) { |
| out.push(cur); |
| cur = ""; |
| } else { |
| cur += c; |
| } |
| } |
| out.push(cur); |
| return out; |
| } |
|
|
| function parseTable(content: string, delim: string, maxRows: number): { |
| columns: string[]; |
| rows: string[][]; |
| total: number; |
| } { |
| const lines = content.split(/\r\n|\n|\r/).filter((l) => l.length > 0); |
| if (lines.length === 0) return { columns: [], rows: [], total: 0 }; |
| const columns = splitCsvLine(lines[0], delim); |
| const rows = lines |
| .slice(1, 1 + maxRows) |
| .map((l) => splitCsvLine(l, delim)); |
| return { columns, rows, total: Math.max(0, lines.length - 1) }; |
| } |
|
|
| function prettyJson(content: string): string { |
| try { |
| return JSON.stringify(JSON.parse(content), null, 2); |
| } catch { |
| return content; |
| } |
| } |
|
|
| export function DataFileViewer({ file, onClose }: DataFileViewerProps) { |
| const { ext } = file.meta; |
| const isTabular = ext === "csv" || ext === "tsv"; |
| const [mode, setMode] = useState<"table" | "raw">(isTabular ? "table" : "raw"); |
|
|
| const parsed = useMemo(() => { |
| if (!isTabular) return null; |
| return parseTable(file.content, ext === "tsv" ? "\t" : ",", MAX_PREVIEW_ROWS); |
| }, [file.content, ext, isTabular]); |
|
|
| const raw = useMemo(() => { |
| if (ext === "json") return prettyJson(file.content); |
| return file.content; |
| }, [file.content, ext]); |
|
|
| return ( |
| <div className="es-data-viewer"> |
| <div className="es-data-viewer__header"> |
| <div className="es-data-viewer__title"> |
| <strong>{file.meta.name}</strong> |
| <span className="es-data-viewer__meta"> |
| {ext.toUpperCase()} - {formatBytes(file.meta.size)} |
| {file.meta.rowCount !== undefined ? ` - ${file.meta.rowCount} rows` : ""} |
| </span> |
| </div> |
| <div className="es-data-viewer__actions"> |
| {isTabular && ( |
| <div className="es-data-viewer__tabs"> |
| <button |
| type="button" |
| className={`es-preview__tab ${mode === "table" ? "es-preview__tab--active" : ""}`} |
| onClick={() => setMode("table")} |
| > |
| <TableIcon size={12} /> |
| Table |
| </button> |
| <button |
| type="button" |
| className={`es-preview__tab ${mode === "raw" ? "es-preview__tab--active" : ""}`} |
| onClick={() => setMode("raw")} |
| > |
| <FileText size={12} /> |
| Raw |
| </button> |
| </div> |
| )} |
| <button |
| type="button" |
| className="embed-btn" |
| onClick={onClose} |
| aria-label="Close file viewer" |
| > |
| <X size={14} /> |
| </button> |
| </div> |
| </div> |
| |
| <div className="es-data-viewer__body"> |
| {isTabular && mode === "table" && parsed ? ( |
| <div className="es-data-table-wrap"> |
| <table className="es-data-table"> |
| <thead> |
| <tr> |
| {parsed.columns.map((col, i) => ( |
| <th key={i}>{col}</th> |
| ))} |
| </tr> |
| </thead> |
| <tbody> |
| {parsed.rows.map((row, ri) => ( |
| <tr key={ri}> |
| {parsed.columns.map((_, ci) => ( |
| <td key={ci}>{row[ci] ?? ""}</td> |
| ))} |
| </tr> |
| ))} |
| </tbody> |
| </table> |
| {parsed.total > parsed.rows.length && ( |
| <div className="es-data-table__footer"> |
| Showing first {parsed.rows.length} of {parsed.total} rows |
| </div> |
| )} |
| </div> |
| ) : ( |
| <pre className="es-data-viewer__raw"> |
| <code>{raw}</code> |
| </pre> |
| )} |
| </div> |
| </div> |
| ); |
| } |
|
|