Spaces:
Sleeping
Sleeping
| import { useState, useEffect, useRef } from 'react' | |
| import { Loader2, GitFork } from 'lucide-react' | |
| import { useStore } from '../store/useStore' | |
| import { fetchSchemaGraph } from '../lib/api' | |
| import type { SchemaTable, SchemaRelationship } from '../lib/types' | |
| // βββ Table card βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function TableCard({ table, x, y }: { table: SchemaTable; x: number; y: number }) { | |
| return ( | |
| <g transform={`translate(${x},${y})`}> | |
| {/* Card bg */} | |
| <rect | |
| width={180} | |
| height={28 + table.columns.length * 20} | |
| rx={8} | |
| fill="#0e0e16" | |
| stroke="rgba(255,255,255,0.08)" | |
| strokeWidth={1} | |
| /> | |
| {/* Header */} | |
| <rect width={180} height={28} rx={8} fill="rgba(139,92,246,0.15)" /> | |
| <rect y={20} width={180} height={8} fill="rgba(139,92,246,0.15)" /> | |
| <text | |
| x={10} | |
| y={18} | |
| fill="#a78bfa" | |
| fontSize={11} | |
| fontWeight="bold" | |
| fontFamily="ui-monospace,monospace" | |
| > | |
| {table.name} | |
| </text> | |
| {/* Columns */} | |
| {table.columns.map((col, i) => ( | |
| <g key={col.name} transform={`translate(0,${28 + i * 20})`}> | |
| <rect | |
| width={180} | |
| height={20} | |
| fill={i % 2 === 0 ? 'rgba(255,255,255,0.01)' : 'transparent'} | |
| /> | |
| <text | |
| x={10} | |
| y={14} | |
| fill={col.pk ? '#60a5fa' : col.fk ? '#34d399' : 'rgba(255,255,255,0.5)'} | |
| fontSize={10} | |
| fontFamily="ui-monospace,monospace" | |
| > | |
| {col.pk ? 'π ' : col.fk ? 'π ' : ' '} | |
| {col.name} | |
| </text> | |
| <text | |
| x={170} | |
| y={14} | |
| fill="rgba(255,255,255,0.2)" | |
| fontSize={9} | |
| fontFamily="ui-monospace,monospace" | |
| textAnchor="end" | |
| > | |
| {col.type} | |
| </text> | |
| </g> | |
| ))} | |
| </g> | |
| ) | |
| } | |
| // βββ Layout helpers βββββββββββββββββββββββββββββββββββββββββββββββ | |
| function layoutTables(tables: SchemaTable[]) { | |
| const CARD_W = 180 | |
| const CARD_H_BASE = 28 | |
| const COL_H = 20 | |
| const GAP_X = 40 | |
| const GAP_Y = 30 | |
| const COLS_PER_ROW = 3 | |
| const positions: Record<string, { x: number; y: number; w: number; h: number }> = {} | |
| let maxRowH = 0 | |
| tables.forEach((t, i) => { | |
| const col = i % COLS_PER_ROW | |
| const row = Math.floor(i / COLS_PER_ROW) | |
| const h = CARD_H_BASE + t.columns.length * COL_H | |
| if (row === Math.floor(i / COLS_PER_ROW) && col === 0) maxRowH = 0 | |
| maxRowH = Math.max(maxRowH, h) | |
| const prevRowsH = tables | |
| .slice(0, row * COLS_PER_ROW) | |
| .reduce((acc, _, idx) => { | |
| if (idx % COLS_PER_ROW === 0) { | |
| const rowH = tables.slice(idx, idx + COLS_PER_ROW).reduce( | |
| (m, rt) => Math.max(m, CARD_H_BASE + rt.columns.length * COL_H), | |
| 0 | |
| ) | |
| return acc + rowH + GAP_Y | |
| } | |
| return acc | |
| }, 0) | |
| positions[t.name] = { | |
| x: col * (CARD_W + GAP_X) + 20, | |
| y: prevRowsH + 20, | |
| w: CARD_W, | |
| h, | |
| } | |
| }) | |
| return positions | |
| } | |
| function RelationshipLine({ | |
| from, | |
| to, | |
| positions, | |
| }: { | |
| from: string | |
| to: string | |
| positions: Record<string, { x: number; y: number; w: number; h: number }> | |
| }) { | |
| const a = positions[from] | |
| const b = positions[to] | |
| if (!a || !b) return null | |
| const x1 = a.x + a.w | |
| const y1 = a.y + 14 | |
| const x2 = b.x | |
| const y2 = b.y + 14 | |
| const cx = (x1 + x2) / 2 | |
| return ( | |
| <path | |
| d={`M${x1},${y1} C${cx},${y1} ${cx},${y2} ${x2},${y2}`} | |
| stroke="rgba(139,92,246,0.3)" | |
| strokeWidth={1.5} | |
| fill="none" | |
| strokeDasharray="4 3" | |
| /> | |
| ) | |
| } | |
| // βββ ER Diagram component βββββββββββββββββββββββββββββββββββββββββ | |
| export function ERDiagram() { | |
| const { schemaGraph, setSchemaGraph } = useStore() | |
| const [loading, setLoading] = useState(false) | |
| const svgRef = useRef<SVGSVGElement>(null) | |
| const load = async () => { | |
| setLoading(true) | |
| try { | |
| const data = await fetchSchemaGraph() | |
| setSchemaGraph(data) | |
| } catch { | |
| // noop | |
| } finally { | |
| setLoading(false) | |
| } | |
| } | |
| useEffect(() => { | |
| if (!schemaGraph) void load() | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, []) | |
| if (loading) { | |
| return ( | |
| <div className="flex items-center justify-center h-full gap-2 text-gray-500"> | |
| <Loader2 size={16} className="animate-spin" /> | |
| <span className="text-sm">Loading schema...</span> | |
| </div> | |
| ) | |
| } | |
| if (!schemaGraph || schemaGraph.tables.length === 0) { | |
| return ( | |
| <div className="flex flex-col items-center justify-center h-full gap-3 text-gray-600"> | |
| <GitFork size={32} className="text-gray-700" /> | |
| <p className="text-sm">Schema will appear after database connects</p> | |
| <button | |
| onClick={() => void load()} | |
| className="text-xs text-violet-400 hover:text-violet-300 transition-colors" | |
| > | |
| Retry | |
| </button> | |
| </div> | |
| ) | |
| } | |
| const { tables, relationships } = schemaGraph | |
| const positions = layoutTables(tables) | |
| const allX = Object.values(positions).map((p) => p.x + p.w) | |
| const allY = Object.values(positions).map((p) => p.y + p.h) | |
| const svgW = Math.max(...allX) + 40 | |
| const svgH = Math.max(...allY) + 40 | |
| return ( | |
| <div className="h-full overflow-auto p-4"> | |
| <div className="text-[10px] text-gray-500 uppercase tracking-widest mb-3 flex items-center gap-1.5"> | |
| <GitFork size={10} className="text-violet-400" /> | |
| Entity Relationship Diagram | |
| <span className="text-gray-700">Β· {tables.length} tables</span> | |
| </div> | |
| <svg | |
| ref={svgRef} | |
| width={svgW} | |
| height={svgH} | |
| style={{ minWidth: svgW }} | |
| > | |
| {/* FK lines */} | |
| {(relationships as SchemaRelationship[]).map((rel, i) => ( | |
| <RelationshipLine | |
| key={i} | |
| from={rel.from} | |
| to={rel.to} | |
| positions={positions} | |
| /> | |
| ))} | |
| {/* Tables */} | |
| {tables.map((t: SchemaTable) => ( | |
| <TableCard | |
| key={t.name} | |
| table={t} | |
| x={positions[t.name]?.x ?? 0} | |
| y={positions[t.name]?.y ?? 0} | |
| /> | |
| ))} | |
| </svg> | |
| </div> | |
| ) | |
| } | |