diff --git a/src/components/DatabaseExplorer.tsx b/src/components/DatabaseExplorer.tsx index 91172ed..74f2b67 100644 --- a/src/components/DatabaseExplorer.tsx +++ b/src/components/DatabaseExplorer.tsx @@ -1,40 +1,60 @@ -'use client'; +import { useState, useEffect, useMemo } from 'react'; +import { z } from 'zod'; -/** - * TailwindSQL Database Explorer - * - * A DBeaver-like interface for exploring database tables. - */ +const TableSchema = z.object({ + name: z.string(), + columns: z.array(z.object({ + name: z.string(), + type: z.string(), + })), + rowCount: z.number(), + data: z.array(z.record(z.unknown())), +}); -import { useState, useEffect } from 'react'; +const SchemaData = z.object({ + tables: z.array(TableSchema), +}); -interface TableInfo { - name: string; - columns: { name: string; type: string }[]; - rowCount: number; - data: Record[]; -} - -interface SchemaData { - tables: TableInfo[]; -} +type TableInfo = z.infer; +type SchemaData = z.infer; export function DatabaseExplorer() { const [schema, setSchema] = useState(null); - const [activeTable, setActiveTable] = useState('users'); + const [activeTable, setActiveTable] = useState(''); const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(''); + const [sortColumn, setSortColumn] = useState(''); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); + const [currentPage, setCurrentPage] = useState(1); + const [error, setError] = useState(null); + const rowsPerPage = 10; useEffect(() => { async function fetchSchema() { try { - const response = await fetch('/api/schema'); - const data = await response.json(); - setSchema(data); - if (data.tables?.length > 0) { - setActiveTable(data.tables[0].name); + setLoading(true); + setError(null); + const response = await fetch('/api/schema', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '', + }, + credentials: 'same-origin', + }); + + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const rawData = await response.json(); + const validatedData = SchemaData.parse(rawData); + + setSchema(validatedData); + if (validatedData.tables?.length > 0) { + setActiveTable(validatedData.tables[0].name); } - } catch (error) { - console.error('Failed to fetch schema:', error); + } catch (err) { + console.error('Failed to fetch schema:', err); + setError(err instanceof Error ? err.message : 'Unknown error'); } finally { setLoading(false); } @@ -42,6 +62,90 @@ export function DatabaseExplorer() { fetchSchema(); }, []); + const currentTable = useMemo(() => + schema?.tables.find(t => t.name === activeTable), + [schema, activeTable] + ); + + const filteredAndSortedData = useMemo(() => { + if (!currentTable) return []; + + let filteredData = currentTable.data; + + if (searchTerm) { + filteredData = currentTable.data.filter(row => + Object.values(row).some(value => + String(value).toLowerCase().includes(searchTerm.toLowerCase()) + ) + ); + } + + if (sortColumn) { + filteredData = [...filteredData].sort((a, b) => { + const aValue = a[sortColumn]; + const bValue = b[sortColumn]; + + if (aValue === null || aValue === undefined) return 1; + if (bValue === null || bValue === undefined) return -1; + + if (typeof aValue === 'number' && typeof bValue === 'number') { + return sortDirection === 'asc' ? aValue - bValue : bValue - aValue; + } + + const aStr = String(aValue).toLowerCase(); + const bStr = String(bValue).toLowerCase(); + + if (sortDirection === 'asc') { + return aStr < bStr ? -1 : aStr > bStr ? 1 : 0; + } else { + return aStr > bStr ? -1 : aStr < bStr ? 1 : 0; + } + }); + } + + return filteredData; + }, [currentTable, searchTerm, sortColumn, sortDirection]); + + const paginatedData = useMemo(() => { + const startIndex = (currentPage - 1) * rowsPerPage; + return filteredAndSortedData.slice(startIndex, startIndex + rowsPerPage); + }, [filteredAndSortedData, currentPage]); + + const totalPages = Math.ceil(filteredAndSortedData.length / rowsPerPage); + + const handleSort = (columnName: string) => { + if (sortColumn === columnName) { + setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); + } else { + setSortColumn(columnName); + setSortDirection('asc'); + } + }; + + const handleExport = () => { + if (!currentTable) return; + + const csvContent = [ + currentTable.columns.map(col => col.name).join(','), + ...currentTable.data.map(row => + currentTable.columns.map(col => { + const value = row[col.name]; + if (value === null || value === undefined) return 'NULL'; + if (typeof value === 'string' && value.includes(',')) return `"${value}"`; + return String(value); + }).join(',') + ) + ].join('\n'); + + const blob = new Blob([csvContent], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${currentTable.name}.csv`; + a.click(); + URL.revokeObjectURL(url); + }; + if (loading) { return (
@@ -50,6 +154,14 @@ export function DatabaseExplorer() { ); } + if (error) { + return ( +
+ Error: {error} +
+ ); + } + if (!schema) { return (
@@ -58,8 +170,6 @@ export function DatabaseExplorer() { ); } - const currentTable = schema.tables.find(t => t.name === activeTable); - return (
{/* Sidebar - Table List */} @@ -77,7 +187,12 @@ export function DatabaseExplorer() { {schema.tables.map((table) => (
- - db-{currentTable.name} - +
+ + + db-{currentTable.name} + +
+
+ + {/* Search Bar */} +
+ { + setSearchTerm(e.target.value); + setCurrentPage(1); + }} + className="w-full bg-black/40 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-cyan-500/50" + />
{/* Column Schema */} @@ -139,15 +276,23 @@ export function DatabaseExplorer() { {currentTable.columns.map((col) => ( handleSort(col.name)} + className="border border-white/10 px-2 sm:px-3 py-1.5 sm:py-2 text-left font-semibold text-cyan-400 bg-white/5 whitespace-nowrap cursor-pointer hover:bg-white/10" > - {col.name} +
+ {col.name} + {sortColumn === col.name && ( + + {sortDirection === 'asc' ? '↑' : '↓'} + + )} +
))} - {currentTable.data.map((row, i) => ( + {paginatedData.map((row, i) => ( {currentTable.columns.map((col) => (
- {/* Footer */} -
- Showing {currentTable.data.length} of {currentTable.rowCount} rows + {/* Pagination */} +
+
+ Showing {paginatedData.length} of {filteredAndSortedData.length} rows + {filteredAndSortedData.length !== currentTable.rowCount && ( + (filtered from {currentTable.rowCount} total) + )} +
+
+ + + {currentPage} / {totalPages || 1} + + +
)} @@ -193,5 +362,3 @@ function formatValue(value: unknown): string { } export default DatabaseExplorer; - -