239 lines
5.9 KiB
TypeScript
239 lines
5.9 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 { THEME } from "@/components/project/dashboard-ui";
|
|
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={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 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>
|
|
);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────
|
|
|
|
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.75rem",
|
|
color: INK.mid,
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
padding: "4px 8px",
|
|
};
|
|
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,
|
|
};
|