Skip to content
File

Blob: app/page.jsx

javascript785 lines
1"use client";
2 
3import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4import {
5 ChevronLeft,
6 ChevronRight,
7 Database,
8 FileUp,
9 Play,
10 RotateCcw,
11 Rows3,
12 Table as TableIcon,
13 Terminal,
14 Upload,
15 XCircle,
16} from "lucide-react";
17import { Footer } from "./components/Footer";
18import GridTable from "./components/GridTable";
19 
20const DEFAULT_QUERY = "SELECT name FROM sqlite_master WHERE type='table';";
21const INITIAL_IMPORT_PROGRESS = {
22 processed: 0,
23 totalStatements: 0,
24 applied: 0,
25 skipped: 0,
26};
27 
28export default function HomePage() {
29 const workerRef = useRef(null);
30 const pendingRequestsRef = useRef(new Map());
31 const requestIdRef = useRef(1);
32 const selectedTableRef = useRef(null);
33 
34 const [engineStatus, setEngineStatus] = useState("Initializing...");
35 const [engineTone, setEngineTone] = useState("");
36 const [isEngineReady, setIsEngineReady] = useState(false);
37 const [isBusy, setIsBusy] = useState(true);
38 const [isImporting, setIsImporting] = useState(false);
39 
40 const [selectedFile, setSelectedFile] = useState(null);
41 const [importSummary, setImportSummary] = useState(
42 "Waiting for SQL engine...",
43 );
44 const [importLogLines, setImportLogLines] = useState([]);
45 const [importProgress, setImportProgress] = useState(INITIAL_IMPORT_PROGRESS);
46 
47 const [tables, setTables] = useState([]);
48 const [selectedTable, setSelectedTable] = useState(null);
49 const [page, setPage] = useState(0);
50 const [pageSize, setPageSize] = useState(50);
51 
52 const [tableMeta, setTableMeta] = useState("No table selected.");
53 const [tableColumns, setTableColumns] = useState([]);
54 const [tableRows, setTableRows] = useState([]);
55 const [canPrevPage, setCanPrevPage] = useState(false);
56 const [canNextPage, setCanNextPage] = useState(false);
57 
58 const [queryInput, setQueryInput] = useState(DEFAULT_QUERY);
59 const [queryMeta, setQueryMeta] = useState("");
60 const [queryColumns, setQueryColumns] = useState([]);
61 const [queryRows, setQueryRows] = useState([]);
62 
63 useEffect(() => {
64 selectedTableRef.current = selectedTable;
65 }, [selectedTable]);
66 
67 const importLog = useMemo(() => importLogLines.join("\n"), [importLogLines]);
68 
69 const appendLog = useCallback((line) => {
70 setImportLogLines((prev) => [...prev, line]);
71 }, []);
72 
73 const clearQueryResults = useCallback(() => {
74 setQueryMeta("");
75 setQueryColumns([]);
76 setQueryRows([]);
77 }, []);
78 
79 const clearTableSelection = useCallback(() => {
80 setTableMeta("No table selected.");
81 setTableColumns([]);
82 setTableRows([]);
83 setCanPrevPage(false);
84 setCanNextPage(false);
85 }, []);
86 
87 const rejectPendingRequests = useCallback((error) => {
88 for (const pending of pendingRequestsRef.current.values()) {
89 pending.reject(error);
90 }
91 pendingRequestsRef.current.clear();
92 }, []);
93 
94 const callWorker = useCallback((type, payload = {}) => {
95 const worker = workerRef.current;
96 if (!worker) {
97 return Promise.reject(new Error("SQL worker is not initialized."));
98 }
99 
100 return new Promise((resolve, reject) => {
101 const requestId = requestIdRef.current;
102 requestIdRef.current += 1;
103 
104 pendingRequestsRef.current.set(requestId, { resolve, reject });
105 worker.postMessage({ type, requestId, ...payload });
106 });
107 }, []);
108 
109 const handleWorkerEvent = useCallback(
110 (eventName, data = {}) => {
111 switch (eventName) {
112 case "log":
113 if (data.line) {
114 appendLog(data.line);
115 }
116 break;
117 case "import-started": {
118 const totalStatements = Number(data.totalStatements || 0);
119 setImportProgress({
120 processed: 0,
121 totalStatements,
122 applied: 0,
123 skipped: 0,
124 });
125 setImportSummary(
126 `Importing... 0/${totalStatements.toLocaleString()}`,
127 );
128 break;
129 }
130 case "import-progress": {
131 const processed = Number(data.processed || 0);
132 const totalStatements = Number(data.totalStatements || 0);
133 const applied = Number(data.applied || 0);
134 const skipped = Number(data.skipped || 0);
135 const percent =
136 totalStatements > 0
137 ? Math.floor((processed / totalStatements) * 100)
138 : 0;
139 
140 setImportProgress({
141 processed,
142 totalStatements,
143 applied,
144 skipped,
145 });
146 setImportSummary(
147 `Importing... ${processed.toLocaleString()}/${totalStatements.toLocaleString()} (${percent}%)`,
148 );
149 break;
150 }
151 case "import-cancel-requested":
152 setImportSummary("Cancelling import...");
153 break;
154 case "import-cancelled": {
155 const processed = Number(data.processed || 0);
156 const totalStatements = Number(data.totalStatements || 0);
157 const applied = Number(data.applied || 0);
158 const skipped = Number(data.skipped || 0);
159 
160 setImportProgress({
161 processed,
162 totalStatements,
163 applied,
164 skipped,
165 });
166 setImportSummary(
167 `Import cancelled at ${processed.toLocaleString()}/${totalStatements.toLocaleString()} statements.`,
168 );
169 break;
170 }
171 default:
172 break;
173 }
174 },
175 [appendLog],
176 );
177 
178 const refreshTables = useCallback(
179 async ({ keepSelection = false } = {}) => {
180 const data = await callWorker("getTables");
181 const nextTables = data.tables || [];
182 const currentSelection = selectedTableRef.current;
183 const shouldKeepCurrent =
184 keepSelection &&
185 currentSelection &&
186 nextTables.includes(currentSelection);
187 
188 setTables(nextTables);
189 
190 if (!nextTables.length) {
191 setSelectedTable(null);
192 setPage(0);
193 clearTableSelection();
194 setTableMeta("No tables were created from this dump.");
195 return;
196 }
197 
198 if (shouldKeepCurrent) {
199 setSelectedTable(currentSelection);
200 } else {
201 setSelectedTable(nextTables[0]);
202 setPage(0);
203 }
204 },
205 [callWorker, clearTableSelection],
206 );
207 
208 useEffect(() => {
209 const worker = new Worker(
210 new URL("./workers/sqlWorker.js", import.meta.url),
211 {
212 type: "module",
213 },
214 );
215 
216 workerRef.current = worker;
217 
218 worker.onmessage = (event) => {
219 const message = event.data || {};
220 
221 if (message.type === "event") {
222 handleWorkerEvent(message.event, message.data || {});
223 return;
224 }
225 
226 if (message.type !== "response") {
227 return;
228 }
229 
230 const pending = pendingRequestsRef.current.get(message.requestId);
231 if (!pending) {
232 return;
233 }
234 
235 pendingRequestsRef.current.delete(message.requestId);
236 
237 if (message.ok) {
238 pending.resolve(message.data);
239 } else {
240 pending.reject(
241 new Error(message.error?.message || "Worker command failed."),
242 );
243 }
244 };
245 
246 worker.onerror = (event) => {
247 const message = event.message || "Unknown worker error.";
248 appendLog(`[fatal] ${message}`);
249 setEngineStatus(`Worker error: ${message}`);
250 setEngineTone("bad");
251 setIsEngineReady(false);
252 setIsBusy(false);
253 setIsImporting(false);
254 rejectPendingRequests(new Error(message));
255 };
256 
257 let cancelled = false;
258 
259 async function initEngine() {
260 setIsBusy(true);
261 
262 try {
263 await callWorker("init");
264 
265 if (cancelled) {
266 return;
267 }
268 
269 setIsEngineReady(true);
270 setEngineStatus("SQLite WASM ready (worker)");
271 setEngineTone("ok");
272 setImportSummary(
273 "Engine ready. Choose a .sql or .sql.gz dump to import.",
274 );
275 await refreshTables();
276 } catch (error) {
277 if (cancelled) {
278 return;
279 }
280 
281 appendLog(`[fatal] ${error.stack || error.message}`);
282 setEngineStatus(`Failed to initialize SQL worker: ${error.message}`);
283 setEngineTone("bad");
284 setIsEngineReady(false);
285 } finally {
286 if (!cancelled) {
287 setIsBusy(false);
288 }
289 }
290 }
291 
292 void initEngine();
293 
294 return () => {
295 cancelled = true;
296 worker.terminate();
297 workerRef.current = null;
298 rejectPendingRequests(new Error("SQL worker terminated."));
299 };
300 }, [
301 appendLog,
302 callWorker,
303 handleWorkerEvent,
304 refreshTables,
305 rejectPendingRequests,
306 ]);
307 
308 useEffect(() => {
309 if (!isEngineReady || !selectedTable) {
310 clearTableSelection();
311 return;
312 }
313 
314 let cancelled = false;
315 
316 async function loadTablePage() {
317 try {
318 const data = await callWorker("getTablePage", {
319 tableName: selectedTable,
320 page,
321 pageSize,
322 });
323 
324 if (cancelled) {
325 return;
326 }
327 
328 const rows = data.rows || [];
329 const columns = data.columns || [];
330 const totalRows = Number(data.totalRows || 0);
331 const safePage = Number(data.page || 0);
332 const safePageSize = Number(data.pageSize || pageSize);
333 const offset = safePage * safePageSize;
334 const start = totalRows === 0 ? 0 : offset + 1;
335 const end = Math.min(offset + rows.length, totalRows);
336 
337 if (safePage !== page) {
338 setPage(safePage);
339 }
340 
341 setTableColumns(columns);
342 setTableRows(rows);
343 setCanPrevPage(Boolean(data.canPrevPage));
344 setCanNextPage(Boolean(data.canNextPage));
345 setTableMeta(
346 `${selectedTable} โ€ข rows ${start}-${end} of ${totalRows.toLocaleString()} โ€ข page ${safePage + 1}`,
347 );
348 } catch (error) {
349 if (cancelled) {
350 return;
351 }
352 
353 clearTableSelection();
354 setTableMeta(`Failed to render table: ${error.message}`);
355 }
356 }
357 
358 void loadTablePage();
359 
360 return () => {
361 cancelled = true;
362 };
363 }, [
364 callWorker,
365 clearTableSelection,
366 isEngineReady,
367 page,
368 pageSize,
369 selectedTable,
370 ]);
371 
372 const handleImportDump = useCallback(async () => {
373 if (!selectedFile) {
374 setImportSummary("Pick a .sql or .sql.gz file first.");
375 return;
376 }
377 
378 if (!isEngineReady) {
379 setImportSummary("Database engine is not ready yet.");
380 return;
381 }
382 
383 setIsBusy(true);
384 setIsImporting(true);
385 setImportLogLines([]);
386 setImportProgress(INITIAL_IMPORT_PROGRESS);
387 setImportSummary("Preparing import...");
388 
389 try {
390 const result = await callWorker("importDump", {
391 file: selectedFile,
392 });
393 
394 if (result.cancelled) {
395 setImportSummary(
396 `Import cancelled (${Number(result.applied || 0).toLocaleString()} applied, ${Number(
397 result.skipped || 0,
398 ).toLocaleString()} skipped).`,
399 );
400 } else {
401 setImportSummary(
402 `Imported ${Number(result.applied || 0).toLocaleString()} statements (${Number(
403 result.skipped || 0,
404 ).toLocaleString()} skipped).`,
405 );
406 }
407 
408 await refreshTables();
409 } catch (error) {
410 appendLog(`[error] ${error.stack || error.message}`);
411 setImportSummary(`Import failed: ${error.message}`);
412 } finally {
413 setIsImporting(false);
414 setIsBusy(false);
415 }
416 }, [appendLog, callWorker, isEngineReady, refreshTables, selectedFile]);
417 
418 const handleCancelImport = useCallback(() => {
419 if (!isImporting || !workerRef.current) {
420 return;
421 }
422 
423 setImportSummary("Cancelling import...");
424 workerRef.current.postMessage({ type: "cancelImport" });
425 }, [isImporting]);
426 
427 const handleReset = useCallback(async () => {
428 if (!isEngineReady) {
429 return;
430 }
431 
432 setIsBusy(true);
433 
434 try {
435 await callWorker("reset");
436 setTables([]);
437 setSelectedTable(null);
438 setPage(0);
439 setPageSize(50);
440 clearTableSelection();
441 clearQueryResults();
442 setImportProgress(INITIAL_IMPORT_PROGRESS);
443 appendLog("[reset] in-memory database cleared");
444 setImportSummary("Database reset. Import another dump.");
445 } catch (error) {
446 setImportSummary(`Reset failed: ${error.message}`);
447 } finally {
448 setIsBusy(false);
449 }
450 }, [
451 appendLog,
452 callWorker,
453 clearQueryResults,
454 clearTableSelection,
455 isEngineReady,
456 ]);
457 
458 const handleRunQuery = useCallback(async () => {
459 if (!isEngineReady) {
460 setQueryMeta("Database engine is not ready yet.");
461 setQueryColumns([]);
462 setQueryRows([]);
463 return;
464 }
465 
466 const sql = queryInput.trim();
467 if (!sql) {
468 setQueryMeta("Enter a query first.");
469 setQueryColumns([]);
470 setQueryRows([]);
471 return;
472 }
473 
474 setQueryMeta("Running query...");
475 
476 try {
477 const data = await callWorker("runQuery", { sql });
478 setQueryColumns(data.columns || []);
479 setQueryRows(data.rows || []);
480 setQueryMeta(data.meta || "Query completed.");
481 } catch (error) {
482 setQueryMeta(`Query error: ${error.message}`);
483 setQueryColumns([]);
484 setQueryRows([]);
485 }
486 }, [callWorker, isEngineReady, queryInput]);
487 
488 const importDisabled = isBusy || !isEngineReady || isImporting;
489 const resetDisabled = isBusy || !isEngineReady || isImporting;
490 const runQueryDisabled = !isEngineReady || isImporting;
491 
492 const statusClasses =
493 engineTone === "ok"
494 ? "border-accent-500/35 bg-accent-500/15 text-accent-300"
495 : engineTone === "bad"
496 ? "border-red-400/35 bg-red-500/14 text-red-300"
497 : "border-zinc-700/60 bg-zinc-800/60 text-zinc-400";
498 
499 const progressPercent =
500 importProgress.totalStatements > 0
501 ? Math.floor(
502 (importProgress.processed / importProgress.totalStatements) * 100,
503 )
504 : 0;
505 
506 return (
507 <div className="relative z-10 flex min-h-screen flex-col sm:h-screen">
508 <a
509 href="#main-content"
510 className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[200] focus:rounded-lg focus:bg-zinc-900 focus:px-4 focus:py-2 focus:text-accent-400 focus:ring-2 focus:ring-accent-500/50"
511 >
512 Skip to content
513 </a>
514 {/* Header */}
515 <header className="sticky top-0 z-50 w-full border-b border-zinc-800/60 bg-zinc-900/95 backdrop-blur-sm">
516 <div className="mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-3 sm:px-6">
517 <div className="flex items-center gap-3">
518 <span className="inline-grid h-9 w-9 shrink-0 place-items-center">
519 <Database
520 aria-hidden="true"
521 strokeWidth={2}
522 className="h-6 w-6 text-accent-400"
523 />
524 </span>
525 <span>
526 <strong className="block text-sm font-semibold text-zinc-100">
527 MySQL Dump Browser
528 </strong>
529 <small className="hidden sm:block text-xs text-zinc-400">
530 Import{" "}
531 <code className="rounded bg-zinc-800 px-1 py-0.5 text-[10px] font-medium text-zinc-300">
532 .sql
533 </code>{" "}
534 or{" "}
535 <code className="rounded bg-zinc-800 px-1 py-0.5 text-[10px] font-medium text-zinc-300">
536 .sql.gz
537 </code>{" "}
538 and explore in-browser
539 </small>
540 </span>
541 </div>
542 <span
543 className={`rounded-full border px-3 py-1 text-xs font-medium whitespace-nowrap ${statusClasses}`}
544 >
545 {engineStatus}
546 </span>
547 </div>
548 </header>
549
550 <main
551 id="main-content"
552 className="animate-slide-up mx-auto w-full max-w-7xl flex-1 space-y-4 px-4 py-6 sm:overflow-y-auto sm:px-6"
553 >
554 {/* Import Section */}
555 <section className="rounded-2xl border border-zinc-800/60 bg-zinc-900/50 p-6">
556 <span className="mb-2 inline-block text-xs font-semibold uppercase tracking-wider text-accent-400">
557 Import
558 </span>
559 <h2 className="text-lg font-semibold text-zinc-100">
560 Load a SQL dump
561 </h2>
562 <p className="mt-1 text-sm leading-relaxed text-zinc-400">
563 {importSummary}
564 </p>
565
566 {importProgress.totalStatements > 0 && (
567 <div className="mt-3 space-y-1.5">
568 <div className="h-1.5 w-full overflow-hidden rounded-full bg-zinc-800">
569 <div
570 className="h-full rounded-full bg-accent-500 transition-[width] duration-300"
571 style={{ width: `${progressPercent}%` }}
572 />
573 </div>
574 <p className="text-xs text-zinc-500">
575 {importProgress.processed.toLocaleString()}/
576 {importProgress.totalStatements.toLocaleString()} statements
577 &middot; {importProgress.applied.toLocaleString()} applied
578 &middot; {importProgress.skipped.toLocaleString()} skipped
579 </p>
580 </div>
581 )}
582
583 <div className="mt-4 flex flex-wrap items-center gap-2">
584 <label className="flex cursor-pointer items-center gap-2 rounded-xl border border-zinc-700/60 bg-zinc-800/80 px-4 py-2 text-sm text-zinc-300 hover:border-zinc-600 hover:bg-zinc-700/60 focus-within:border-accent-500/50 focus-within:ring-1 focus-within:ring-accent-500/30">
585 <FileUp aria-hidden="true" className="h-4 w-4 text-zinc-400" />
586 <span>{selectedFile ? selectedFile.name : "Choose file..."}</span>
587 <input
588 type="file"
589 className="sr-only"
590 accept=".sql,.gz,.sql.gz,application/gzip,text/plain"
591 disabled={isBusy || isImporting}
592 onChange={(event) => {
593 const file = event.target.files?.[0] || null;
594 setSelectedFile(file);
595 setImportSummary(
596 file ? `Selected: ${file.name}` : "No dump loaded.",
597 );
598 }}
599 />
600 </label>
601 <button
602 type="button"
603 disabled={importDisabled}
604 onClick={() => void handleImportDump()}
605 className="flex items-center gap-2 rounded-xl bg-accent-600 px-4 py-2 text-sm font-semibold text-white hover:bg-accent-500 active:scale-[0.98] transition-all disabled:cursor-not-allowed disabled:opacity-50"
606 >
607 <Upload aria-hidden="true" className="h-3.5 w-3.5" />
608 Import
609 </button>
610 <button
611 type="button"
612 disabled={resetDisabled}
613 onClick={() => void handleReset()}
614 className="flex items-center gap-2 rounded-xl border border-zinc-700/60 bg-zinc-800/60 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-700/60 active:scale-[0.98] transition-transform disabled:cursor-not-allowed disabled:opacity-50"
615 >
616 <RotateCcw aria-hidden="true" className="h-3.5 w-3.5" />
617 Reset
618 </button>
619 {isImporting && (
620 <button
621 type="button"
622 onClick={handleCancelImport}
623 className="flex items-center gap-2 rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-2 text-sm font-medium text-red-300 hover:bg-red-500/20 active:scale-[0.98] transition-transform"
624 >
625 <XCircle aria-hidden="true" className="h-3.5 w-3.5" />
626 Cancel
627 </button>
628 )}
629 </div>
630
631 {importLog && (
632 <pre
633 className="mt-4 max-h-48 min-h-[60px] overflow-auto rounded-xl border border-zinc-800 bg-zinc-950/80 p-3 font-mono text-xs leading-relaxed text-zinc-400"
634 aria-live="polite"
635 >
636 {importLog}
637 </pre>
638 )}
639 </section>
640
641 {/* Tables + Data */}
642 <div className="grid gap-5 lg:grid-cols-[minmax(230px,400px)_minmax(0,1fr)]">
643 {/* Sidebar */}
644 <aside className="rounded-2xl border border-zinc-800/60 bg-zinc-900/50 p-5">
645 <div className="flex items-center justify-between">
646 <h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-zinc-500">
647 <TableIcon aria-hidden="true" className="h-3.5 w-3.5" />
648 Tables
649 </h2>
650 <span className="text-xs text-zinc-600">
651 {tables.length.toLocaleString()}
652 </span>
653 </div>
654 <ul className="mt-3 max-h-[620px] space-y-1 overflow-auto">
655 {tables.length === 0 && (
656 <li className="py-4 text-center text-sm text-zinc-600">
657 No tables loaded
658 </li>
659 )}
660 {tables.map((tableName) => {
661 const active = tableName === selectedTable;
662 return (
663 <li key={tableName}>
664 <button
665 type="button"
666 className={`flex w-full items-center gap-2 truncate rounded-xl border px-3 py-2 text-left text-sm ${
667 active
668 ? "border-accent-500/50 bg-accent-500/10 font-medium text-accent-400"
669 : "border-zinc-800/50 bg-zinc-800/30 text-zinc-400 hover:border-zinc-700/60 hover:bg-zinc-900/80 hover:text-zinc-200"
670 }`}
671 onClick={() => {
672 setSelectedTable(tableName);
673 setPage(0);
674 }}
675 >
676 <TableIcon
677 aria-hidden="true"
678 className="h-3 w-3 shrink-0"
679 />
680 {tableName}
681 </button>
682 </li>
683 );
684 })}
685 </ul>
686 </aside>
687
688 {/* Data panel */}
689 <section className="rounded-2xl border border-zinc-800/60 bg-zinc-900/50 p-5">
690 <div className="flex items-center justify-between gap-4">
691 <h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-zinc-500">
692 <Rows3 aria-hidden="true" className="h-3.5 w-3.5" />
693 Data
694 </h2>
695 <div className="flex items-center gap-2">
696 <label htmlFor="pageSize" className="text-xs text-zinc-500">
697 Rows/page
698 </label>
699 <select
700 id="pageSize"
701 value={pageSize}
702 onChange={(event) => {
703 setPageSize(Math.max(1, Number(event.target.value) || 50));
704 setPage(0);
705 }}
706 className="rounded-lg border border-zinc-700/60 bg-zinc-800/80 px-2.5 py-1 text-xs text-zinc-300 outline-none focus:border-accent-500/50"
707 >
708 <option value={25}>25</option>
709 <option value={50}>50</option>
710 <option value={100}>100</option>
711 <option value={250}>250</option>
712 </select>
713 </div>
714 </div>
715
716 <p className="mt-2 text-xs text-zinc-500">{tableMeta}</p>
717
718 <div className="mt-3 max-h-[560px] overflow-auto rounded-xl border border-zinc-800/60 bg-zinc-950/50">
719 <GridTable columns={tableColumns} rows={tableRows} />
720 </div>
721
722 <div className="mt-3 flex items-center gap-2">
723 <button
724 type="button"
725 disabled={!canPrevPage}
726 onClick={() => setPage((prev) => Math.max(0, prev - 1))}
727 className="flex items-center gap-1.5 rounded-lg border border-zinc-700/60 bg-zinc-800/60 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-700/60 active:scale-[0.98] transition-transform disabled:cursor-not-allowed disabled:opacity-40"
728 >
729 <ChevronLeft aria-hidden="true" className="h-3 w-3" />
730 Previous
731 </button>
732 <button
733 type="button"
734 disabled={!canNextPage}
735 onClick={() => setPage((prev) => prev + 1)}
736 className="flex items-center gap-1.5 rounded-lg border border-zinc-700/60 bg-zinc-800/60 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-700/60 active:scale-[0.98] transition-transform disabled:cursor-not-allowed disabled:opacity-40"
737 >
738 Next
739 <ChevronRight aria-hidden="true" className="h-3 w-3" />
740 </button>
741 </div>
742 </section>
743 </div>
744
745 {/* SQL Query */}
746 <section className="rounded-2xl border border-zinc-800/60 bg-zinc-900/50 p-5">
747 <div className="flex items-center justify-between gap-4">
748 <h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-zinc-500">
749 <Terminal aria-hidden="true" className="h-3.5 w-3.5" />
750 SQL Query
751 </h2>
752 <button
753 type="button"
754 disabled={runQueryDisabled}
755 onClick={() => void handleRunQuery()}
756 className="flex items-center gap-2 rounded-xl bg-accent-600 px-4 py-1.5 text-xs font-semibold text-white hover:bg-accent-500 active:scale-[0.98] transition-all disabled:cursor-not-allowed disabled:opacity-50"
757 >
758 <Play aria-hidden="true" className="h-3 w-3" />
759 Run
760 </button>
761 </div>
762 <textarea
763 rows={5}
764 spellCheck={false}
765 value={queryInput}
766 onChange={(event) => setQueryInput(event.target.value)}
767 placeholder={DEFAULT_QUERY}
768 className="mt-3 w-full resize-y rounded-xl border border-zinc-700/60 bg-zinc-800/80 p-3 font-mono text-sm text-zinc-200 outline-none placeholder:text-zinc-600 focus:border-accent-500/50"
769 />
770 {queryMeta && (
771 <p className="mt-2 text-xs text-zinc-500">{queryMeta}</p>
772 )}
773 {queryColumns.length > 0 && (
774 <div className="mt-3 max-h-[400px] overflow-auto rounded-xl border border-zinc-800/60 bg-zinc-950/50">
775 <GridTable columns={queryColumns} rows={queryRows} />
776 </div>
777 )}
778 </section>
779 </main>
780
781 <Footer />
782 </div>
783 );
784}