| |
| |
| |
| "use client"; |
|
|
| import { useEffect, useMemo, useState } from "react"; |
|
|
| export interface CSVViewerProps { |
| url: string; |
| |
| name?: string; |
| bytes?: number; |
| } |
|
|
| interface ParsedCsv { |
| header: string[]; |
| rows: string[][]; |
| } |
|
|
| |
| |
| function splitLines(text: string): string[] { |
| const lines: string[] = []; |
| let current = ""; |
| let inQuotes = false; |
| for (let i = 0; i < text.length; i++) { |
| const ch = text[i]; |
| if (ch === '"') { |
| inQuotes = !inQuotes; |
| current += ch; |
| } else if (ch === "\r" && text[i + 1] === "\n" && !inQuotes) { |
| lines.push(current); |
| current = ""; |
| i++; |
| } else if (ch === "\n" && !inQuotes) { |
| lines.push(current); |
| current = ""; |
| } else { |
| current += ch; |
| } |
| } |
| if (current.length > 0) lines.push(current); |
| return lines; |
| } |
|
|
| function parseCsv(input: string, delim: string): ParsedCsv { |
| let text = input; |
| if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); |
|
|
| const lines = splitLines(text); |
| const parsed: string[][] = []; |
| for (const line of lines) { |
| if (line.length === 0) continue; |
| parsed.push(parseLine(line, delim)); |
| } |
|
|
| while (parsed.length > 0) { |
| const last = parsed[parsed.length - 1]; |
| if (last.every((c) => c === "")) parsed.pop(); |
| else break; |
| } |
|
|
| if (parsed.length === 0) return { header: [], rows: [] }; |
| const [header, ...rows] = parsed; |
| return { header, rows }; |
| } |
|
|
| function parseLine(line: string, delim: string): string[] { |
| const out: string[] = []; |
| let field = ""; |
| let inQuotes = false; |
| for (let i = 0; i < line.length; i++) { |
| const ch = line[i]; |
| if (inQuotes) { |
| if (ch === '"') { |
| if (line[i + 1] === '"') { |
| field += '"'; |
| i++; |
| } else { |
| inQuotes = false; |
| } |
| } else { |
| field += ch; |
| } |
| } else { |
| if (ch === '"' && field === "") { |
| inQuotes = true; |
| } else if (ch === delim) { |
| out.push(field); |
| field = ""; |
| } else { |
| field += ch; |
| } |
| } |
| } |
| out.push(field); |
| return out; |
| } |
|
|
| function isNumericValue(v: string): boolean { |
| if (v === "" || v == null) return false; |
| const trimmed = v.trim(); |
| if (trimmed === "") return false; |
| if (!/^-?\d*\.?\d+(?:[eE][-+]?\d+)?$/.test(trimmed)) return false; |
| return Number.isFinite(Number(trimmed)); |
| } |
|
|
| function detectNumericColumns(header: string[], rows: string[][]): boolean[] { |
| return header.map((_, col) => { |
| let total = 0; |
| let numeric = 0; |
| for (const row of rows) { |
| const v = row[col]; |
| if (v === undefined || v === "") continue; |
| total++; |
| if (isNumericValue(v)) numeric++; |
| } |
| if (total === 0) return false; |
| return numeric / total >= 0.8; |
| }); |
| } |
|
|
| function formatBytes(n: number | undefined): string { |
| if (!n && n !== 0) return ""; |
| if (n < 1024) return `${n} B`; |
| if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; |
| return `${(n / (1024 * 1024)).toFixed(1)} MB`; |
| } |
|
|
| const DEFAULT_ROWS_SHOWN = 25; |
|
|
| export default function CSVViewer({ url, name, bytes }: CSVViewerProps) { |
| const [raw, setRaw] = useState<string | null>(null); |
| const [error, setError] = useState<string | null>(null); |
| const [showAll, setShowAll] = useState(false); |
|
|
| const delim = useMemo(() => { |
| |
| |
| const n = (name || "").toLowerCase(); |
| if (n.endsWith(".tsv")) return "\t"; |
| return ","; |
| }, [name]); |
|
|
| const MAX_PREVIEW_BYTES = 2 * 1024 * 1024; |
|
|
| useEffect(() => { |
| let cancelled = false; |
| setRaw(null); |
| setError(null); |
| setShowAll(false); |
| fetch(url) |
| .then((r) => { |
| if (!r.ok) throw new Error(`HTTP ${r.status}`); |
| return r.text(); |
| }) |
| .then((t) => { |
| if (!cancelled) { |
| |
| if (t.length > MAX_PREVIEW_BYTES) { |
| setRaw(t.slice(0, MAX_PREVIEW_BYTES)); |
| } else { |
| setRaw(t); |
| } |
| } |
| }) |
| .catch((e) => { |
| if (!cancelled) setError(e.message || String(e)); |
| }); |
| return () => { |
| cancelled = true; |
| }; |
| }, [url]); |
|
|
| const parsed = useMemo<ParsedCsv | null>(() => { |
| if (raw == null) return null; |
| try { |
| const p = parseCsv(raw, delim); |
| if (p.header.length === 0) return null; |
| return p; |
| } catch { |
| return null; |
| } |
| }, [raw, delim]); |
|
|
| const numericCols = useMemo( |
| () => (parsed ? detectNumericColumns(parsed.header, parsed.rows) : []), |
| [parsed], |
| ); |
|
|
| const totalRows = parsed?.rows.length ?? 0; |
| const visibleRows = showAll ? totalRows : Math.min(DEFAULT_ROWS_SHOWN, totalRows); |
| const shownRows = parsed ? parsed.rows.slice(0, visibleRows) : []; |
| const hasToggle = totalRows > DEFAULT_ROWS_SHOWN; |
|
|
| return ( |
| <div |
| data-testid="csv-viewer" |
| className="relative w-full overflow-hidden rounded-lg border border-border-subtle bg-white" |
| > |
| <div className="flex items-center justify-between px-3 py-2 border-b border-border-subtle"> |
| <span className="text-[10px] font-bold uppercase tracking-widest text-muted-fg"> |
| {name ? `CSV · ${name}` : "CSV"} |
| </span> |
| <a |
| href={url} |
| download={name} |
| className="text-[10px] text-muted-fg hover:text-accent underline underline-offset-2" |
| > |
| Download |
| </a> |
| </div> |
| |
| {error ? ( |
| <div className="p-4 text-xs text-red-600"> |
| Failed to load CSV: {error} |
| </div> |
| ) : raw == null ? ( |
| <div className="p-4 text-xs text-muted-fg">Loading CSV…</div> |
| ) : parsed == null ? ( |
| <div className="p-4 text-xs text-red-600">Could not parse as CSV.</div> |
| ) : ( |
| <> |
| <div className="max-h-96 overflow-auto"> |
| <table className="w-full border-collapse font-mono text-[11px] leading-tight"> |
| <thead className="sticky top-0 z-10 bg-muted/80 backdrop-blur"> |
| <tr> |
| {parsed.header.map((h, idx) => ( |
| <th |
| key={idx} |
| title={h} |
| className={`px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted-fg border-b border-border-subtle whitespace-nowrap ${ |
| numericCols[idx] ? "text-right" : "text-left" |
| }`} |
| style={{ maxWidth: 140 }} |
| > |
| <span className="block overflow-hidden text-ellipsis whitespace-nowrap"> |
| {h} |
| </span> |
| </th> |
| ))} |
| </tr> |
| </thead> |
| <tbody> |
| {shownRows.map((row, rIdx) => ( |
| <tr |
| key={rIdx} |
| className={rIdx % 2 === 1 ? "bg-muted/30" : undefined} |
| > |
| {parsed.header.map((_, cIdx) => { |
| const val = row[cIdx] ?? ""; |
| return ( |
| <td |
| key={cIdx} |
| title={val} |
| className={`px-2 py-1 border-b border-border-subtle/60 ${ |
| numericCols[cIdx] ? "text-right tabular-nums" : "text-left" |
| }`} |
| style={{ maxWidth: 140 }} |
| > |
| <span className="block overflow-hidden text-ellipsis whitespace-nowrap"> |
| {val} |
| </span> |
| </td> |
| ); |
| })} |
| </tr> |
| ))} |
| </tbody> |
| </table> |
| </div> |
| |
| <div className="flex items-center justify-between px-3 py-1.5 border-t border-border-subtle text-[10px] text-muted-fg"> |
| <span> |
| {totalRows} row{totalRows === 1 ? "" : "s"} × {parsed.header.length} column |
| {parsed.header.length === 1 ? "" : "s"} |
| {bytes != null ? ` · ${formatBytes(bytes)}` : ""} |
| {!showAll && hasToggle ? ` · showing ${visibleRows}` : ""} |
| </span> |
| {hasToggle ? ( |
| <button |
| type="button" |
| onClick={() => setShowAll((s) => !s)} |
| className="text-[10px] text-muted-fg hover:text-accent underline underline-offset-2" |
| > |
| {showAll ? "Show fewer" : "Show all"} |
| </button> |
| ) : null} |
| </div> |
| </> |
| )} |
| </div> |
| ); |
| } |
|
|
| export { CSVViewer }; |
|
|