Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
245 changes: 206 additions & 39 deletions src/components/DatabaseExplorer.tsx
Original file line number Diff line number Diff line change
@@ -1,47 +1,151 @@
'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<string, unknown>[];
}

interface SchemaData {
tables: TableInfo[];
}
type TableInfo = z.infer<typeof TableSchema>;
type SchemaData = z.infer<typeof SchemaData>;

export function DatabaseExplorer() {
const [schema, setSchema] = useState<SchemaData | null>(null);
const [activeTable, setActiveTable] = useState<string>('users');
const [activeTable, setActiveTable] = useState<string>('');
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [sortColumn, setSortColumn] = useState<string>('');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [currentPage, setCurrentPage] = useState(1);
const [error, setError] = useState<string | null>(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);
}
}
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 (
<div className="flex items-center justify-center h-64">
Expand All @@ -50,6 +154,14 @@ export function DatabaseExplorer() {
);
}

if (error) {
return (
<div className="text-red-400 text-center py-8">
Error: {error}
</div>
);
}

if (!schema) {
return (
<div className="text-red-400 text-center py-8">
Expand All @@ -58,8 +170,6 @@ export function DatabaseExplorer() {
);
}

const currentTable = schema.tables.find(t => t.name === activeTable);

return (
<div className="flex flex-col lg:flex-row gap-3 sm:gap-4 min-h-[400px] sm:min-h-[500px]">
{/* Sidebar - Table List */}
Expand All @@ -77,7 +187,12 @@ export function DatabaseExplorer() {
{schema.tables.map((table) => (
<button
key={table.name}
onClick={() => setActiveTable(table.name)}
onClick={() => {
setActiveTable(table.name);
setCurrentPage(1);
setSearchTerm('');
setSortColumn('');
}}
className={`w-full text-left px-2 sm:px-3 py-1.5 sm:py-2 rounded-lg text-xs sm:text-sm font-mono transition-all flex items-center justify-between group ${
activeTable === table.name
? 'bg-cyan-500/20 text-cyan-400 border border-cyan-500/30'
Expand Down Expand Up @@ -109,9 +224,31 @@ export function DatabaseExplorer() {
{currentTable.rowCount} rows
</span>
</div>
<code className="text-xs text-purple-400 bg-black/40 px-2 py-1 rounded font-mono break-all">
db-{currentTable.name}
</code>
<div className="flex items-center gap-2">
<button
onClick={handleExport}
className="text-xs bg-cyan-500/20 hover:bg-cyan-500/30 text-cyan-400 px-2 py-1 rounded transition-colors"
>
Export CSV
</button>
<code className="text-xs text-purple-400 bg-black/40 px-2 py-1 rounded font-mono break-all">
db-{currentTable.name}
</code>
</div>
</div>

{/* Search Bar */}
<div className="px-3 sm:px-4 py-2 border-b border-white/10 bg-white/[0.02]">
<input
type="text"
placeholder="Search..."
value={searchTerm}
onChange={(e) => {
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"
/>
</div>

{/* Column Schema */}
Expand Down Expand Up @@ -139,15 +276,23 @@ export function DatabaseExplorer() {
{currentTable.columns.map((col) => (
<th
key={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"
onClick={() => 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}
<div className="flex items-center gap-1">
{col.name}
{sortColumn === col.name && (
<span className="text-xs">
{sortDirection === 'asc' ? '↑' : '↓'}
</span>
)}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{currentTable.data.map((row, i) => (
{paginatedData.map((row, i) => (
<tr key={i} className="hover:bg-white/5 transition-colors">
{currentTable.columns.map((col) => (
<td
Expand All @@ -164,9 +309,33 @@ export function DatabaseExplorer() {
</div>
</div>

{/* Footer */}
<div className="px-3 sm:px-4 py-2 border-t border-white/10 bg-white/[0.02] text-xs text-slate-500">
Showing {currentTable.data.length} of {currentTable.rowCount} rows
{/* Pagination */}
<div className="px-3 sm:px-4 py-2 border-t border-white/10 bg-white/[0.02] flex items-center justify-between">
<div className="text-xs text-slate-500">
Showing {paginatedData.length} of {filteredAndSortedData.length} rows
{filteredAndSortedData.length !== currentTable.rowCount && (
<span> (filtered from {currentTable.rowCount} total)</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="px-2 py-1 text-xs rounded bg-white/5 hover:bg-white/10 disabled:opacity-50 disabled:cursor-not-allowed text-slate-300"
>
Previous
</button>
<span className="px-2 py-1 text-xs text-slate-400">
{currentPage} / {totalPages || 1}
</span>
<button
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages || totalPages === 0}
className="px-2 py-1 text-xs rounded bg-white/5 hover:bg-white/10 disabled:opacity-50 disabled:cursor-not-allowed text-slate-300"
>
Next
</button>
</div>
</div>
</div>
)}
Expand All @@ -193,5 +362,3 @@ function formatValue(value: unknown): string {
}

export default DatabaseExplorer;