Files
vibn-frontend/components/project/table-viewer.tsx

181 lines
5.4 KiB
TypeScript

"use client";
/**
* Right-pane table preview. Shows the first ~50 rows of the selected
* table as a compact data grid. Cells longer than 200 chars are
* truncated server-side; we render the rest as-is. Read-only.
*/
import { useEffect, useState } from "react";
import { Loader2, AlertCircle, Info } from "lucide-react";
interface PreviewedTable {
columns: string[];
rows: Array<Record<string, string>>;
truncated: boolean;
unsupported?: boolean;
error?: string;
}
interface Props {
projectId: string;
dbUuid: string;
schema: string;
table: string;
}
export function TableViewer({ projectId, dbUuid, schema, table }: Props) {
const [data, setData] = useState<PreviewedTable | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 12_000);
setLoading(true);
setError(null);
setData(null);
const url = `/api/projects/${projectId}/databases/${dbUuid}/preview` +
`?schema=${encodeURIComponent(schema)}&table=${encodeURIComponent(table)}`;
fetch(url, { credentials: "include", signal: ctrl.signal })
.then(async r => {
let body: unknown = {};
try { body = await r.json(); } catch {/* keep {} */}
if (!r.ok) throw new Error((body as { error?: string }).error || `HTTP ${r.status}`);
return body as PreviewedTable;
})
.then(d => { if (!cancelled) setData(d); })
.catch(err => {
if (cancelled) return;
if (err?.name === "AbortError") setError("Timed out after 12s.");
else setError(err?.message || "Failed to load preview");
})
.finally(() => { clearTimeout(t); if (!cancelled) setLoading(false); });
return () => { cancelled = true; ctrl.abort(); clearTimeout(t); };
}, [projectId, dbUuid, schema, table]);
if (loading) {
return (
<div style={center}>
<Loader2 size={14} className="animate-spin" />
<span style={{ marginLeft: 8 }}>Querying {schema}.{table}</span>
</div>
);
}
if (error) {
return (
<div style={errorBox}>
<AlertCircle size={13} /> {error}
</div>
);
}
if (!data) return null;
if (data.rows.length === 0) {
return (
<div style={infoBox}>
<Info size={13} />
Table is empty.
</div>
);
}
return (
<div style={wrap}>
<div style={meta}>
Showing {data.rows.length} row{data.rows.length === 1 ? "" : "s"}
{data.truncated && " (truncated to first 50)"} ·{" "}
{data.columns.length} column{data.columns.length === 1 ? "" : "s"} ·{" "}
<code style={qual}>{schema}.{table}</code>
</div>
<div style={tableScroll}>
<table style={tableEl}>
<thead>
<tr>
{data.columns.map(c => (
<th key={c} style={th}>{c}</th>
))}
</tr>
</thead>
<tbody>
{data.rows.map((row, i) => (
<tr key={i} style={i % 2 === 0 ? trEven : trOdd}>
{data.columns.map(c => (
<td key={c} style={td} title={row[c]}>
{row[c]}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// ──────────────────────────────────────────────────
const INK = {
ink: "#1a1a1a",
mid: "#5f5e5a",
muted: "#a09a90",
borderSoft: "#efebe1",
border: "#e8e4dc",
} as const;
const wrap: React.CSSProperties = {
display: "flex", flexDirection: "column", gap: 8, minHeight: 0, flex: 1,
};
const meta: React.CSSProperties = {
fontSize: "0.74rem", color: INK.mid,
};
const qual: React.CSSProperties = {
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
color: INK.ink,
};
const tableScroll: React.CSSProperties = {
flex: 1, minHeight: 0, overflow: "auto",
border: `1px solid ${INK.borderSoft}`, borderRadius: 6,
};
const tableEl: React.CSSProperties = {
borderCollapse: "collapse",
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: "0.76rem",
width: "100%",
};
const th: React.CSSProperties = {
position: "sticky", top: 0,
textAlign: "left", padding: "6px 10px",
background: "#fafaf6", color: INK.ink,
fontWeight: 600, fontSize: "0.72rem",
borderBottom: `1px solid ${INK.border}`,
whiteSpace: "nowrap",
};
const td: React.CSSProperties = {
padding: "5px 10px", color: INK.ink,
borderBottom: `1px solid ${INK.borderSoft}`,
whiteSpace: "nowrap", maxWidth: 360,
overflow: "hidden", textOverflow: "ellipsis",
};
const trEven: React.CSSProperties = { background: "#fff" };
const trOdd: React.CSSProperties = { background: "#fcfaf3" };
const center: React.CSSProperties = {
flex: 1, display: "flex", alignItems: "center", justifyContent: "center",
color: INK.mid, fontSize: "0.85rem",
};
const errorBox: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 6,
padding: "10px 12px", fontSize: "0.82rem", color: "#7a1f15",
background: "#fbe9e7", border: `1px solid #f4c2bc`, borderRadius: 8,
};
const infoBox: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 6,
padding: "10px 12px", fontSize: "0.82rem", color: INK.mid,
background: "#fafaf6", border: `1px dashed ${INK.borderSoft}`, borderRadius: 8,
};