@@ -338,4 +356,4 @@ const PerfQuery: React.FC = () => {
);
};
-export default PerfQuery;
\ No newline at end of file
+export default PerfQuery;
diff --git a/frontend/src/SQLQuery.tsx b/frontend/src/SQLQuery.tsx
index 8769045c..df4f25ce 100644
--- a/frontend/src/SQLQuery.tsx
+++ b/frontend/src/SQLQuery.tsx
@@ -1,8 +1,8 @@
-import React, { useState, useEffect, useCallback } from 'react';
+import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useRouter } from 'next/router';
import CodeMirror from '@uiw/react-codemirror';
import { sql } from '@codemirror/lang-sql';
-import { EditorView } from '@codemirror/view';
+import { EditorView, keymap, lineNumbers } from '@codemirror/view';
import { autocompletion } from '@codemirror/autocomplete';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { xcodeLight, xcodeLightPatch } from './components/CodeMirrorTheme';
@@ -16,6 +16,13 @@ interface QueryResult {
}
const SQLQuery: React.FC = () => {
+ const isExecutionShortcut = (event: KeyboardEvent) => {
+ if (!(event.metaKey || event.ctrlKey)) {
+ return false;
+ }
+ return event.key === 'Enter' || event.key === 'NumpadEnter' || event.key === 'Return';
+ };
+
const router = useRouter();
// Get query ID from path parameters (for catch-all routes like [slug])
const pathQueryId = router.query.slug && Array.isArray(router.query.slug)
@@ -122,21 +129,24 @@ SELECT * FROM students;`);
}, [query, router]);
// Add global keyboard event listener for Cmd+Enter
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
- e.preventDefault();
+ const runKeymap = useMemo(() => keymap.of([
+ {
+ key: 'Mod-Enter',
+ run: () => {
executeQuery();
- }
- };
-
- window.addEventListener('keydown', handleKeyDown);
-
- // Cleanup event listener on component unmount
- return () => {
- window.removeEventListener('keydown', handleKeyDown);
- };
- }, [executeQuery]);
+ return true;
+ },
+ preventDefault: true,
+ },
+ {
+ key: 'Ctrl-Enter',
+ run: () => {
+ executeQuery();
+ return true;
+ },
+ preventDefault: true,
+ },
+ ]), [executeQuery]);
const renderTable = (result: QueryResult, index: number) => {
// Handle empty data
@@ -204,36 +214,40 @@ SELECT * FROM students;`);
};
return (
-
- {/* Header */}
-
-
-
SQL Query
-
- {loading ? (
- <>
-
- RUNNING...
- >
- ) : (
- <>
- โถ RUN QUERY
- >
- )}
-
-
+
+
+
+ {loading ? (
+ <>
+
+ Running...
+ >
+ ) : (
+ <>
+ โถ
+ Run query
+ >
+ )}
+
+ Press โโ to run
-
- {/* Content - Resizable Panels */}
-
-
+
+
{/* Left Panel - SQL Editor */}
-
-
+
+
setQuery(value)}
editable={!loading}
style={{
@@ -267,9 +277,9 @@ SELECT * FROM students;`);
{/* Right Panel - Results */}
-
-
-
+
+
+
{error ? (
@@ -295,4 +305,4 @@ SELECT * FROM students;`);
);
};
-export default SQLQuery;
\ No newline at end of file
+export default SQLQuery;
diff --git a/frontend/src/components/NotebookCell.tsx b/frontend/src/components/NotebookCell.tsx
new file mode 100644
index 00000000..16a08143
--- /dev/null
+++ b/frontend/src/components/NotebookCell.tsx
@@ -0,0 +1,564 @@
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+import CodeMirror from '@uiw/react-codemirror';
+import { sql } from '@codemirror/lang-sql';
+import { EditorView, keymap, lineNumbers } from '@codemirror/view';
+import { autocompletion } from '@codemirror/autocomplete';
+import { xcodeLight, xcodeLightPatch } from './CodeMirrorTheme';
+import { NotebookCell, QueryResult } from '../types/notebook';
+import { formatRelativeTime } from '../utills/time';
+import {
+ PlayIcon,
+ ChevronDownIcon,
+ ChevronUpIcon,
+ ColumnsIcon,
+ CodeIcon,
+ GripIcon,
+ ExpandIcon,
+ EllipsisIcon,
+} from './icons';
+
+interface NotebookCellProps {
+ cell: NotebookCell;
+ index: number;
+ isActive: boolean;
+ canDelete: boolean;
+ onSqlChange: (sql: string) => void;
+ onExecute: () => void;
+ onDelete: () => void;
+ onSelect: () => void;
+ onToggleCollapse: () => void;
+ onToggleEditor: () => void;
+ onToggleResult: () => void;
+ onToggleFullscreen: () => void;
+ onMoveUp: () => void;
+ onMoveDown: () => void;
+ isFullscreen?: boolean;
+ dragState: {
+ isDragging: boolean;
+ isDragOver: boolean;
+ dragOverPosition?: 'before' | 'after';
+ };
+ onDragStart: () => void;
+ onDragEnd: () => void;
+ onDragOver: (placeAfter?: boolean) => void;
+ onDrop: (placeAfter?: boolean) => void;
+ onRunFromHere?: () => void;
+ onRunToHere?: () => void;
+}
+
+const NotebookCellComponent: React.FC
= ({
+ cell,
+ index,
+ isActive,
+ canDelete,
+ onSqlChange,
+ onExecute,
+ onDelete,
+ onSelect,
+ onToggleCollapse,
+ onToggleEditor,
+ onToggleResult,
+ onToggleFullscreen,
+ onMoveUp,
+ onMoveDown,
+ isFullscreen = false,
+ dragState,
+ onDragStart,
+ onDragEnd,
+ onDragOver,
+ onDrop,
+ onRunFromHere = () => {},
+ onRunToHere = () => {},
+}) => {
+ const cardRef = useRef(null);
+ const isExecutionShortcut = (event: KeyboardEvent) => {
+ if (!(event.metaKey || event.ctrlKey)) {
+ return false;
+ }
+ return event.key === 'Enter' || event.key === 'NumpadEnter' || event.key === 'Return';
+ };
+ const [menuOpen, setMenuOpen] = useState(false);
+ const menuRef = useRef(null);
+ const [runMenuOpen, setRunMenuOpen] = useState(false);
+ const runMenuRef = useRef(null);
+
+ useEffect(() => {
+ if (!menuOpen) return;
+ const handleClick = (event: MouseEvent) => {
+ if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
+ setMenuOpen(false);
+ }
+ };
+ document.addEventListener('mousedown', handleClick);
+ return () => document.removeEventListener('mousedown', handleClick);
+ }, [menuOpen]);
+
+ useEffect(() => {
+ if (!runMenuOpen) return;
+ const handleClick = (event: MouseEvent) => {
+ if (runMenuRef.current && !runMenuRef.current.contains(event.target as Node)) {
+ setRunMenuOpen(false);
+ }
+ };
+ document.addEventListener('mousedown', handleClick);
+ return () => document.removeEventListener('mousedown', handleClick);
+ }, [runMenuOpen]);
+
+ const showEditor = !cell.hideEditor && !cell.collapsed;
+ const showResult = !cell.hideResult && !cell.collapsed;
+
+ const statusLabel = useMemo(() => {
+ if (cell.loading) return 'Running query';
+ if (cell.lastExecutedAt) {
+ return `Ran ${formatRelativeTime(cell.lastExecutedAt)}`;
+ }
+ return 'Ready to run';
+ }, [cell.loading, cell.lastExecutedAt]);
+
+ const renderTable = (result: QueryResult) => {
+ if (!result.data || result.data.length === 0) {
+ return (
+
+ No data returned
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {result.columns.map((column, columnIndex) => (
+
+ {column}
+ {result.types && result.types[columnIndex] && (
+ {result.types[columnIndex]}
+ )}
+
+ ))}
+
+
+
+ {result.data.map((row, rowIndex) => (
+
+ {row.map((value, cellIndex) => (
+
+ {value}
+
+ ))}
+
+ ))}
+
+
+
+
+ );
+ };
+
+
+ const baseCardClasses = [
+ 'flex',
+ 'gap-3',
+ 'rounded-2xl',
+ 'border',
+ 'bg-white/95',
+ 'backdrop-blur-[2px]',
+ 'transition-all',
+ dragState.isDragOver ? 'ring-1 ring-indigo-300 border-indigo-300' : 'border-gray-100',
+ isFullscreen
+ ? 'shadow-[0_0_0_2px_rgba(99,102,241,0.35)] border-indigo-300'
+ : isActive
+ ? 'shadow-[0_15px_35px_-20px_rgba(79,70,229,0.45)]'
+ : 'shadow-sm hover:shadow-md hover:border-gray-200',
+ cell.loading ? 'ring-1 ring-blue-300 border-blue-200 bg-blue-50/80' : '',
+ ].join(' ');
+
+ // ๆพ็ฝฎๆ็คบๅจๆ ทๅผ
+ const dropIndicatorClass = [
+ 'absolute',
+ 'left-0',
+ 'right-0',
+ 'h-1',
+ 'bg-indigo-500/80',
+ 'rounded-full',
+ 'transition-opacity',
+ 'pointer-events-none',
+ 'opacity-0',
+ ].join(' ');
+
+ const dragTransformClass = dragState.isDragOver
+ ? dragState.dragOverPosition === 'after'
+ ? 'translate-y-1.5'
+ : '-translate-y-1.5'
+ : 'translate-y-0';
+ const showTopIndicator = dragState.isDragOver && dragState.dragOverPosition === 'before';
+ const showBottomIndicator = dragState.isDragOver && dragState.dragOverPosition === 'after';
+
+ const editorHeight = isFullscreen ? 'auto' : 'auto';
+ const editorMinHeight = isFullscreen ? '44px' : '44px';
+ const showSideControls = !isFullscreen;
+ const showMenu = !isFullscreen;
+
+ const runKeymap = useMemo(() => keymap.of([
+ {
+ key: 'Mod-Enter',
+ run: () => {
+ onExecute();
+ return true;
+ },
+ preventDefault: true,
+ },
+ {
+ key: 'Mod-NumpadEnter',
+ run: () => {
+ onExecute();
+ return true;
+ },
+ preventDefault: true,
+ },
+ {
+ key: 'Alt-Enter',
+ run: () => {
+ onRunFromHere?.();
+ return true;
+ },
+ preventDefault: true,
+ },
+ {
+ key: 'Shift-Enter',
+ run: () => {
+ onRunToHere?.();
+ return true;
+ },
+ preventDefault: true,
+ },
+ ]), [onExecute, onRunFromHere, onRunToHere]);
+
+ const getPlaceAfter = (event: React.DragEvent) => {
+ if (!cardRef.current) {
+ return false;
+ }
+ const rect = cardRef.current.getBoundingClientRect();
+ return event.clientY - rect.top > rect.height / 2;
+ };
+
+ const handleDragOver = (event: React.DragEvent) => {
+ event.preventDefault();
+ onDragOver(getPlaceAfter(event));
+ };
+
+ const handleDrop = (event: React.DragEvent) => {
+ event.preventDefault();
+ onDrop(getPlaceAfter(event));
+ };
+
+ const handleDragStartWrapper = (event: React.DragEvent) => {
+ event.dataTransfer.effectAllowed = 'move';
+ event.dataTransfer.setData('text/plain', cell.id);
+ if (cardRef.current) {
+ const rect = cardRef.current.getBoundingClientRect();
+ event.dataTransfer.setDragImage(
+ cardRef.current,
+ event.clientX - rect.left,
+ event.clientY - rect.top,
+ );
+ }
+ onDragStart();
+ };
+
+ const handleDragEndWrapper = (event: React.DragEvent) => {
+ event.preventDefault();
+ onDragEnd();
+ };
+
+ return (
+
+ {showTopIndicator && (
+
+ )}
+
+
+ {showSideControls && (
+
+
+
+
+
+ {
+ e.stopPropagation();
+ onToggleCollapse();
+ }}
+ className={`rounded-md border px-1.5 py-1 text-xs ${cell.collapsed ? 'border-indigo-300 text-indigo-500' : 'border-gray-200 hover:text-gray-600'}`}
+ title={cell.collapsed ? 'Expand cell' : 'Collapse cell'}
+ >
+ {cell.collapsed ? : }
+
+ {!cell.collapsed && (
+ <>
+ {
+ e.stopPropagation();
+ onToggleEditor();
+ }}
+ className={`rounded-md border px-1.5 py-1 text-xs ${cell.hideEditor ? 'border-indigo-300 text-indigo-500' : 'border-gray-200 hover:text-gray-600'}`}
+ title={cell.hideEditor ? 'Show SQL editor' : 'Collapse SQL editor'}
+ >
+
+
+ {
+ e.stopPropagation();
+ onToggleResult();
+ }}
+ className={`rounded-md border px-1.5 py-1 text-xs ${cell.hideResult ? 'border-indigo-300 text-indigo-500' : 'border-gray-200 hover:text-gray-600'}`}
+ title={cell.hideResult ? 'Show results' : 'Collapse results'}
+ >
+
+
+ >
+ )}
+
+
+ )}
+
+
+
+
+
{
+ if (!cell.loading && cell.sql.trim()) {
+ setRunMenuOpen(true);
+ }
+ }}
+ onMouseLeave={() => setRunMenuOpen(false)}
+ >
+
{
+ e.stopPropagation();
+ if (cell.loading || !cell.sql.trim()) {
+ return;
+ }
+ onExecute();
+ setRunMenuOpen(false);
+ }}
+ disabled={cell.loading || !cell.sql.trim()}
+ className={`flex h-8 w-8 items-center justify-center rounded-full border text-sm font-semibold transition ${
+ cell.loading || !cell.sql.trim()
+ ? 'border-gray-200 text-gray-400 cursor-not-allowed'
+ : runMenuOpen
+ ? 'border-indigo-400 text-indigo-600 bg-indigo-50'
+ : 'border-gray-300 text-gray-600 hover:border-indigo-300 hover:text-indigo-600'
+ }`}
+ title="Run cell"
+ >
+
+
+ {runMenuOpen && !cell.loading && cell.sql.trim() && (
+
+ {
+ e.stopPropagation();
+ onExecute();
+ setRunMenuOpen(false);
+ }}
+ >
+ Run cell
+ โโ
+
+ {
+ e.stopPropagation();
+ onRunFromHere();
+ setRunMenuOpen(false);
+ }}
+ >
+ Run from here
+ โฅโ
+
+ {
+ e.stopPropagation();
+ onRunToHere();
+ setRunMenuOpen(false);
+ }}
+ >
+ Run to here
+ โงโ
+
+
+ )}
+
+
+ {statusLabel}
+
+
+
+
+
{
+ e.stopPropagation();
+ onToggleFullscreen();
+ }}
+ className={`rounded-full border p-1.5 text-gray-500 hover:text-gray-700 ${
+ isFullscreen ? 'border-indigo-300 text-indigo-500' : 'border-gray-200 hover:border-gray-300'
+ }`}
+ title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen cell'}
+ >
+
+
+ {showMenu && (
+
+
{
+ e.stopPropagation();
+ setMenuOpen((prev) => !prev);
+ }}
+ className="rounded-full border border-gray-200 p-1.5 text-gray-400 hover:border-gray-300 hover:text-gray-600"
+ title="Cell options"
+ >
+
+
+ {menuOpen && (
+
+ {
+ e.stopPropagation();
+ onMoveUp();
+ setMenuOpen(false);
+ }}
+ >
+ Move cell up
+ โโ
+
+ {
+ e.stopPropagation();
+ onMoveDown();
+ setMenuOpen(false);
+ }}
+ >
+ Move cell down
+ โโ
+
+ {canDelete && (
+ {
+ e.stopPropagation();
+ onDelete();
+ setMenuOpen(false);
+ }}
+ >
+ Delete
+
+ )}
+
+ )}
+
+ )}
+
+
+
+ {!cell.collapsed && (
+
+ {showEditor ? (
+
+
+
+ ) : (
+
{
+ e.stopPropagation();
+ onToggleEditor();
+ }}
+ >
+
+ {cell.sql || 'SQL editor collapsed'}
+
+
Show editor
+
+ )}
+
+ {cell.error && showResult && (
+
+ {cell.error}
+
+ )}
+
+ {!cell.error && showResult && cell.result && (
+
+
+ {cell.result.rowCount} row{cell.result.rowCount === 1 ? '' : 's'} returned {cell.result.duration ? `in ${cell.result.duration}` : ''}
+
+
+ {renderTable(cell.result)}
+
+
+ )}
+
+ {!cell.error && !showResult && !cell.collapsed}
+
+ )}
+
+
+
+ {/* ไธๆนๆพ็ฝฎๆ็คบๅจ */}
+ {showBottomIndicator && (
+
+ )}
+
+ );
+};
+
+export default NotebookCellComponent;
diff --git a/frontend/src/components/icons.tsx b/frontend/src/components/icons.tsx
new file mode 100644
index 00000000..5504a117
--- /dev/null
+++ b/frontend/src/components/icons.tsx
@@ -0,0 +1,61 @@
+import React from 'react';
+
+export const PlayIcon: React.FC> = (props) => (
+
+
+
+);
+
+export const ChevronDownIcon: React.FC> = (props) => (
+
+
+
+);
+
+export const ChevronUpIcon: React.FC> = (props) => (
+
+
+
+);
+
+export const ColumnsIcon: React.FC> = (props) => (
+
+
+
+
+);
+
+export const CodeIcon: React.FC> = (props) => (
+
+
+
+
+);
+
+export const GripIcon: React.FC> = (props) => (
+
+
+
+
+
+
+
+
+);
+
+export const ExpandIcon: React.FC> = (props) => (
+
+
+
+
+
+
+);
+
+export const EllipsisIcon: React.FC> = (props) => (
+
+
+
+
+
+);
diff --git a/frontend/src/index.css b/frontend/src/index.css
index a90f0749..47ad4c46 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -2,3 +2,8 @@
@tailwind components;
@tailwind utilities;
+html,
+body,
+#__next {
+ height: 100%;
+}
diff --git a/frontend/src/types/notebook.ts b/frontend/src/types/notebook.ts
new file mode 100644
index 00000000..1de4956c
--- /dev/null
+++ b/frontend/src/types/notebook.ts
@@ -0,0 +1,32 @@
+export interface QueryResult {
+ columns: string[];
+ types: string[];
+ data: string[][];
+ rowCount: number;
+ duration: string;
+}
+
+export interface NotebookCell {
+ id: string;
+ sql: string;
+ result?: QueryResult;
+ error?: string;
+ loading: boolean;
+ lastExecutedAt?: Date;
+ collapsed?: boolean;
+ hideEditor?: boolean;
+ hideResult?: boolean;
+}
+
+export interface Notebook {
+ id: string;
+ name: string;
+ cells: NotebookCell[];
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+export interface NotebookStorage {
+ notebooks: Notebook[];
+ currentNotebookId?: string;
+}
diff --git a/frontend/src/utills/index.ts b/frontend/src/utills/index.ts
index b807d96e..632b49b9 100644
--- a/frontend/src/utills/index.ts
+++ b/frontend/src/utills/index.ts
@@ -1,4 +1,5 @@
export * from "./graph";
+export * from "./time";
/**
* Formats a given percentage value.
@@ -25,4 +26,4 @@ export function transformErrors(errors) {
const type = Object.keys(error)[0];
return { _errorType: type, ...error[type] };
});
-}
\ No newline at end of file
+}
diff --git a/frontend/src/utills/notebookStorage.ts b/frontend/src/utills/notebookStorage.ts
new file mode 100644
index 00000000..1c941d00
--- /dev/null
+++ b/frontend/src/utills/notebookStorage.ts
@@ -0,0 +1,80 @@
+import { Notebook, NotebookCell, NotebookStorage } from '../types/notebook';
+
+const STORAGE_KEY = 'bendsql-notebooks';
+
+export const notebookStorage = {
+ // Get all notebooks from localStorage
+ getNotebooks(): NotebookStorage {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored) {
+ const data = JSON.parse(stored);
+ // Convert date strings back to Date objects
+ return {
+ notebooks: data.notebooks.map((nb: any) => ({
+ ...nb,
+ createdAt: new Date(nb.createdAt),
+ updatedAt: new Date(nb.updatedAt),
+ cells: nb.cells.map((cell: any) => ({
+ ...cell,
+ collapsed: cell.collapsed ?? false,
+ hideEditor: cell.hideEditor ?? false,
+ hideResult: cell.hideResult ?? false,
+ lastExecutedAt: cell.lastExecutedAt ? new Date(cell.lastExecutedAt) : undefined,
+ })),
+ })),
+ currentNotebookId: data.currentNotebookId,
+ };
+ }
+ } catch (error) {
+ console.error('Failed to load notebooks from storage:', error);
+ }
+
+ // Return default empty state
+ return { notebooks: [] };
+ },
+
+ // Save notebooks to localStorage
+ saveNotebooks(storage: NotebookStorage): void {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(storage));
+ } catch (error) {
+ console.error('Failed to save notebooks to storage:', error);
+ }
+ },
+
+ // Create a new notebook
+ createNotebook(name: string = 'Untitled Notebook'): Notebook {
+ const now = new Date();
+ return {
+ id: generateId(),
+ name,
+ cells: [{
+ id: generateId(),
+ sql: '',
+ loading: false,
+ collapsed: false,
+ hideEditor: false,
+ hideResult: false,
+ }],
+ createdAt: now,
+ updatedAt: now,
+ };
+ },
+
+ // Create a new cell
+ createCell(): NotebookCell {
+ return {
+ id: generateId(),
+ sql: '',
+ loading: false,
+ collapsed: false,
+ hideEditor: false,
+ hideResult: false,
+ };
+ },
+};
+
+function generateId(): string {
+ return Date.now().toString(36) + Math.random().toString(36).substr(2);
+}
diff --git a/frontend/src/utills/time.ts b/frontend/src/utills/time.ts
new file mode 100644
index 00000000..767aa6e5
--- /dev/null
+++ b/frontend/src/utills/time.ts
@@ -0,0 +1,34 @@
+export function formatRelativeTime(input?: Date | string | number): string {
+ if (!input) {
+ return '';
+ }
+
+ const date = input instanceof Date ? input : new Date(input);
+ if (isNaN(date.getTime())) {
+ return '';
+ }
+
+ const diff = Date.now() - date.getTime();
+ const minute = 60 * 1000;
+ const hour = 60 * minute;
+ const day = 24 * hour;
+ const week = 7 * day;
+
+ if (diff < minute) {
+ return 'just now';
+ }
+ if (diff < hour) {
+ const minutes = Math.floor(diff / minute);
+ return `${minutes}m ago`;
+ }
+ if (diff < day) {
+ const hours = Math.floor(diff / hour);
+ return `${hours}h ago`;
+ }
+ if (diff < week) {
+ const days = Math.floor(diff / day);
+ return `${days}d ago`;
+ }
+
+ return date.toLocaleDateString();
+}