| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { useEffect, useMemo, useState } from 'react' |
| import useAppStore from '../store/appStore' |
|
|
| const REFRESH_INTERVAL_MS = 6_000 |
|
|
| export default function DbBrowserPanel() { |
| const projectId = useAppStore((s) => s.project.id) |
|
|
| const [schema, setSchema] = useState(null) |
| const [activeTbl, setActiveTbl] = useState(null) |
| const [rows, setRows] = useState(null) |
| const [offset, setOffset] = useState(0) |
| const [error, setError] = useState(null) |
| const [loading, setLoading] = useState(false) |
|
|
| const PAGE = 100 |
|
|
| |
| |
| useEffect(() => { |
| if (!projectId) return |
| let cancelled = false |
|
|
| async function loadSchema() { |
| try { |
| const r = await fetch(`/api/projects/${projectId}/db/schema`) |
| if (!r.ok) { |
| if (!cancelled) { |
| const err = await r.json().catch(() => ({})) |
| setError(err.detail || `HTTP ${r.status}`) |
| setSchema(null) |
| } |
| return |
| } |
| const data = await r.json() |
| if (cancelled) return |
| setError(null) |
| setSchema(data) |
| } catch (e) { |
| if (!cancelled) setError(String(e.message || e)) |
| } |
| } |
|
|
| loadSchema() |
| const id = setInterval(loadSchema, REFRESH_INTERVAL_MS) |
| return () => { cancelled = true; clearInterval(id) } |
| }, [projectId]) |
|
|
| |
| |
| useEffect(() => { |
| if (!schema?.tables?.length) { |
| setActiveTbl(null) |
| return |
| } |
| const names = schema.tables.map((t) => t.name) |
| if (!activeTbl || !names.includes(activeTbl)) { |
| setActiveTbl(schema.tables[0].name) |
| setOffset(0) |
| } |
| }, [schema, activeTbl]) |
|
|
| |
| useEffect(() => { |
| if (!projectId || !activeTbl) { setRows(null); return } |
| let cancelled = false |
| setLoading(true) |
|
|
| async function loadRows() { |
| try { |
| const url = `/api/projects/${projectId}/db/rows` |
| + `?table=${encodeURIComponent(activeTbl)}` |
| + `&limit=${PAGE}&offset=${offset}` |
| const r = await fetch(url) |
| if (!r.ok) { |
| const err = await r.json().catch(() => ({})) |
| if (!cancelled) { setRows(null); setError(err.detail || `HTTP ${r.status}`) } |
| return |
| } |
| const data = await r.json() |
| if (cancelled) return |
| setRows(data) |
| setError(null) |
| } catch (e) { |
| if (!cancelled) setError(String(e.message || e)) |
| } finally { |
| if (!cancelled) setLoading(false) |
| } |
| } |
| loadRows() |
| return () => { cancelled = true } |
| }, [projectId, activeTbl, offset]) |
|
|
| const tables = schema?.tables ?? [] |
| const activeMeta = useMemo( |
| () => tables.find((t) => t.name === activeTbl), |
| [tables, activeTbl], |
| ) |
|
|
| if (!projectId) { |
| return <EmptyState label="Open a project to browse its database." /> |
| } |
| if (schema && !schema.exists) { |
| return <EmptyState label={`No database yet — run the project to create ${schema.db_path}.`} /> |
| } |
| if (error && !schema) { |
| return <EmptyState label={error} tone="error" /> |
| } |
|
|
| return ( |
| <div style={{ display: 'flex', height: '100%', background: 'var(--bg)' }}> |
| {/* Tables sidebar */} |
| <div |
| style={{ |
| width: 180, |
| borderRight: '1px solid var(--border)', |
| background: 'var(--surface)', |
| overflow: 'auto', |
| flexShrink: 0, |
| }} |
| > |
| <div |
| style={{ |
| padding: '8px 10px 6px', |
| fontSize: 10, |
| color: 'var(--text-faint)', |
| textTransform: 'uppercase', |
| letterSpacing: '0.08em', |
| }} |
| > |
| Tables ({tables.length}) |
| </div> |
| {tables.length === 0 && ( |
| <div style={{ padding: '0 10px', fontSize: 11, color: 'var(--text-faint)' }}> |
| No tables yet. Create one in schema.sql (or via Prisma migrate). |
| </div> |
| )} |
| {tables.map((t) => { |
| const active = t.name === activeTbl |
| return ( |
| <button |
| key={t.name} |
| onClick={() => { setActiveTbl(t.name); setOffset(0) }} |
| style={{ |
| display: 'flex', |
| alignItems: 'center', |
| justifyContent: 'space-between', |
| width: '100%', |
| padding: '6px 10px', |
| border: 'none', |
| background: active ? 'var(--surface-2)' : 'transparent', |
| color: active ? 'var(--text)' : 'var(--text-dim)', |
| cursor: 'pointer', |
| textAlign: 'left', |
| fontFamily: 'ui-monospace, "JetBrains Mono", monospace', |
| fontSize: 12, |
| borderLeft: active ? '2px solid var(--accent)' : '2px solid transparent', |
| }} |
| > |
| <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> |
| {t.name} |
| </span> |
| <span style={{ color: 'var(--text-faint)', fontSize: 10 }}> |
| {t.row_count} |
| </span> |
| </button> |
| ) |
| })} |
| </div> |
| |
| {/* Right: schema + rows */} |
| <div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}> |
| {activeMeta && ( |
| <div |
| style={{ |
| padding: '8px 12px', |
| borderBottom: '1px solid var(--border)', |
| background: 'var(--surface)', |
| display: 'flex', |
| gap: 12, |
| alignItems: 'center', |
| flexWrap: 'wrap', |
| fontSize: 11, |
| }} |
| > |
| <span |
| style={{ |
| fontFamily: 'ui-monospace, "JetBrains Mono", monospace', |
| color: 'var(--text)', |
| fontWeight: 600, |
| }} |
| > |
| {activeMeta.name} |
| </span> |
| <span style={{ color: 'var(--text-faint)' }}> |
| {activeMeta.columns.length} cols · {activeMeta.row_count} rows |
| </span> |
| <span style={{ flex: 1 }} /> |
| <Pager |
| offset={offset} |
| limit={PAGE} |
| total={activeMeta.row_count} |
| onChange={setOffset} |
| /> |
| </div> |
| )} |
| |
| <div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}> |
| {loading && !rows && ( |
| <div style={{ padding: 16, fontSize: 12, color: 'var(--text-faint)' }}>Loading…</div> |
| )} |
| {rows && <RowTable rows={rows} columns={activeMeta?.columns ?? []} />} |
| </div> |
| </div> |
| </div> |
| ) |
| } |
|
|
| function RowTable({ rows, columns }) { |
| if (!rows.rows.length) { |
| return ( |
| <div style={{ padding: 16, fontSize: 12, color: 'var(--text-faint)' }}> |
| Empty table — INSERTs from the running app show up here on the next refresh. |
| </div> |
| ) |
| } |
| const colMeta = Object.fromEntries(columns.map((c) => [c.name, c])) |
| return ( |
| <table |
| style={{ |
| borderCollapse: 'separate', |
| borderSpacing: 0, |
| width: '100%', |
| fontSize: 11.5, |
| fontFamily: 'ui-monospace, "JetBrains Mono", monospace', |
| color: 'var(--text)', |
| }} |
| > |
| <thead> |
| <tr> |
| {rows.columns.map((c) => ( |
| <th |
| key={c} |
| style={{ |
| textAlign: 'left', |
| padding: '6px 10px', |
| background: 'var(--surface)', |
| borderBottom: '1px solid var(--border-2)', |
| fontWeight: 600, |
| color: 'var(--text-dim)', |
| whiteSpace: 'nowrap', |
| position: 'sticky', |
| top: 0, |
| }} |
| > |
| {c} |
| {colMeta[c]?.pk && ( |
| <span style={{ marginLeft: 5, color: 'var(--accent)', fontSize: 9 }} title="primary key">★</span> |
| )} |
| <div style={{ fontWeight: 400, fontSize: 9.5, color: 'var(--text-faint)' }}> |
| {colMeta[c]?.type || ''} |
| </div> |
| </th> |
| ))} |
| </tr> |
| </thead> |
| <tbody> |
| {rows.rows.map((row, i) => ( |
| <tr key={i}> |
| {row.map((cell, j) => ( |
| <td |
| key={j} |
| style={{ |
| padding: '4px 10px', |
| borderBottom: '1px solid var(--border)', |
| verticalAlign:'top', |
| maxWidth: 320, |
| overflow: 'hidden', |
| textOverflow: 'ellipsis', |
| whiteSpace: 'nowrap', |
| color: cell == null ? 'var(--text-faint)' : 'var(--text)', |
| }} |
| title={cell == null ? 'NULL' : String(cell)} |
| > |
| {cell == null ? 'NULL' : String(cell)} |
| </td> |
| ))} |
| </tr> |
| ))} |
| </tbody> |
| </table> |
| ) |
| } |
|
|
| function Pager({ offset, limit, total, onChange }) { |
| const last = Math.max(0, Math.floor((total - 1) / limit) * limit) |
| const cur = Math.floor(offset / limit) + 1 |
| const max = Math.max(1, Math.floor((total - 1) / limit) + 1) |
| return ( |
| <div style={{ display: 'flex', gap: 4, alignItems: 'center', color: 'var(--text-faint)' }}> |
| <PagerBtn disabled={offset === 0} onClick={() => onChange(0)}>«</PagerBtn> |
| <PagerBtn disabled={offset === 0} onClick={() => onChange(Math.max(0, offset - limit))}>‹</PagerBtn> |
| <span style={{ fontSize: 10.5, padding: '0 4px' }}>{cur}/{max}</span> |
| <PagerBtn disabled={offset >= last} onClick={() => onChange(Math.min(last, offset + limit))}>›</PagerBtn> |
| <PagerBtn disabled={offset >= last} onClick={() => onChange(last)}>»</PagerBtn> |
| </div> |
| ) |
| } |
|
|
| function PagerBtn({ disabled, onClick, children }) { |
| return ( |
| <button |
| disabled={disabled} |
| onClick={onClick} |
| style={{ |
| width: 22, height: 22, |
| borderRadius: 4, |
| border: '1px solid var(--border-2)', |
| background: 'var(--surface-2)', |
| color: disabled ? 'var(--text-faint)' : 'var(--text-dim)', |
| cursor: disabled ? 'default' : 'pointer', |
| fontSize: 11, |
| }} |
| >{children}</button> |
| ) |
| } |
|
|
| function EmptyState({ label, tone }) { |
| return ( |
| <div |
| style={{ |
| height: '100%', |
| display: 'flex', |
| flexDirection: 'column', |
| alignItems: 'center', |
| justifyContent: 'center', |
| gap: 8, |
| color: tone === 'error' ? 'var(--danger)' : 'var(--text-faint)', |
| fontSize: 12.5, |
| padding: 24, |
| textAlign: 'center', |
| background: 'var(--bg)', |
| }} |
| > |
| <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" style={{ opacity: 0.4 }}> |
| <ellipse cx="12" cy="5" rx="9" ry="3" /> |
| <path d="M3 5v14c0 1.7 4 3 9 3s9-1.3 9-3V5" /> |
| <path d="M3 12c0 1.7 4 3 9 3s9-1.3 9-3" /> |
| </svg> |
| {label} |
| </div> |
| ) |
| } |
|
|