mirror of
https://github.com/Rohithgilla12/data-peek
synced 2026-04-21 12:57:16 +00:00
feat: SQL Notebooks for team runbooks (#157)
* feat(notebooks): add shared types for notebooks and cells * feat(notebooks): add SQLite storage layer with full CRUD Implements NotebookStorage class backed by better-sqlite3 with WAL mode, foreign-key cascading deletes, and full CRUD for notebooks and cells. * feat(notebooks): add IPC handlers for notebook CRUD * feat(notebooks): add preload bridge for notebook IPC * feat(notebooks): add NotebookTab type and createNotebookTab action Adds 'notebook' to TabType union, NotebookTab interface, notebookId to PersistedTab, createNotebookTab action with deduplication, persistence handling, and notebook type guards in tab-query-editor.tsx. * feat(notebooks): add Zustand store for notebook state management * feat(notebooks): add NotebookEditor component and wire into TabContainer * feat(notebooks): add sidebar section for browsing and creating notebooks * feat(notebooks): add export functions for .dpnb and Markdown formats * feat(notebooks): add NotebookCell component and dependencies Add react-markdown and remark-gfm for markdown cell rendering. NotebookCell handles SQL execution, markdown rendering, result pinning, and keyboard shortcuts. * fix: address review feedback for SQL notebooks - Wrap JSON.parse in try/catch for corrupt pinned_result data - Fix IPC type mismatches: update/duplicate/updateCell return actual data - Remove non-functional Run All button from notebook editor - Replace confirm() dialog with immediate delete + toast notification - Remove dead notebook placeholder branch in tab-query-editor - Remove duplicate react-markdown/remark-gfm from root package.json * chore: lock files * docs(notebooks): add feature docs, demo runbook, and nav entry - SQL Notebooks feature documentation page for docs site - Demo runbook .dpnb file for ACME SaaS health checks - Add sql-notebooks to features navigation * content(notebooks): add release notes, social posts, and 3 blog posts - Release notes for v0.20.0 (SQL Notebooks) - Social media posts (Twitter, Reddit, Dev.to) - Blog 11: Feature announcement - Blog 12: Storage architecture deep dive - Blog 13: Lazy-loading Monaco in notebook cells * content(notebooks): add Threads posts and posting strategy 4 Threads posts: launch carousel thread, technical behind-the-scenes, runbook showcase, and conversation starter. Includes posting cadence and format tips. * docs(notebooks): add demo recording script * feat(video): add release video and demo video for SQL Notebooks - ReleaseVideo020: 24s release announcement (4 feature scenes) - NotebookDemo: 45s feature walkthrough with animated notebook mockup showing cells typing in, queries executing, results sliding in, keyboard navigation, and export formats * feat(video): add new background music for notebook videos Screen Saver by Kevin MacLeod (CC BY 4.0) as primary track, Equatorial Complex as alternative. Both compositions updated to use the new track. * fix: apply CodeRabbit auto-fixes Fixed 5 file(s) based on 5 unresolved review comments. Co-authored-by: CodeRabbit <noreply@coderabbit.ai> --------- Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
This commit is contained in:
parent
bdf4b709a4
commit
c9f8e4297d
43 changed files with 6910 additions and 75 deletions
|
|
@ -79,7 +79,9 @@
|
|||
"papaparse": "^5.5.3",
|
||||
"pg": "^8.16.3",
|
||||
"react-grid-layout": "^2.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^3.5.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sql-formatter": "^15.6.10",
|
||||
"ssh2": "^1.17.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
|
|
|
|||
373
apps/desktop/src/main/__tests__/notebook-storage.test.ts
Normal file
373
apps/desktop/src/main/__tests__/notebook-storage.test.ts
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
|
||||
vi.mock('electron-log/main', () => ({
|
||||
default: {
|
||||
initialize: vi.fn(),
|
||||
transports: {
|
||||
console: { level: 'debug' },
|
||||
file: { level: 'debug', maxSize: 0, format: '' }
|
||||
},
|
||||
scope: () => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn()
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
isPackaged: false
|
||||
}
|
||||
}))
|
||||
|
||||
import { NotebookStorage } from '../notebook-storage'
|
||||
|
||||
describe('NotebookStorage', () => {
|
||||
let storage: NotebookStorage
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'notebook-storage-test-'))
|
||||
storage = new NotebookStorage(tmpDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
storage.close()
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('creates and lists notebooks', () => {
|
||||
it('returns empty array when no notebooks exist', () => {
|
||||
const notebooks = storage.listNotebooks()
|
||||
expect(notebooks).toEqual([])
|
||||
})
|
||||
|
||||
it('creates a notebook and lists it', () => {
|
||||
const notebook = storage.createNotebook({
|
||||
title: 'My Notebook',
|
||||
connectionId: 'conn-1'
|
||||
})
|
||||
|
||||
expect(notebook.id).toBeDefined()
|
||||
expect(notebook.title).toBe('My Notebook')
|
||||
expect(notebook.connectionId).toBe('conn-1')
|
||||
expect(notebook.folder).toBeNull()
|
||||
expect(notebook.createdAt).toBeGreaterThan(0)
|
||||
expect(notebook.updatedAt).toBeGreaterThan(0)
|
||||
|
||||
const list = storage.listNotebooks()
|
||||
expect(list).toHaveLength(1)
|
||||
expect(list[0].id).toBe(notebook.id)
|
||||
expect(list[0].title).toBe('My Notebook')
|
||||
})
|
||||
|
||||
it('lists multiple notebooks ordered by updatedAt desc', () => {
|
||||
const nb1 = storage.createNotebook({ title: 'First', connectionId: 'conn-1' })
|
||||
const nb2 = storage.createNotebook({ title: 'Second', connectionId: 'conn-1' })
|
||||
const nb3 = storage.createNotebook({ title: 'Third', connectionId: 'conn-1' })
|
||||
|
||||
const list = storage.listNotebooks()
|
||||
expect(list).toHaveLength(3)
|
||||
expect(list.map((n) => n.id)).toContain(nb1.id)
|
||||
expect(list.map((n) => n.id)).toContain(nb2.id)
|
||||
expect(list.map((n) => n.id)).toContain(nb3.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('creates notebook with folder', () => {
|
||||
it('creates a notebook with a folder', () => {
|
||||
const notebook = storage.createNotebook({
|
||||
title: 'Foldered Notebook',
|
||||
connectionId: 'conn-2',
|
||||
folder: 'work/analytics'
|
||||
})
|
||||
|
||||
expect(notebook.folder).toBe('work/analytics')
|
||||
|
||||
const list = storage.listNotebooks()
|
||||
expect(list[0].folder).toBe('work/analytics')
|
||||
})
|
||||
})
|
||||
|
||||
describe('gets notebook with cells', () => {
|
||||
it('returns null for non-existent notebook', () => {
|
||||
const result = storage.getNotebook('nonexistent-id')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns notebook with empty cells array', () => {
|
||||
const notebook = storage.createNotebook({ title: 'Empty', connectionId: 'conn-1' })
|
||||
const result = storage.getNotebook(notebook.id)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.id).toBe(notebook.id)
|
||||
expect(result!.title).toBe('Empty')
|
||||
expect(result!.cells).toEqual([])
|
||||
})
|
||||
|
||||
it('returns notebook with its cells', () => {
|
||||
const notebook = storage.createNotebook({ title: 'With Cells', connectionId: 'conn-1' })
|
||||
storage.addCell(notebook.id, { type: 'sql', content: 'SELECT 1', order: 0 })
|
||||
storage.addCell(notebook.id, { type: 'markdown', content: '# Title', order: 1 })
|
||||
|
||||
const result = storage.getNotebook(notebook.id)
|
||||
expect(result!.cells).toHaveLength(2)
|
||||
expect(result!.cells[0].content).toBe('SELECT 1')
|
||||
expect(result!.cells[0].type).toBe('sql')
|
||||
expect(result!.cells[1].content).toBe('# Title')
|
||||
expect(result!.cells[1].type).toBe('markdown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('updates notebook title and folder', () => {
|
||||
it('updates the title', () => {
|
||||
const notebook = storage.createNotebook({ title: 'Old Title', connectionId: 'conn-1' })
|
||||
const updated = storage.updateNotebook(notebook.id, { title: 'New Title' })
|
||||
|
||||
expect(updated).not.toBeNull()
|
||||
expect(updated!.title).toBe('New Title')
|
||||
expect(updated!.updatedAt).toBeGreaterThanOrEqual(notebook.updatedAt)
|
||||
})
|
||||
|
||||
it('updates the folder', () => {
|
||||
const notebook = storage.createNotebook({
|
||||
title: 'NB',
|
||||
connectionId: 'conn-1',
|
||||
folder: 'old-folder'
|
||||
})
|
||||
const updated = storage.updateNotebook(notebook.id, { folder: 'new-folder' })
|
||||
|
||||
expect(updated!.folder).toBe('new-folder')
|
||||
})
|
||||
|
||||
it('clears the folder when set to null', () => {
|
||||
const notebook = storage.createNotebook({
|
||||
title: 'NB',
|
||||
connectionId: 'conn-1',
|
||||
folder: 'some-folder'
|
||||
})
|
||||
const updated = storage.updateNotebook(notebook.id, { folder: null })
|
||||
|
||||
expect(updated!.folder).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for non-existent notebook', () => {
|
||||
const result = storage.updateNotebook('nonexistent', { title: 'X' })
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deletes notebook and cascades cells', () => {
|
||||
it('deletes a notebook', () => {
|
||||
const notebook = storage.createNotebook({ title: 'To Delete', connectionId: 'conn-1' })
|
||||
storage.deleteNotebook(notebook.id)
|
||||
|
||||
const list = storage.listNotebooks()
|
||||
expect(list).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('cascades deletion to cells', () => {
|
||||
const notebook = storage.createNotebook({ title: 'Has Cells', connectionId: 'conn-1' })
|
||||
const cell = storage.addCell(notebook.id, { type: 'sql', content: 'SELECT 1', order: 0 })
|
||||
|
||||
storage.deleteNotebook(notebook.id)
|
||||
|
||||
// The cell should no longer exist; verify by recreating and checking no cells leak
|
||||
const newNotebook = storage.createNotebook({ title: 'Fresh', connectionId: 'conn-1' })
|
||||
const result = storage.getNotebook(newNotebook.id)
|
||||
expect(result!.cells).toHaveLength(0)
|
||||
|
||||
// Also ensure the old notebook is gone
|
||||
expect(storage.getNotebook(notebook.id)).toBeNull()
|
||||
|
||||
void cell // used above indirectly
|
||||
})
|
||||
})
|
||||
|
||||
describe('duplicates notebook to different connection', () => {
|
||||
it('duplicates a notebook preserving title and cells', () => {
|
||||
const original = storage.createNotebook({
|
||||
title: 'Original',
|
||||
connectionId: 'conn-1',
|
||||
folder: 'myfolder'
|
||||
})
|
||||
storage.addCell(original.id, { type: 'sql', content: 'SELECT 1', order: 0 })
|
||||
storage.addCell(original.id, { type: 'markdown', content: '# Note', order: 1 })
|
||||
|
||||
const duplicate = storage.duplicateNotebook(original.id, 'conn-2')
|
||||
|
||||
expect(duplicate).not.toBeNull()
|
||||
expect(duplicate!.id).not.toBe(original.id)
|
||||
expect(duplicate!.title).toBe('Original')
|
||||
expect(duplicate!.connectionId).toBe('conn-2')
|
||||
expect(duplicate!.folder).toBe('myfolder')
|
||||
|
||||
const dupWithCells = storage.getNotebook(duplicate!.id)
|
||||
expect(dupWithCells!.cells).toHaveLength(2)
|
||||
expect(dupWithCells!.cells[0].content).toBe('SELECT 1')
|
||||
expect(dupWithCells!.cells[1].content).toBe('# Note')
|
||||
// Duplicated cells should have new IDs
|
||||
const originalWithCells = storage.getNotebook(original.id)
|
||||
expect(dupWithCells!.cells[0].id).not.toBe(originalWithCells!.cells[0].id)
|
||||
})
|
||||
|
||||
it('returns null when duplicating non-existent notebook', () => {
|
||||
const result = storage.duplicateNotebook('nonexistent', 'conn-2')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('adds and retrieves cells in order', () => {
|
||||
it('adds a cell and retrieves it', () => {
|
||||
const notebook = storage.createNotebook({ title: 'NB', connectionId: 'conn-1' })
|
||||
const cell = storage.addCell(notebook.id, { type: 'sql', content: 'SELECT *', order: 0 })
|
||||
|
||||
expect(cell.id).toBeDefined()
|
||||
expect(cell.notebookId).toBe(notebook.id)
|
||||
expect(cell.type).toBe('sql')
|
||||
expect(cell.content).toBe('SELECT *')
|
||||
expect(cell.order).toBe(0)
|
||||
expect(cell.pinnedResult).toBeNull()
|
||||
expect(cell.createdAt).toBeGreaterThan(0)
|
||||
expect(cell.updatedAt).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('retrieves cells sorted by order', () => {
|
||||
const notebook = storage.createNotebook({ title: 'NB', connectionId: 'conn-1' })
|
||||
storage.addCell(notebook.id, { type: 'sql', content: 'C', order: 2 })
|
||||
storage.addCell(notebook.id, { type: 'sql', content: 'A', order: 0 })
|
||||
storage.addCell(notebook.id, { type: 'sql', content: 'B', order: 1 })
|
||||
|
||||
const result = storage.getNotebook(notebook.id)
|
||||
expect(result!.cells.map((c) => c.content)).toEqual(['A', 'B', 'C'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('updates cell content', () => {
|
||||
it('updates the content of a cell', () => {
|
||||
const notebook = storage.createNotebook({ title: 'NB', connectionId: 'conn-1' })
|
||||
const cell = storage.addCell(notebook.id, { type: 'sql', content: 'SELECT 1', order: 0 })
|
||||
|
||||
const updated = storage.updateCell(cell.id, { content: 'SELECT 2' })
|
||||
expect(updated).not.toBeNull()
|
||||
expect(updated!.content).toBe('SELECT 2')
|
||||
expect(updated!.updatedAt).toBeGreaterThanOrEqual(cell.updatedAt)
|
||||
})
|
||||
|
||||
it('returns null for non-existent cell', () => {
|
||||
const result = storage.updateCell('nonexistent', { content: 'x' })
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('updates notebook updatedAt when cell content changes', () => {
|
||||
const notebook = storage.createNotebook({ title: 'NB', connectionId: 'conn-1' })
|
||||
const cell = storage.addCell(notebook.id, { type: 'sql', content: 'SELECT 1', order: 0 })
|
||||
|
||||
storage.updateCell(cell.id, { content: 'SELECT 2' })
|
||||
const updated = storage.getNotebook(notebook.id)
|
||||
expect(updated!.updatedAt).toBeGreaterThanOrEqual(notebook.updatedAt)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pins and unpins results', () => {
|
||||
it('pins a result to a cell', () => {
|
||||
const notebook = storage.createNotebook({ title: 'NB', connectionId: 'conn-1' })
|
||||
const cell = storage.addCell(notebook.id, { type: 'sql', content: 'SELECT 1', order: 0 })
|
||||
|
||||
const pinnedResult = {
|
||||
columns: ['id', 'name'],
|
||||
rows: [[1, 'Alice'], [2, 'Bob']],
|
||||
rowCount: 2,
|
||||
executedAt: Date.now(),
|
||||
durationMs: 42,
|
||||
error: null
|
||||
}
|
||||
|
||||
const updated = storage.updateCell(cell.id, { pinnedResult })
|
||||
expect(updated!.pinnedResult).not.toBeNull()
|
||||
expect(updated!.pinnedResult!.columns).toEqual(['id', 'name'])
|
||||
expect(updated!.pinnedResult!.rowCount).toBe(2)
|
||||
expect(updated!.pinnedResult!.durationMs).toBe(42)
|
||||
})
|
||||
|
||||
it('unpins a result from a cell', () => {
|
||||
const notebook = storage.createNotebook({ title: 'NB', connectionId: 'conn-1' })
|
||||
const cell = storage.addCell(notebook.id, { type: 'sql', content: 'SELECT 1', order: 0 })
|
||||
|
||||
const pinnedResult = {
|
||||
columns: ['id'],
|
||||
rows: [[1]],
|
||||
rowCount: 1,
|
||||
executedAt: Date.now(),
|
||||
durationMs: 10,
|
||||
error: null
|
||||
}
|
||||
|
||||
storage.updateCell(cell.id, { pinnedResult })
|
||||
const unpinned = storage.updateCell(cell.id, { pinnedResult: null })
|
||||
expect(unpinned!.pinnedResult).toBeNull()
|
||||
})
|
||||
|
||||
it('persists pinned result across getNotebook calls', () => {
|
||||
const notebook = storage.createNotebook({ title: 'NB', connectionId: 'conn-1' })
|
||||
const cell = storage.addCell(notebook.id, { type: 'sql', content: 'SELECT 1', order: 0 })
|
||||
|
||||
const pinnedResult = {
|
||||
columns: ['val'],
|
||||
rows: [[42]],
|
||||
rowCount: 1,
|
||||
executedAt: 1000,
|
||||
durationMs: 5,
|
||||
error: null
|
||||
}
|
||||
|
||||
storage.updateCell(cell.id, { pinnedResult })
|
||||
const fetched = storage.getNotebook(notebook.id)
|
||||
expect(fetched!.cells[0].pinnedResult).toEqual(pinnedResult)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deletes a cell', () => {
|
||||
it('deletes a cell from a notebook', () => {
|
||||
const notebook = storage.createNotebook({ title: 'NB', connectionId: 'conn-1' })
|
||||
const cell = storage.addCell(notebook.id, { type: 'sql', content: 'SELECT 1', order: 0 })
|
||||
storage.addCell(notebook.id, { type: 'sql', content: 'SELECT 2', order: 1 })
|
||||
|
||||
storage.deleteCell(cell.id)
|
||||
|
||||
const result = storage.getNotebook(notebook.id)
|
||||
expect(result!.cells).toHaveLength(1)
|
||||
expect(result!.cells[0].content).toBe('SELECT 2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('reorders cells', () => {
|
||||
it('reorders cells in a notebook', () => {
|
||||
const notebook = storage.createNotebook({ title: 'NB', connectionId: 'conn-1' })
|
||||
const cell1 = storage.addCell(notebook.id, { type: 'sql', content: 'A', order: 0 })
|
||||
const cell2 = storage.addCell(notebook.id, { type: 'sql', content: 'B', order: 1 })
|
||||
const cell3 = storage.addCell(notebook.id, { type: 'sql', content: 'C', order: 2 })
|
||||
|
||||
storage.reorderCells(notebook.id, [cell3.id, cell1.id, cell2.id])
|
||||
|
||||
const result = storage.getNotebook(notebook.id)
|
||||
expect(result!.cells.map((c) => c.content)).toEqual(['C', 'A', 'B'])
|
||||
})
|
||||
|
||||
it('reorders cells and updates notebook updatedAt', () => {
|
||||
const notebook = storage.createNotebook({ title: 'NB', connectionId: 'conn-1' })
|
||||
const cell1 = storage.addCell(notebook.id, { type: 'sql', content: 'A', order: 0 })
|
||||
const cell2 = storage.addCell(notebook.id, { type: 'sql', content: 'B', order: 1 })
|
||||
|
||||
storage.reorderCells(notebook.id, [cell2.id, cell1.id])
|
||||
|
||||
const result = storage.getNotebook(notebook.id)
|
||||
expect(result!.updatedAt).toBeGreaterThanOrEqual(notebook.updatedAt)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -11,6 +11,7 @@ import { initLicenseStore } from './license-service'
|
|||
import { initAIStore } from './ai-service'
|
||||
import { initAutoUpdater, stopPeriodicChecks } from './updater'
|
||||
import { DpStorage } from './storage'
|
||||
import { NotebookStorage } from './notebook-storage'
|
||||
import { initSchemaCache } from './schema-cache'
|
||||
import { registerAllHandlers } from './ipc'
|
||||
import { setForceQuit } from './app-state'
|
||||
|
|
@ -102,11 +103,12 @@ app.whenReady().then(async () => {
|
|||
})
|
||||
|
||||
// Register all IPC handlers
|
||||
const notebookStorage = new NotebookStorage(app.getPath('userData'))
|
||||
registerAllHandlers({
|
||||
connections: store,
|
||||
savedQueries: savedQueriesStore,
|
||||
snippets: snippetsStore
|
||||
})
|
||||
}, notebookStorage)
|
||||
|
||||
// Create initial window
|
||||
await windowManager.createWindow()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { ConnectionConfig, SavedQuery, Snippet } from '@shared/index'
|
||||
import type { DpStorage } from '../storage'
|
||||
import type { NotebookStorage } from '../notebook-storage'
|
||||
import { registerConnectionHandlers } from './connection-handlers'
|
||||
import { registerQueryHandlers } from './query-handlers'
|
||||
import { registerDDLHandlers } from './ddl-handlers'
|
||||
|
|
@ -18,6 +19,7 @@ import { registerDataGenHandlers } from './data-gen-handlers'
|
|||
import { registerPgNotifyHandlers } from './pg-notify-handlers'
|
||||
import { registerHealthHandlers } from './health-handlers'
|
||||
import { registerPgExportImportHandlers } from './pg-export-import-handlers'
|
||||
import { registerNotebookHandlers } from './notebook-handlers'
|
||||
|
||||
const log = createLogger('ipc')
|
||||
|
||||
|
|
@ -32,7 +34,7 @@ export interface IpcStores {
|
|||
*
|
||||
* @param stores - Persistent stores required by handler categories; includes `connections` (connection configs) and `savedQueries` (saved query entries)
|
||||
*/
|
||||
export function registerAllHandlers(stores: IpcStores): void {
|
||||
export function registerAllHandlers(stores: IpcStores, notebookStorage: NotebookStorage): void {
|
||||
// Connection CRUD operations
|
||||
registerConnectionHandlers(stores.connections)
|
||||
|
||||
|
|
@ -84,6 +86,9 @@ export function registerAllHandlers(stores: IpcStores): void {
|
|||
// PostgreSQL export/import (pg_dump/pg_restore)
|
||||
registerPgExportImportHandlers()
|
||||
|
||||
// SQL Notebooks
|
||||
registerNotebookHandlers(notebookStorage)
|
||||
|
||||
log.debug('All handlers registered')
|
||||
}
|
||||
|
||||
|
|
@ -102,3 +107,4 @@ export { registerDataGenHandlers } from './data-gen-handlers'
|
|||
export { registerPgNotifyHandlers } from './pg-notify-handlers'
|
||||
export { registerHealthHandlers } from './health-handlers'
|
||||
export { registerPgExportImportHandlers } from './pg-export-import-handlers'
|
||||
export { registerNotebookHandlers } from './notebook-handlers'
|
||||
|
|
|
|||
124
apps/desktop/src/main/ipc/notebook-handlers.ts
Normal file
124
apps/desktop/src/main/ipc/notebook-handlers.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { ipcMain } from 'electron'
|
||||
import type { NotebookStorage } from '../notebook-storage'
|
||||
import type { CreateNotebookInput, UpdateNotebookInput, AddCellInput, UpdateCellInput } from '@shared/index'
|
||||
|
||||
export function registerNotebookHandlers(storage: NotebookStorage): void {
|
||||
ipcMain.handle('notebooks:list', () => {
|
||||
try {
|
||||
const data = storage.listNotebooks()
|
||||
return { success: true, data }
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('notebooks:get', (_, id: string) => {
|
||||
try {
|
||||
const data = storage.getNotebook(id)
|
||||
if (!data) return { success: false, error: 'Notebook not found' }
|
||||
return { success: true, data }
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('notebooks:create', (_, input: CreateNotebookInput) => {
|
||||
try {
|
||||
const data = storage.createNotebook(input)
|
||||
return { success: true, data }
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'notebooks:update',
|
||||
(_, { id, updates }: { id: string; updates: UpdateNotebookInput }) => {
|
||||
try {
|
||||
const data = storage.updateNotebook(id, updates)
|
||||
if (!data) return { success: false, error: 'Notebook not found' }
|
||||
return { success: true, data }
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('notebooks:delete', (_, id: string) => {
|
||||
try {
|
||||
storage.deleteNotebook(id)
|
||||
return { success: true }
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'notebooks:duplicate',
|
||||
(_, { id, connectionId }: { id: string; connectionId: string }) => {
|
||||
try {
|
||||
const data = storage.duplicateNotebook(id, connectionId)
|
||||
if (!data) return { success: false, error: 'Notebook not found' }
|
||||
return { success: true, data }
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'notebooks:add-cell',
|
||||
(_, { notebookId, input }: { notebookId: string; input: AddCellInput }) => {
|
||||
try {
|
||||
const data = storage.addCell(notebookId, input)
|
||||
return { success: true, data }
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'notebooks:update-cell',
|
||||
(_, { cellId, updates }: { cellId: string; updates: UpdateCellInput }) => {
|
||||
try {
|
||||
const data = storage.updateCell(cellId, updates)
|
||||
if (!data) return { success: false, error: 'Cell not found' }
|
||||
return { success: true, data }
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('notebooks:delete-cell', (_, cellId: string) => {
|
||||
try {
|
||||
storage.deleteCell(cellId)
|
||||
return { success: true }
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'notebooks:reorder-cells',
|
||||
(_, { notebookId, cellIds }: { notebookId: string; cellIds: string[] }) => {
|
||||
try {
|
||||
storage.reorderCells(notebookId, cellIds)
|
||||
return { success: true }
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
287
apps/desktop/src/main/notebook-storage.ts
Normal file
287
apps/desktop/src/main/notebook-storage.ts
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
import { randomUUID } from 'crypto'
|
||||
import { join } from 'path'
|
||||
import Database from 'better-sqlite3'
|
||||
import type {
|
||||
Notebook,
|
||||
NotebookCell,
|
||||
NotebookWithCells,
|
||||
PinnedResult,
|
||||
CreateNotebookInput,
|
||||
UpdateNotebookInput,
|
||||
AddCellInput,
|
||||
UpdateCellInput
|
||||
} from '@shared/index'
|
||||
import { createLogger } from './lib/logger'
|
||||
|
||||
const log = createLogger('notebook-storage')
|
||||
|
||||
interface NotebookRow {
|
||||
id: string
|
||||
title: string
|
||||
connection_id: string
|
||||
folder: string | null
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
interface CellRow {
|
||||
id: string
|
||||
notebook_id: string
|
||||
type: 'sql' | 'markdown'
|
||||
content: string
|
||||
pinned_result: string | null
|
||||
order_index: number
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
function rowToNotebook(row: NotebookRow): Notebook {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
connectionId: row.connection_id,
|
||||
folder: row.folder,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
}
|
||||
}
|
||||
|
||||
function parsePinnedResult(raw: string): PinnedResult | null {
|
||||
try {
|
||||
return JSON.parse(raw) as PinnedResult
|
||||
} catch {
|
||||
log.warn('Corrupt pinned_result JSON, falling back to null')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function rowToCell(row: CellRow): NotebookCell {
|
||||
return {
|
||||
id: row.id,
|
||||
notebookId: row.notebook_id,
|
||||
type: row.type,
|
||||
content: row.content,
|
||||
pinnedResult: row.pinned_result ? parsePinnedResult(row.pinned_result) : null,
|
||||
order: row.order_index,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
}
|
||||
}
|
||||
|
||||
export class NotebookStorage {
|
||||
private db: Database.Database
|
||||
|
||||
constructor(userDataPath: string) {
|
||||
const dbPath = join(userDataPath, 'notebooks.db')
|
||||
this.db = new Database(dbPath)
|
||||
this.db.pragma('journal_mode = WAL')
|
||||
this.db.pragma('foreign_keys = ON')
|
||||
this.init()
|
||||
log.info('NotebookStorage initialised', dbPath)
|
||||
}
|
||||
|
||||
private init(): void {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS notebooks (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
connection_id TEXT NOT NULL,
|
||||
folder TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notebook_cells (
|
||||
id TEXT PRIMARY KEY,
|
||||
notebook_id TEXT NOT NULL REFERENCES notebooks(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL CHECK(type IN ('sql', 'markdown')),
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
pinned_result TEXT,
|
||||
order_index INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notebook_cells_notebook_id ON notebook_cells(notebook_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notebooks_updated_at ON notebooks(updated_at DESC);
|
||||
`)
|
||||
}
|
||||
|
||||
private touchNotebook(id: string): void {
|
||||
const now = Date.now()
|
||||
this.db.prepare('UPDATE notebooks SET updated_at = ? WHERE id = ?').run(now, id)
|
||||
}
|
||||
|
||||
listNotebooks(): Notebook[] {
|
||||
const rows = this.db
|
||||
.prepare('SELECT * FROM notebooks ORDER BY updated_at DESC')
|
||||
.all() as NotebookRow[]
|
||||
return rows.map(rowToNotebook)
|
||||
}
|
||||
|
||||
getNotebook(id: string): NotebookWithCells | null {
|
||||
const notebookRow = this.db.prepare('SELECT * FROM notebooks WHERE id = ?').get(id) as
|
||||
| NotebookRow
|
||||
| undefined
|
||||
|
||||
if (!notebookRow) return null
|
||||
|
||||
const cellRows = this.db
|
||||
.prepare('SELECT * FROM notebook_cells WHERE notebook_id = ? ORDER BY order_index ASC')
|
||||
.all(id) as CellRow[]
|
||||
|
||||
return {
|
||||
...rowToNotebook(notebookRow),
|
||||
cells: cellRows.map(rowToCell)
|
||||
}
|
||||
}
|
||||
|
||||
createNotebook(input: CreateNotebookInput): Notebook {
|
||||
const now = Date.now()
|
||||
const id = randomUUID()
|
||||
this.db
|
||||
.prepare(
|
||||
'INSERT INTO notebooks (id, title, connection_id, folder, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run(id, input.title, input.connectionId, input.folder ?? null, now, now)
|
||||
log.debug('Created notebook', id)
|
||||
return rowToNotebook(
|
||||
this.db.prepare('SELECT * FROM notebooks WHERE id = ?').get(id) as NotebookRow
|
||||
)
|
||||
}
|
||||
|
||||
updateNotebook(id: string, input: UpdateNotebookInput): Notebook | null {
|
||||
const existing = this.db.prepare('SELECT * FROM notebooks WHERE id = ?').get(id) as
|
||||
| NotebookRow
|
||||
| undefined
|
||||
if (!existing) return null
|
||||
|
||||
const now = Date.now()
|
||||
const title = input.title !== undefined ? input.title : existing.title
|
||||
const folder = input.folder !== undefined ? input.folder : existing.folder
|
||||
|
||||
this.db
|
||||
.prepare('UPDATE notebooks SET title = ?, folder = ?, updated_at = ? WHERE id = ?')
|
||||
.run(title, folder, now, id)
|
||||
|
||||
return rowToNotebook(
|
||||
this.db.prepare('SELECT * FROM notebooks WHERE id = ?').get(id) as NotebookRow
|
||||
)
|
||||
}
|
||||
|
||||
deleteNotebook(id: string): void {
|
||||
this.db.prepare('DELETE FROM notebooks WHERE id = ?').run(id)
|
||||
log.debug('Deleted notebook', id)
|
||||
}
|
||||
|
||||
duplicateNotebook(id: string, targetConnectionId: string): Notebook | null {
|
||||
const original = this.getNotebook(id)
|
||||
if (!original) return null
|
||||
|
||||
const duplicateInTransaction = this.db.transaction(() => {
|
||||
const now = Date.now()
|
||||
const newNotebookId = randomUUID()
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
'INSERT INTO notebooks (id, title, connection_id, folder, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run(newNotebookId, original.title, targetConnectionId, original.folder, now, now)
|
||||
|
||||
for (const cell of original.cells) {
|
||||
const newCellId = randomUUID()
|
||||
this.db
|
||||
.prepare(
|
||||
'INSERT INTO notebook_cells (id, notebook_id, type, content, pinned_result, order_index, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run(
|
||||
newCellId,
|
||||
newNotebookId,
|
||||
cell.type,
|
||||
cell.content,
|
||||
cell.pinnedResult ? JSON.stringify(cell.pinnedResult) : null,
|
||||
cell.order,
|
||||
now,
|
||||
now
|
||||
)
|
||||
}
|
||||
|
||||
return newNotebookId
|
||||
})
|
||||
|
||||
const newId = duplicateInTransaction()
|
||||
log.debug('Duplicated notebook', id, '->', newId)
|
||||
return rowToNotebook(
|
||||
this.db.prepare('SELECT * FROM notebooks WHERE id = ?').get(newId) as NotebookRow
|
||||
)
|
||||
}
|
||||
|
||||
addCell(notebookId: string, input: AddCellInput): NotebookCell {
|
||||
const now = Date.now()
|
||||
const id = randomUUID()
|
||||
this.db
|
||||
.prepare(
|
||||
'INSERT INTO notebook_cells (id, notebook_id, type, content, pinned_result, order_index, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run(id, notebookId, input.type, input.content, null, input.order, now, now)
|
||||
this.touchNotebook(notebookId)
|
||||
log.debug('Added cell', id, 'to notebook', notebookId)
|
||||
return rowToCell(
|
||||
this.db.prepare('SELECT * FROM notebook_cells WHERE id = ?').get(id) as CellRow
|
||||
)
|
||||
}
|
||||
|
||||
updateCell(id: string, input: UpdateCellInput): NotebookCell | null {
|
||||
const existing = this.db.prepare('SELECT * FROM notebook_cells WHERE id = ?').get(id) as
|
||||
| CellRow
|
||||
| undefined
|
||||
if (!existing) return null
|
||||
|
||||
const now = Date.now()
|
||||
const content = input.content !== undefined ? input.content : existing.content
|
||||
const pinnedResult =
|
||||
input.pinnedResult !== undefined
|
||||
? input.pinnedResult === null
|
||||
? null
|
||||
: JSON.stringify(input.pinnedResult)
|
||||
: existing.pinned_result
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
'UPDATE notebook_cells SET content = ?, pinned_result = ?, updated_at = ? WHERE id = ?'
|
||||
)
|
||||
.run(content, pinnedResult, now, id)
|
||||
|
||||
this.touchNotebook(existing.notebook_id)
|
||||
return rowToCell(
|
||||
this.db.prepare('SELECT * FROM notebook_cells WHERE id = ?').get(id) as CellRow
|
||||
)
|
||||
}
|
||||
|
||||
deleteCell(id: string): void {
|
||||
const cell = this.db.prepare('SELECT * FROM notebook_cells WHERE id = ?').get(id) as
|
||||
| CellRow
|
||||
| undefined
|
||||
if (!cell) return
|
||||
this.db.prepare('DELETE FROM notebook_cells WHERE id = ?').run(id)
|
||||
this.touchNotebook(cell.notebook_id)
|
||||
log.debug('Deleted cell', id)
|
||||
}
|
||||
|
||||
reorderCells(notebookId: string, orderedIds: string[]): void {
|
||||
const reorderInTransaction = this.db.transaction(() => {
|
||||
for (let i = 0; i < orderedIds.length; i++) {
|
||||
this.db
|
||||
.prepare('UPDATE notebook_cells SET order_index = ? WHERE id = ? AND notebook_id = ?')
|
||||
.run(i, orderedIds[i], notebookId)
|
||||
}
|
||||
this.touchNotebook(notebookId)
|
||||
})
|
||||
reorderInTransaction()
|
||||
log.debug('Reordered cells for notebook', notebookId)
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.db.close()
|
||||
}
|
||||
}
|
||||
25
apps/desktop/src/preload/index.d.ts
vendored
25
apps/desktop/src/preload/index.d.ts
vendored
|
|
@ -54,7 +54,14 @@ import type {
|
|||
PgExportResult,
|
||||
PgImportOptions,
|
||||
PgImportProgress,
|
||||
PgImportResult
|
||||
PgImportResult,
|
||||
Notebook,
|
||||
NotebookWithCells,
|
||||
NotebookCell,
|
||||
CreateNotebookInput,
|
||||
UpdateNotebookInput,
|
||||
AddCellInput,
|
||||
UpdateCellInput
|
||||
} from '@shared/index'
|
||||
|
||||
// AI Types
|
||||
|
|
@ -423,9 +430,7 @@ interface DataPeekApi {
|
|||
onEvent: (callback: (event: PgNotificationEvent) => void) => () => void
|
||||
onStatus: (callback: (status: PgNotificationConnectionStatus) => void) => () => void
|
||||
reconnect: (connectionId: string) => Promise<IpcResponse<void>>
|
||||
getStatus: (
|
||||
connectionId: string
|
||||
) => Promise<IpcResponse<PgNotificationConnectionStatus | null>>
|
||||
getStatus: (connectionId: string) => Promise<IpcResponse<PgNotificationConnectionStatus | null>>
|
||||
getAllStatuses: () => Promise<IpcResponse<PgNotificationConnectionStatus[]>>
|
||||
}
|
||||
health: {
|
||||
|
|
@ -455,6 +460,18 @@ interface DataPeekApi {
|
|||
cancelImport: () => Promise<IpcResponse<void>>
|
||||
onImportProgress: (callback: (progress: PgImportProgress) => void) => () => void
|
||||
}
|
||||
notebooks: {
|
||||
list: () => Promise<IpcResponse<Notebook[]>>
|
||||
get: (id: string) => Promise<IpcResponse<NotebookWithCells>>
|
||||
create: (input: CreateNotebookInput) => Promise<IpcResponse<Notebook>>
|
||||
update: (id: string, updates: UpdateNotebookInput) => Promise<IpcResponse<Notebook>>
|
||||
delete: (id: string) => Promise<IpcResponse<void>>
|
||||
duplicate: (id: string, connectionId: string) => Promise<IpcResponse<Notebook>>
|
||||
addCell: (notebookId: string, input: AddCellInput) => Promise<IpcResponse<NotebookCell>>
|
||||
updateCell: (cellId: string, updates: UpdateCellInput) => Promise<IpcResponse<NotebookCell>>
|
||||
deleteCell: (cellId: string) => Promise<IpcResponse<void>>
|
||||
reorderCells: (notebookId: string, cellIds: string[]) => Promise<IpcResponse<void>>
|
||||
}
|
||||
files: {
|
||||
openFilePicker: () => Promise<string | null>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,7 +63,14 @@ import type {
|
|||
PgExportResult,
|
||||
PgImportOptions,
|
||||
PgImportProgress,
|
||||
PgImportResult
|
||||
PgImportResult,
|
||||
Notebook,
|
||||
NotebookWithCells,
|
||||
NotebookCell,
|
||||
CreateNotebookInput,
|
||||
UpdateNotebookInput,
|
||||
AddCellInput,
|
||||
UpdateCellInput
|
||||
} from '@shared/index'
|
||||
|
||||
// Re-export AI types for renderer consumers
|
||||
|
|
@ -310,6 +317,27 @@ const api = {
|
|||
ipcRenderer.invoke('snippets:update', { id, updates }),
|
||||
delete: (id: string): Promise<IpcResponse<void>> => ipcRenderer.invoke('snippets:delete', id)
|
||||
},
|
||||
// Notebooks management
|
||||
notebooks: {
|
||||
list: (): Promise<IpcResponse<Notebook[]>> => ipcRenderer.invoke('notebooks:list'),
|
||||
get: (id: string): Promise<IpcResponse<NotebookWithCells>> =>
|
||||
ipcRenderer.invoke('notebooks:get', id),
|
||||
create: (input: CreateNotebookInput): Promise<IpcResponse<Notebook>> =>
|
||||
ipcRenderer.invoke('notebooks:create', input),
|
||||
update: (id: string, updates: UpdateNotebookInput): Promise<IpcResponse<Notebook>> =>
|
||||
ipcRenderer.invoke('notebooks:update', { id, updates }),
|
||||
delete: (id: string): Promise<IpcResponse<void>> => ipcRenderer.invoke('notebooks:delete', id),
|
||||
duplicate: (id: string, connectionId: string): Promise<IpcResponse<Notebook>> =>
|
||||
ipcRenderer.invoke('notebooks:duplicate', { id, connectionId }),
|
||||
addCell: (notebookId: string, input: AddCellInput): Promise<IpcResponse<NotebookCell>> =>
|
||||
ipcRenderer.invoke('notebooks:add-cell', { notebookId, input }),
|
||||
updateCell: (cellId: string, updates: UpdateCellInput): Promise<IpcResponse<NotebookCell>> =>
|
||||
ipcRenderer.invoke('notebooks:update-cell', { cellId, updates }),
|
||||
deleteCell: (cellId: string): Promise<IpcResponse<void>> =>
|
||||
ipcRenderer.invoke('notebooks:delete-cell', cellId),
|
||||
reorderCells: (notebookId: string, cellIds: string[]): Promise<IpcResponse<void>> =>
|
||||
ipcRenderer.invoke('notebooks:reorder-cells', { notebookId, cellIds })
|
||||
},
|
||||
// Scheduled queries management
|
||||
scheduledQueries: {
|
||||
list: (): Promise<IpcResponse<ScheduledQuery[]>> =>
|
||||
|
|
@ -542,11 +570,8 @@ const api = {
|
|||
ipcRenderer.on('pg-notify:event', handler)
|
||||
return () => ipcRenderer.removeListener('pg-notify:event', handler)
|
||||
},
|
||||
onStatus: (
|
||||
callback: (status: PgNotificationConnectionStatus) => void
|
||||
): (() => void) => {
|
||||
const handler = (_: unknown, status: PgNotificationConnectionStatus): void =>
|
||||
callback(status)
|
||||
onStatus: (callback: (status: PgNotificationConnectionStatus) => void): (() => void) => {
|
||||
const handler = (_: unknown, status: PgNotificationConnectionStatus): void => callback(status)
|
||||
ipcRenderer.on('pg-notify:status', handler)
|
||||
return () => ipcRenderer.removeListener('pg-notify:status', handler)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { SchemaExplorer } from '@/components/schema-explorer'
|
|||
import { SidebarOmnibar } from '@/components/sidebar-omnibar'
|
||||
import { SidebarQuickQuery } from '@/components/sidebar-quick-query'
|
||||
import { Snippets } from '@/components/snippets'
|
||||
import { NotebookSidebar } from '@/components/notebook-sidebar'
|
||||
import { FunAnalytics } from '@/components/fun-analytics'
|
||||
|
||||
import { useConnectionStore, useTabStore } from '@/stores'
|
||||
|
|
@ -74,6 +75,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||
<QueryHistory />
|
||||
<SavedQueries />
|
||||
<Snippets />
|
||||
<NotebookSidebar />
|
||||
|
||||
<SidebarSeparator className="mx-3" />
|
||||
|
||||
|
|
|
|||
440
apps/desktop/src/renderer/src/components/notebook-cell.tsx
Normal file
440
apps/desktop/src/renderer/src/components/notebook-cell.tsx
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
import { useState, useCallback, useRef, useEffect, memo } from 'react'
|
||||
import { Play, MoreHorizontal, GripVertical, Check, X, Pin, PinOff } from 'lucide-react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
cn
|
||||
} from '@data-peek/ui'
|
||||
import type { NotebookCell as CellType, PinnedResult } from '@shared/index'
|
||||
import { useNotebookStore } from '@/stores/notebook-store'
|
||||
import { useConnectionStore } from '@/stores/connection-store'
|
||||
|
||||
interface NotebookCellProps {
|
||||
cell: CellType
|
||||
connectionId: string
|
||||
isFocused: boolean
|
||||
onFocus: () => void
|
||||
onRunAndAdvance: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
interface QueryResult {
|
||||
fields: { name: string }[]
|
||||
rows: unknown[][]
|
||||
}
|
||||
|
||||
const MAX_DISPLAY_ROWS = 100
|
||||
|
||||
function ResultTable({ result }: { result: QueryResult }) {
|
||||
const columns = result.fields.map((f) => f.name)
|
||||
const rows = result.rows.slice(0, MAX_DISPLAY_ROWS)
|
||||
const isTruncated = result.rows.length > MAX_DISPLAY_ROWS
|
||||
|
||||
if (columns.length === 0) {
|
||||
return <p className="text-xs text-muted-foreground py-2">Query executed successfully.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-border/50">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col}
|
||||
className="text-left px-2 py-1.5 font-medium text-muted-foreground whitespace-nowrap"
|
||||
>
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, ri) => (
|
||||
<tr key={ri} className="border-b border-border/30 hover:bg-muted/20">
|
||||
{(row as unknown[]).map((cell, ci) => (
|
||||
<td key={ci} className="px-2 py-1 font-mono whitespace-nowrap max-w-[300px] truncate">
|
||||
{cell === null || cell === undefined ? (
|
||||
<span className="italic text-muted-foreground/50">null</span>
|
||||
) : (
|
||||
String(cell)
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{isTruncated && (
|
||||
<p className="text-[10px] text-muted-foreground px-2 py-1.5 border-t border-border/30">
|
||||
Showing {MAX_DISPLAY_ROWS} of {result.rows.length} rows
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status: 'idle' | 'running' | 'success' | 'error' }) {
|
||||
if (status === 'idle') return null
|
||||
if (status === 'running')
|
||||
return <span className="size-2 rounded-full bg-primary animate-pulse shrink-0" />
|
||||
if (status === 'success')
|
||||
return <Check className="size-3 text-emerald-500 shrink-0" />
|
||||
return <X className="size-3 text-destructive shrink-0" />
|
||||
}
|
||||
|
||||
export const NotebookCell = memo(function NotebookCell({
|
||||
cell,
|
||||
connectionId,
|
||||
isFocused,
|
||||
onFocus,
|
||||
onRunAndAdvance,
|
||||
onDelete
|
||||
}: NotebookCellProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [status, setStatus] = useState<'idle' | 'running' | 'success' | 'error'>('idle')
|
||||
const [liveResult, setLiveResult] = useState<QueryResult | null>(null)
|
||||
const [liveError, setLiveError] = useState<string | null>(null)
|
||||
const [durationMs, setDurationMs] = useState<number | null>(null)
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const updateCellContent = useNotebookStore((s) => s.updateCellContent)
|
||||
const flushCellContent = useNotebookStore((s) => s.flushCellContent)
|
||||
const pinResult = useNotebookStore((s) => s.pinResult)
|
||||
const unpinResult = useNotebookStore((s) => s.unpinResult)
|
||||
const connections = useConnectionStore((s) => s.connections)
|
||||
|
||||
const activeConnection = connections.find((c) => c.id === connectionId) ?? null
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && isFocused && textareaRef.current) {
|
||||
textareaRef.current.focus()
|
||||
}
|
||||
}, [isEditing, isFocused])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFocused) {
|
||||
setIsEditing(false)
|
||||
}
|
||||
}, [isFocused])
|
||||
|
||||
const handleContentChange = useCallback(
|
||||
(value: string) => {
|
||||
updateCellContent(cell.id, value)
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => {
|
||||
flushCellContent(cell.id, value)
|
||||
}, 500)
|
||||
},
|
||||
[cell.id, updateCellContent, flushCellContent]
|
||||
)
|
||||
|
||||
const runQuery = useCallback(async () => {
|
||||
if (!activeConnection || cell.type !== 'sql' || !cell.content.trim()) return
|
||||
|
||||
setStatus('running')
|
||||
setLiveError(null)
|
||||
const start = Date.now()
|
||||
|
||||
try {
|
||||
const result = await window.api.db.query(activeConnection, cell.content)
|
||||
const elapsed = Date.now() - start
|
||||
setDurationMs(elapsed)
|
||||
|
||||
if (result.success && result.data) {
|
||||
setLiveResult(result.data as QueryResult)
|
||||
setStatus('success')
|
||||
} else {
|
||||
setLiveError(result.error ?? 'Query failed')
|
||||
setStatus('error')
|
||||
}
|
||||
} catch (err) {
|
||||
setDurationMs(Date.now() - start)
|
||||
setLiveError(err instanceof Error ? err.message : 'Unknown error')
|
||||
setStatus('error')
|
||||
}
|
||||
}, [activeConnection, cell.content, cell.type])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setIsEditing(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && e.shiftKey) {
|
||||
e.preventDefault()
|
||||
runQuery().then(() => onRunAndAdvance())
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
runQuery()
|
||||
return
|
||||
}
|
||||
},
|
||||
[runQuery, onRunAndAdvance]
|
||||
)
|
||||
|
||||
const handlePinResult = useCallback(() => {
|
||||
if (!liveResult) return
|
||||
const pinned: PinnedResult = {
|
||||
columns: liveResult.fields.map((f) => f.name),
|
||||
rows: liveResult.rows,
|
||||
rowCount: liveResult.rows.length,
|
||||
executedAt: Date.now(),
|
||||
durationMs: durationMs ?? 0,
|
||||
error: null
|
||||
}
|
||||
pinResult(cell.id, pinned)
|
||||
}, [cell.id, liveResult, durationMs, pinResult])
|
||||
|
||||
const handleUnpinResult = useCallback(() => {
|
||||
unpinResult(cell.id)
|
||||
}, [cell.id, unpinResult])
|
||||
|
||||
const pinnedResult = cell.pinnedResult
|
||||
const hasPinnedResult = pinnedResult !== null && pinnedResult !== undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative rounded-lg border transition-all duration-150',
|
||||
'border-border/50 bg-card',
|
||||
isFocused && 'border-primary/30 shadow-[0_0_0_1px_oklch(0.55_0.15_250/0.3)]'
|
||||
)}
|
||||
onClick={onFocus}
|
||||
>
|
||||
{status === 'running' && (
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5 rounded-t-lg overflow-hidden">
|
||||
<div className="h-full bg-primary animate-[shimmer_1.5s_ease-in-out_infinite] w-1/3" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 border-b border-border/30">
|
||||
<GripVertical
|
||||
className={cn(
|
||||
'size-3.5 text-muted-foreground/40 shrink-0 cursor-grab',
|
||||
'opacity-0 group-hover:opacity-100 transition-opacity'
|
||||
)}
|
||||
/>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-medium px-1.5 py-0.5 rounded font-mono uppercase tracking-wide',
|
||||
cell.type === 'sql'
|
||||
? 'text-primary bg-primary/10'
|
||||
: 'text-muted-foreground bg-muted/50'
|
||||
)}
|
||||
>
|
||||
{cell.type === 'sql' ? 'SQL' : 'MD'}
|
||||
</span>
|
||||
|
||||
<StatusIcon status={status} />
|
||||
|
||||
{durationMs !== null && status !== 'running' && (
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
{durationMs < 1000 ? `${durationMs}ms` : `${(durationMs / 1000).toFixed(2)}s`}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{liveResult && status === 'success' && (
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
{liveResult.rows.length} row{liveResult.rows.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{cell.type === 'sql' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
disabled={status === 'running' || !activeConnection}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
runQuery()
|
||||
}}
|
||||
>
|
||||
<Play className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
{cell.type === 'sql' && liveResult && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handlePinResult()
|
||||
}}
|
||||
>
|
||||
<Pin className="size-3.5 mr-2" />
|
||||
Pin result
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{cell.type === 'sql' && hasPinnedResult && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleUnpinResult()
|
||||
}}
|
||||
>
|
||||
<PinOff className="size-3.5 mr-2" />
|
||||
Unpin result
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{cell.type === 'sql' && (liveResult || hasPinnedResult) && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
>
|
||||
<X className="size-3.5 mr-2" />
|
||||
Delete cell
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="p-3">
|
||||
{cell.type === 'sql' ? (
|
||||
isEditing && isFocused ? (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={cell.content}
|
||||
onChange={(e) => handleContentChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="SELECT * FROM ..."
|
||||
className={cn(
|
||||
'w-full min-h-[80px] resize-y font-mono text-sm bg-transparent outline-none',
|
||||
'placeholder:text-muted-foreground/40 text-foreground'
|
||||
)}
|
||||
rows={Math.max(3, cell.content.split('\n').length)}
|
||||
/>
|
||||
) : (
|
||||
<pre
|
||||
className={cn(
|
||||
'font-mono text-sm text-foreground whitespace-pre-wrap break-all cursor-text min-h-[1.5rem]',
|
||||
!cell.content && 'text-muted-foreground/40 italic'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onFocus()
|
||||
setIsEditing(true)
|
||||
}}
|
||||
>
|
||||
{cell.content || 'Click to edit SQL…'}
|
||||
</pre>
|
||||
)
|
||||
) : isEditing && isFocused ? (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={cell.content}
|
||||
onChange={(e) => handleContentChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setIsEditing(false)
|
||||
}
|
||||
}}
|
||||
placeholder="Write markdown here…"
|
||||
className={cn(
|
||||
'w-full min-h-[80px] resize-y text-sm bg-transparent outline-none',
|
||||
'placeholder:text-muted-foreground/40 text-foreground'
|
||||
)}
|
||||
rows={Math.max(3, cell.content.split('\n').length)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'prose prose-sm dark:prose-invert max-w-none cursor-text min-h-[1.5rem]',
|
||||
!cell.content && 'text-muted-foreground/40 italic text-sm'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onFocus()
|
||||
setIsEditing(true)
|
||||
}}
|
||||
>
|
||||
{cell.content ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{cell.content}</ReactMarkdown>
|
||||
) : (
|
||||
'Click to edit markdown…'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{cell.type === 'sql' && (
|
||||
<div className="border-t border-border/30">
|
||||
{liveError && (
|
||||
<div className="px-3 py-2">
|
||||
<p className="text-xs text-destructive font-mono">{liveError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!liveError && liveResult && (
|
||||
<div className="px-1 py-1">
|
||||
<ResultTable result={liveResult} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!liveError && !liveResult && hasPinnedResult && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 border-b border-border/20">
|
||||
<Pin className="size-3 text-muted-foreground/60" />
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
Pinned — ran {new Date(pinnedResult.executedAt).toLocaleString()}
|
||||
{pinnedResult.durationMs
|
||||
? ` · ${pinnedResult.durationMs < 1000 ? `${pinnedResult.durationMs}ms` : `${(pinnedResult.durationMs / 1000).toFixed(2)}s`}`
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-1 py-1">
|
||||
<ResultTable
|
||||
result={{
|
||||
fields: pinnedResult.columns.map((c) => ({ name: c })),
|
||||
rows: pinnedResult.rows as unknown[][]
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!liveError && !liveResult && !hasPinnedResult && status === 'idle' && (
|
||||
<div className="px-3 py-2">
|
||||
<p className="text-[10px] text-muted-foreground/50">
|
||||
Not yet executed — ⇧⏎ to run
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
294
apps/desktop/src/renderer/src/components/notebook-editor.tsx
Normal file
294
apps/desktop/src/renderer/src/components/notebook-editor.tsx
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Button, cn } from '@data-peek/ui'
|
||||
import { useNotebookStore } from '@/stores/notebook-store'
|
||||
import { useConnectionStore } from '@/stores/connection-store'
|
||||
import { NotebookCell } from './notebook-cell'
|
||||
import type { NotebookTab } from '@/stores/tab-store'
|
||||
|
||||
interface NotebookEditorProps {
|
||||
tab: NotebookTab
|
||||
}
|
||||
|
||||
function formatTimeAgo(timestamp: number): string {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000)
|
||||
if (seconds < 5) return 'just now'
|
||||
if (seconds < 60) return `${seconds}s ago`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
return `${hours}h ago`
|
||||
}
|
||||
|
||||
export function NotebookEditor({ tab }: NotebookEditorProps) {
|
||||
const [focusedCellIndex, setFocusedCellIndex] = useState<number>(0)
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false)
|
||||
const [titleValue, setTitleValue] = useState('')
|
||||
const titleInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const activeNotebook = useNotebookStore((s) => s.activeNotebook)
|
||||
const isLoading = useNotebookStore((s) => s.isLoading)
|
||||
const lastSavedAt = useNotebookStore((s) => s.lastSavedAt)
|
||||
const loadNotebook = useNotebookStore((s) => s.loadNotebook)
|
||||
const addCell = useNotebookStore((s) => s.addCell)
|
||||
const deleteCell = useNotebookStore((s) => s.deleteCell)
|
||||
const updateNotebook = useNotebookStore((s) => s.updateNotebook)
|
||||
|
||||
const connections = useConnectionStore((s) => s.connections)
|
||||
const connectionId = tab.connectionId
|
||||
const connection = connections.find((c) => c.id === connectionId) ?? null
|
||||
|
||||
useEffect(() => {
|
||||
loadNotebook(tab.notebookId)
|
||||
}, [tab.notebookId, loadNotebook])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeNotebook) {
|
||||
setTitleValue(activeNotebook.title)
|
||||
}
|
||||
}, [activeNotebook?.title])
|
||||
|
||||
const cells = activeNotebook?.cells ?? []
|
||||
|
||||
const handleAddCell = useCallback(
|
||||
(type: 'sql' | 'markdown', insertAfterIndex?: number) => {
|
||||
if (!activeNotebook) return
|
||||
const order =
|
||||
insertAfterIndex !== undefined && cells[insertAfterIndex]
|
||||
? cells[insertAfterIndex].order + 0.5
|
||||
: cells.length
|
||||
addCell(activeNotebook.id, { type, content: '', order })
|
||||
setFocusedCellIndex(insertAfterIndex !== undefined ? insertAfterIndex + 1 : cells.length)
|
||||
},
|
||||
[activeNotebook, cells, addCell]
|
||||
)
|
||||
|
||||
const handleDeleteCell = useCallback(
|
||||
(cellId: string, index: number) => {
|
||||
deleteCell(cellId)
|
||||
setFocusedCellIndex((prev) => Math.max(0, prev > index ? prev - 1 : prev))
|
||||
},
|
||||
[deleteCell]
|
||||
)
|
||||
|
||||
const handleTitleEdit = useCallback(() => {
|
||||
if (activeNotebook) {
|
||||
setTitleValue(activeNotebook.title)
|
||||
setIsEditingTitle(true)
|
||||
}
|
||||
}, [activeNotebook])
|
||||
|
||||
const handleTitleSave = useCallback(() => {
|
||||
if (activeNotebook && titleValue.trim()) {
|
||||
updateNotebook(activeNotebook.id, { title: titleValue.trim() })
|
||||
}
|
||||
setIsEditingTitle(false)
|
||||
}, [activeNotebook, titleValue, updateNotebook])
|
||||
|
||||
const handleTitleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleTitleSave()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setIsEditingTitle(false)
|
||||
}
|
||||
},
|
||||
[handleTitleSave]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditingTitle && titleInputRef.current) {
|
||||
titleInputRef.current.focus()
|
||||
titleInputRef.current.select()
|
||||
}
|
||||
}, [isEditingTitle])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const mod = e.metaKey || e.ctrlKey
|
||||
if (!mod) return
|
||||
|
||||
if (e.key === 'j') {
|
||||
e.preventDefault()
|
||||
setFocusedCellIndex((prev) => Math.min(prev + 1, cells.length - 1))
|
||||
} else if (e.key === 'k') {
|
||||
e.preventDefault()
|
||||
setFocusedCellIndex((prev) => Math.max(prev - 1, 0))
|
||||
} else if (e.shiftKey && e.key === 'D') {
|
||||
e.preventDefault()
|
||||
const cell = cells[focusedCellIndex]
|
||||
if (cell) {
|
||||
handleDeleteCell(cell.id, focusedCellIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [cells, focusedCellIndex, handleDeleteCell])
|
||||
|
||||
if (isLoading && !activeNotebook) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">Loading notebook…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!activeNotebook) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">Notebook not found.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden relative">
|
||||
<div className="sticky top-0 z-10 flex items-center gap-3 px-4 py-2 border-b border-border/50 bg-background/95 backdrop-blur-sm">
|
||||
{isEditingTitle ? (
|
||||
<input
|
||||
ref={titleInputRef}
|
||||
value={titleValue}
|
||||
onChange={(e) => setTitleValue(e.target.value)}
|
||||
onBlur={handleTitleSave}
|
||||
onKeyDown={handleTitleKeyDown}
|
||||
className="font-medium text-sm bg-transparent outline-none border-b border-primary/50 min-w-[120px] max-w-[300px]"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleTitleEdit}
|
||||
className="font-medium text-sm hover:text-foreground/80 transition-colors cursor-text"
|
||||
>
|
||||
{activeNotebook.title}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{connection && (
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground border border-border/50">
|
||||
{connection.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{lastSavedAt && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
Saved {formatTimeAgo(lastSavedAt)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
onClick={() => handleAddCell('sql')}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
SQL
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
onClick={() => handleAddCell('markdown')}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
Note
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4 pb-16">
|
||||
{cells.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<p className="text-sm text-muted-foreground">Empty notebook. Add your first cell.</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5 text-xs"
|
||||
onClick={() => handleAddCell('sql')}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
SQL cell
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5 text-xs"
|
||||
onClick={() => handleAddCell('markdown')}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
Note cell
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 max-w-4xl mx-auto">
|
||||
<InsertPoint onInsert={(type) => handleAddCell(type, -1)} />
|
||||
{cells.map((cell, index) => (
|
||||
<div key={cell.id} className="flex flex-col">
|
||||
<NotebookCell
|
||||
cell={cell}
|
||||
connectionId={connectionId ?? ''}
|
||||
isFocused={focusedCellIndex === index}
|
||||
onFocus={() => setFocusedCellIndex(index)}
|
||||
onRunAndAdvance={() => setFocusedCellIndex(Math.min(index + 1, cells.length - 1))}
|
||||
onDelete={() => handleDeleteCell(cell.id, index)}
|
||||
/>
|
||||
<InsertPoint onInsert={(type) => handleAddCell(type, index)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="fixed bottom-0 left-0 right-0 flex items-center justify-center gap-4 px-4 py-1.5 border-t border-border/30 bg-background/80 backdrop-blur-sm text-[10px] text-muted-foreground/60 pointer-events-none z-20">
|
||||
<span>⇧⏎ Run & advance</span>
|
||||
<span>⌘⏎ Run cell</span>
|
||||
<span>⌘J/⌘K Navigate</span>
|
||||
<span>Esc Exit editor</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface InsertPointProps {
|
||||
onInsert: (type: 'sql' | 'markdown') => void
|
||||
}
|
||||
|
||||
function InsertPoint({ onInsert }: InsertPointProps) {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 py-0.5 transition-all duration-150',
|
||||
isHovered ? 'opacity-100' : 'opacity-0 hover:opacity-100'
|
||||
)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="flex-1 h-px bg-border/40" />
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => onInsert('sql')}
|
||||
className="text-[10px] text-muted-foreground/60 hover:text-foreground px-1.5 py-0.5 rounded hover:bg-muted/50 transition-colors font-mono"
|
||||
>
|
||||
+ SQL
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onInsert('markdown')}
|
||||
className="text-[10px] text-muted-foreground/60 hover:text-foreground px-1.5 py-0.5 rounded hover:bg-muted/50 transition-colors font-mono"
|
||||
>
|
||||
+ Note
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 h-px bg-border/40" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
102
apps/desktop/src/renderer/src/components/notebook-export.ts
Normal file
102
apps/desktop/src/renderer/src/components/notebook-export.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import type { NotebookWithCells, PinnedResult } from '@shared/index'
|
||||
|
||||
export interface DpnbFile {
|
||||
version: 1
|
||||
title: string
|
||||
folder: string | null
|
||||
cells: DpnbCell[]
|
||||
}
|
||||
|
||||
export interface DpnbCell {
|
||||
type: 'sql' | 'markdown'
|
||||
content: string
|
||||
pinnedResult?: PinnedResult
|
||||
}
|
||||
|
||||
const MAX_TABLE_ROWS = 50
|
||||
|
||||
export function renderResultAsMarkdownTable(result: PinnedResult): string {
|
||||
const { columns, rows } = result
|
||||
const displayRows = rows.slice(0, MAX_TABLE_ROWS)
|
||||
|
||||
const header = `| ${columns.join(' | ')} |`
|
||||
const separator = `| ${columns.map(() => '---').join(' | ')} |`
|
||||
const dataRows = displayRows.map((row) => {
|
||||
const cells = columns.map((_, i) => {
|
||||
const val = row[i]
|
||||
return val === null ? '*null*' : String(val).replace(/\|/g, '\\|')
|
||||
})
|
||||
return `| ${cells.join(' | ')} |`
|
||||
})
|
||||
|
||||
const lines = [header, separator, ...dataRows]
|
||||
|
||||
if (rows.length > MAX_TABLE_ROWS) {
|
||||
lines.push('')
|
||||
lines.push(`*${rows.length - MAX_TABLE_ROWS} more rows not shown (${rows.length} total)*`)
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function exportAsDpnb(notebook: NotebookWithCells): string {
|
||||
const file: DpnbFile = {
|
||||
version: 1,
|
||||
title: notebook.title,
|
||||
folder: notebook.folder,
|
||||
cells: notebook.cells.map((cell) => {
|
||||
const dpnbCell: DpnbCell = {
|
||||
type: cell.type,
|
||||
content: cell.content
|
||||
}
|
||||
if (cell.pinnedResult !== null) {
|
||||
dpnbCell.pinnedResult = cell.pinnedResult
|
||||
}
|
||||
return dpnbCell
|
||||
})
|
||||
}
|
||||
|
||||
return JSON.stringify(file, null, 2)
|
||||
}
|
||||
|
||||
export function exportAsMarkdown(notebook: NotebookWithCells): string {
|
||||
const lines: string[] = [`# ${notebook.title}`]
|
||||
|
||||
for (const cell of notebook.cells) {
|
||||
lines.push('')
|
||||
|
||||
if (cell.type === 'markdown') {
|
||||
lines.push(cell.content)
|
||||
} else {
|
||||
lines.push('```sql')
|
||||
lines.push(cell.content)
|
||||
lines.push('```')
|
||||
}
|
||||
|
||||
if (cell.pinnedResult !== null) {
|
||||
lines.push('')
|
||||
lines.push(renderResultAsMarkdownTable(cell.pinnedResult))
|
||||
lines.push('')
|
||||
const date = new Date(cell.pinnedResult.executedAt).toISOString()
|
||||
lines.push(
|
||||
`*Last run: ${date} (${cell.pinnedResult.durationMs}ms, ${cell.pinnedResult.rowCount} rows)*`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function parseDpnb(json: string): DpnbFile | null {
|
||||
try {
|
||||
const parsed = JSON.parse(json)
|
||||
|
||||
if (parsed.version !== 1) return null
|
||||
if (!Array.isArray(parsed.cells)) return null
|
||||
if (typeof parsed.title !== 'string') return null
|
||||
|
||||
return parsed as DpnbFile
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
195
apps/desktop/src/renderer/src/components/notebook-sidebar.tsx
Normal file
195
apps/desktop/src/renderer/src/components/notebook-sidebar.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { BookOpen, ChevronRight, Plus, MoreHorizontal, Trash2, FolderOpen } from 'lucide-react'
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar
|
||||
} from '@data-peek/ui'
|
||||
|
||||
import { useNotebookStore } from '@/stores/notebook-store'
|
||||
import { useConnectionStore, useTabStore, notify } from '@/stores'
|
||||
import type { Notebook } from '@shared/index'
|
||||
|
||||
export function NotebookSidebar() {
|
||||
const { isMobile } = useSidebar()
|
||||
const notebooks = useNotebookStore((s) => s.notebooks)
|
||||
const isInitialized = useNotebookStore((s) => s.isInitialized)
|
||||
const initialize = useNotebookStore((s) => s.initialize)
|
||||
const createNotebook = useNotebookStore((s) => s.createNotebook)
|
||||
const deleteNotebook = useNotebookStore((s) => s.deleteNotebook)
|
||||
|
||||
const activeConnectionId = useConnectionStore((s) => s.activeConnectionId)
|
||||
const createNotebookTab = useTabStore((s) => s.createNotebookTab)
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialized) {
|
||||
initialize()
|
||||
}
|
||||
}, [isInitialized, initialize])
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!activeConnectionId) return
|
||||
const nb = await createNotebook({
|
||||
title: 'Untitled Notebook',
|
||||
connectionId: activeConnectionId
|
||||
})
|
||||
if (nb) {
|
||||
createNotebookTab(nb.connectionId, nb.id, nb.title)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpen = (nb: Notebook) => {
|
||||
createNotebookTab(nb.connectionId, nb.id, nb.title)
|
||||
}
|
||||
|
||||
const handleDelete = async (nb: Notebook) => {
|
||||
await deleteNotebook(nb.id)
|
||||
notify.success('Notebook deleted', `"${nb.title}" was removed.`)
|
||||
}
|
||||
|
||||
const toggleFolder = (folder: string) => {
|
||||
setExpandedFolders((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(folder)) {
|
||||
next.delete(folder)
|
||||
} else {
|
||||
next.add(folder)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const ungrouped = notebooks.filter((nb) => !nb.folder)
|
||||
const folderMap = new Map<string, Notebook[]>()
|
||||
for (const nb of notebooks) {
|
||||
if (nb.folder) {
|
||||
const existing = folderMap.get(nb.folder) ?? []
|
||||
existing.push(nb)
|
||||
folderMap.set(nb.folder, existing)
|
||||
}
|
||||
}
|
||||
const folders = Array.from(folderMap.keys()).sort()
|
||||
|
||||
const renderNotebookItem = (nb: Notebook) => (
|
||||
<SidebarMenuItem key={nb.id}>
|
||||
<SidebarMenuButton onClick={() => handleOpen(nb)} className="h-auto py-1.5">
|
||||
<BookOpen className="size-4 shrink-0" />
|
||||
<span className="text-xs truncate">{nb.title}</span>
|
||||
</SidebarMenuButton>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuAction showOnHover>
|
||||
<MoreHorizontal />
|
||||
<span className="sr-only">More</span>
|
||||
</SidebarMenuAction>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-48 rounded-lg"
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
align={isMobile ? 'end' : 'start'}
|
||||
>
|
||||
<DropdownMenuItem onClick={() => handleOpen(nb)}>
|
||||
<BookOpen className="text-muted-foreground" />
|
||||
<span>Open notebook</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-red-400" onClick={() => handleDelete(nb)}>
|
||||
<Trash2 className="text-red-400" />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
|
||||
return (
|
||||
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel className="flex items-center">
|
||||
<CollapsibleTrigger className="flex items-center gap-1 flex-1">
|
||||
<ChevronRight
|
||||
className={`size-4 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
<span>Notebooks</span>
|
||||
</CollapsibleTrigger>
|
||||
<SidebarGroupAction
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCreate()
|
||||
}}
|
||||
title="New notebook"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</SidebarGroupAction>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{notebooks.length === 0 ? (
|
||||
<div className="px-2 py-4 text-xs text-muted-foreground text-center">
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="underline underline-offset-2 hover:text-foreground transition-colors"
|
||||
>
|
||||
Create your first notebook
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{ungrouped.map(renderNotebookItem)}
|
||||
|
||||
{folders.map((folder) => {
|
||||
const folderNotebooks = folderMap.get(folder) ?? []
|
||||
const isFolderExpanded = expandedFolders.has(folder)
|
||||
return (
|
||||
<Collapsible
|
||||
key={folder}
|
||||
open={isFolderExpanded}
|
||||
onOpenChange={() => toggleFolder(folder)}
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton className="h-auto py-1.5">
|
||||
<FolderOpen className="size-4 shrink-0" />
|
||||
<span className="text-xs truncate flex-1">{folder}</span>
|
||||
<ChevronRight
|
||||
className={`size-3 transition-transform ml-auto ${isFolderExpanded ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
</SidebarMenuItem>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenu className="pl-4">
|
||||
{folderNotebooks.map(renderNotebookItem)}
|
||||
</SidebarMenu>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</CollapsibleContent>
|
||||
</SidebarGroup>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,8 +3,10 @@ import { Plus } from 'lucide-react'
|
|||
import { Button } from '@data-peek/ui'
|
||||
import { TabBar } from '@/components/tab-bar'
|
||||
import { TabQueryEditor } from '@/components/tab-query-editor'
|
||||
import { NotebookEditor } from '@/components/notebook-editor'
|
||||
import { useTabStore, useConnectionStore } from '@/stores'
|
||||
import { useHotkeys, type UseHotkeyDefinition, type Hotkey } from '@tanstack/react-hotkeys'
|
||||
import type { NotebookTab } from '@/stores/tab-store'
|
||||
|
||||
export function TabContainer() {
|
||||
const tabs = useTabStore((s) => s.tabs)
|
||||
|
|
@ -86,7 +88,11 @@ export function TabContainer() {
|
|||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<TabBar />
|
||||
{activeTab && <TabQueryEditor key={activeTab.id} tabId={activeTab.id} />}
|
||||
{activeTab && activeTab.type === 'notebook' ? (
|
||||
<NotebookEditor key={activeTab.id} tab={activeTab as NotebookTab} />
|
||||
) : (
|
||||
activeTab && <TabQueryEditor key={activeTab.id} tabId={activeTab.id} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -294,6 +294,7 @@ export function TabQueryEditor({ tabId }: TabQueryEditorProps) {
|
|||
currentTab.type === 'data-generator' ||
|
||||
currentTab.type === 'pg-notifications' ||
|
||||
currentTab.type === 'health-monitor' ||
|
||||
currentTab.type === 'notebook' ||
|
||||
!tabConnection ||
|
||||
currentTab.isExecuting ||
|
||||
!currentTab.query.trim()
|
||||
|
|
@ -443,7 +444,8 @@ export function TabQueryEditor({ tabId }: TabQueryEditorProps) {
|
|||
tab.type === 'table-designer' ||
|
||||
tab.type === 'data-generator' ||
|
||||
tab.type === 'pg-notifications' ||
|
||||
tab.type === 'health-monitor'
|
||||
tab.type === 'health-monitor' ||
|
||||
tab.type === 'notebook'
|
||||
)
|
||||
return
|
||||
if (!tab.isExecuting || !tab.executionId) return
|
||||
|
|
@ -489,6 +491,7 @@ export function TabQueryEditor({ tabId }: TabQueryEditorProps) {
|
|||
tab.type === 'data-generator' ||
|
||||
tab.type === 'pg-notifications' ||
|
||||
tab.type === 'health-monitor' ||
|
||||
tab.type === 'notebook' ||
|
||||
!tab.query.trim()
|
||||
)
|
||||
return
|
||||
|
|
@ -506,6 +509,7 @@ export function TabQueryEditor({ tabId }: TabQueryEditorProps) {
|
|||
tab.type === 'data-generator' ||
|
||||
tab.type === 'pg-notifications' ||
|
||||
tab.type === 'health-monitor' ||
|
||||
tab.type === 'notebook' ||
|
||||
!tabConnection ||
|
||||
tab.isExecuting ||
|
||||
isRunningBenchmark ||
|
||||
|
|
@ -561,6 +565,7 @@ export function TabQueryEditor({ tabId }: TabQueryEditorProps) {
|
|||
tab.type === 'data-generator' ||
|
||||
tab.type === 'pg-notifications' ||
|
||||
tab.type === 'health-monitor' ||
|
||||
tab.type === 'notebook' ||
|
||||
!tabConnection ||
|
||||
isExplaining ||
|
||||
!tab.query.trim()
|
||||
|
|
@ -602,6 +607,7 @@ export function TabQueryEditor({ tabId }: TabQueryEditorProps) {
|
|||
tab.type === 'data-generator' ||
|
||||
tab.type === 'pg-notifications' ||
|
||||
tab.type === 'health-monitor' ||
|
||||
tab.type === 'notebook' ||
|
||||
!tabConnection ||
|
||||
isPerfAnalyzing ||
|
||||
!tab.query.trim()
|
||||
|
|
@ -672,6 +678,7 @@ export function TabQueryEditor({ tabId }: TabQueryEditorProps) {
|
|||
tab.type === 'data-generator' ||
|
||||
tab.type === 'pg-notifications' ||
|
||||
tab.type === 'health-monitor' ||
|
||||
tab.type === 'notebook' ||
|
||||
!tab.result?.columns
|
||||
)
|
||||
return []
|
||||
|
|
@ -722,6 +729,7 @@ export function TabQueryEditor({ tabId }: TabQueryEditorProps) {
|
|||
tab.type === 'data-generator' ||
|
||||
tab.type === 'pg-notifications' ||
|
||||
tab.type === 'health-monitor' ||
|
||||
tab.type === 'notebook' ||
|
||||
!tab.result?.columns ||
|
||||
tab.type !== 'table-preview'
|
||||
)
|
||||
|
|
@ -901,7 +909,8 @@ export function TabQueryEditor({ tabId }: TabQueryEditorProps) {
|
|||
tab.type === 'table-designer' ||
|
||||
tab.type === 'data-generator' ||
|
||||
tab.type === 'pg-notifications' ||
|
||||
tab.type === 'health-monitor'
|
||||
tab.type === 'health-monitor' ||
|
||||
tab.type === 'notebook'
|
||||
)
|
||||
return
|
||||
|
||||
|
|
@ -999,7 +1008,8 @@ export function TabQueryEditor({ tabId }: TabQueryEditorProps) {
|
|||
tab.type === 'table-designer' ||
|
||||
tab.type === 'data-generator' ||
|
||||
tab.type === 'pg-notifications' ||
|
||||
tab.type === 'health-monitor'
|
||||
tab.type === 'health-monitor' ||
|
||||
tab.type === 'notebook'
|
||||
)
|
||||
return ''
|
||||
|
||||
|
|
|
|||
|
|
@ -19,3 +19,4 @@ export * from './pg-notification-store'
|
|||
export * from './health-store'
|
||||
export * from './pg-dump-store'
|
||||
export * from './pokemon-buddy-store'
|
||||
export * from './notebook-store'
|
||||
|
|
|
|||
254
apps/desktop/src/renderer/src/stores/notebook-store.ts
Normal file
254
apps/desktop/src/renderer/src/stores/notebook-store.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import { create } from 'zustand'
|
||||
import type {
|
||||
Notebook,
|
||||
NotebookWithCells,
|
||||
CreateNotebookInput,
|
||||
UpdateNotebookInput,
|
||||
AddCellInput,
|
||||
PinnedResult
|
||||
} from '@shared/index'
|
||||
|
||||
interface NotebookState {
|
||||
notebooks: Notebook[]
|
||||
activeNotebook: NotebookWithCells | null
|
||||
isLoading: boolean
|
||||
isInitialized: boolean
|
||||
lastSavedAt: number | null
|
||||
|
||||
initialize: () => Promise<void>
|
||||
loadNotebook: (id: string) => Promise<void>
|
||||
createNotebook: (input: CreateNotebookInput) => Promise<Notebook | null>
|
||||
updateNotebook: (id: string, updates: UpdateNotebookInput) => Promise<void>
|
||||
deleteNotebook: (id: string) => Promise<void>
|
||||
duplicateNotebook: (id: string, connectionId: string) => Promise<void>
|
||||
addCell: (notebookId: string, input: AddCellInput) => Promise<void>
|
||||
updateCellContent: (cellId: string, content: string) => void
|
||||
flushCellContent: (cellId: string, content: string) => Promise<void>
|
||||
deleteCell: (cellId: string) => Promise<void>
|
||||
reorderCells: (notebookId: string, cellIds: string[]) => Promise<void>
|
||||
pinResult: (cellId: string, result: PinnedResult) => Promise<void>
|
||||
unpinResult: (cellId: string) => Promise<void>
|
||||
}
|
||||
|
||||
export const useNotebookStore = create<NotebookState>((set, get) => ({
|
||||
notebooks: [],
|
||||
activeNotebook: null,
|
||||
isLoading: false,
|
||||
isInitialized: false,
|
||||
lastSavedAt: null,
|
||||
|
||||
initialize: async () => {
|
||||
if (get().isInitialized) return
|
||||
|
||||
set({ isLoading: true })
|
||||
|
||||
try {
|
||||
const result = await window.api.notebooks.list()
|
||||
if (result.success && result.data) {
|
||||
set({ notebooks: result.data, isLoading: false, isInitialized: true })
|
||||
} else {
|
||||
console.error('Failed to load notebooks:', result.error)
|
||||
set({ isLoading: false, isInitialized: true })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize notebooks:', error)
|
||||
set({ isLoading: false, isInitialized: true })
|
||||
}
|
||||
},
|
||||
|
||||
loadNotebook: async (id) => {
|
||||
set({ isLoading: true })
|
||||
|
||||
try {
|
||||
const result = await window.api.notebooks.get(id)
|
||||
if (result.success && result.data) {
|
||||
set({ activeNotebook: result.data, isLoading: false })
|
||||
} else {
|
||||
console.error('Failed to load notebook:', result.error)
|
||||
set({ isLoading: false })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load notebook:', error)
|
||||
set({ isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
createNotebook: async (input) => {
|
||||
try {
|
||||
const result = await window.api.notebooks.create(input)
|
||||
if (result.success && result.data) {
|
||||
set((state) => ({ notebooks: [result.data!, ...state.notebooks] }))
|
||||
return result.data
|
||||
} else {
|
||||
console.error('Failed to create notebook:', result.error)
|
||||
return null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create notebook:', error)
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
updateNotebook: async (id, updates) => {
|
||||
try {
|
||||
const result = await window.api.notebooks.update(id, updates)
|
||||
if (result.success) {
|
||||
set((state) => ({
|
||||
notebooks: state.notebooks.map((n) => (n.id === id ? { ...n, ...updates } : n)),
|
||||
activeNotebook:
|
||||
state.activeNotebook?.id === id
|
||||
? { ...state.activeNotebook, ...updates }
|
||||
: state.activeNotebook
|
||||
}))
|
||||
} else {
|
||||
console.error('Failed to update notebook:', result.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update notebook:', error)
|
||||
}
|
||||
},
|
||||
|
||||
deleteNotebook: async (id) => {
|
||||
try {
|
||||
const result = await window.api.notebooks.delete(id)
|
||||
if (result.success) {
|
||||
set((state) => ({
|
||||
notebooks: state.notebooks.filter((n) => n.id !== id),
|
||||
activeNotebook: state.activeNotebook?.id === id ? null : state.activeNotebook
|
||||
}))
|
||||
} else {
|
||||
console.error('Failed to delete notebook:', result.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete notebook:', error)
|
||||
}
|
||||
},
|
||||
|
||||
duplicateNotebook: async (id, connectionId) => {
|
||||
try {
|
||||
const result = await window.api.notebooks.duplicate(id, connectionId)
|
||||
if (result.success && result.data) {
|
||||
set((state) => ({ notebooks: [result.data!, ...state.notebooks] }))
|
||||
} else {
|
||||
console.error('Failed to duplicate notebook:', result.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to duplicate notebook:', error)
|
||||
}
|
||||
},
|
||||
|
||||
addCell: async (notebookId, input) => {
|
||||
try {
|
||||
const result = await window.api.notebooks.addCell(notebookId, input)
|
||||
if (result.success && result.data) {
|
||||
set((state) => {
|
||||
if (!state.activeNotebook || state.activeNotebook.id !== notebookId) return state
|
||||
const cells = [...state.activeNotebook.cells, result.data!].sort(
|
||||
(a, b) => a.order - b.order
|
||||
)
|
||||
return { activeNotebook: { ...state.activeNotebook, cells } }
|
||||
})
|
||||
} else {
|
||||
console.error('Failed to add cell:', result.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to add cell:', error)
|
||||
}
|
||||
},
|
||||
|
||||
updateCellContent: (cellId, content) => {
|
||||
set((state) => {
|
||||
if (!state.activeNotebook) return state
|
||||
const cells = state.activeNotebook.cells.map((c) => (c.id === cellId ? { ...c, content } : c))
|
||||
return { activeNotebook: { ...state.activeNotebook, cells } }
|
||||
})
|
||||
},
|
||||
|
||||
flushCellContent: async (cellId, content) => {
|
||||
try {
|
||||
const result = await window.api.notebooks.updateCell(cellId, { content })
|
||||
if (result.success) {
|
||||
set({ lastSavedAt: Date.now() })
|
||||
} else {
|
||||
console.error('Failed to flush cell content:', result.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to flush cell content:', error)
|
||||
}
|
||||
},
|
||||
|
||||
deleteCell: async (cellId) => {
|
||||
try {
|
||||
const result = await window.api.notebooks.deleteCell(cellId)
|
||||
if (result.success) {
|
||||
set((state) => {
|
||||
if (!state.activeNotebook) return state
|
||||
const cells = state.activeNotebook.cells.filter((c) => c.id !== cellId)
|
||||
return { activeNotebook: { ...state.activeNotebook, cells } }
|
||||
})
|
||||
} else {
|
||||
console.error('Failed to delete cell:', result.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete cell:', error)
|
||||
}
|
||||
},
|
||||
|
||||
reorderCells: async (notebookId, cellIds) => {
|
||||
try {
|
||||
const result = await window.api.notebooks.reorderCells(notebookId, cellIds)
|
||||
if (result.success) {
|
||||
set((state) => {
|
||||
if (!state.activeNotebook || state.activeNotebook.id !== notebookId) return state
|
||||
const cellMap = new Map(state.activeNotebook.cells.map((c) => [c.id, c]))
|
||||
const cells = cellIds
|
||||
.map((id) => cellMap.get(id))
|
||||
.filter(Boolean) as typeof state.activeNotebook.cells
|
||||
return { activeNotebook: { ...state.activeNotebook, cells } }
|
||||
})
|
||||
} else {
|
||||
console.error('Failed to reorder cells:', result.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to reorder cells:', error)
|
||||
}
|
||||
},
|
||||
|
||||
pinResult: async (cellId, result) => {
|
||||
try {
|
||||
const ipcResult = await window.api.notebooks.updateCell(cellId, { pinnedResult: result })
|
||||
if (ipcResult.success) {
|
||||
set((state) => {
|
||||
if (!state.activeNotebook) return state
|
||||
const cells = state.activeNotebook.cells.map((c) =>
|
||||
c.id === cellId ? { ...c, pinnedResult: result } : c
|
||||
)
|
||||
return { activeNotebook: { ...state.activeNotebook, cells } }
|
||||
})
|
||||
} else {
|
||||
console.error('Failed to pin result:', ipcResult.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to pin result:', error)
|
||||
}
|
||||
},
|
||||
|
||||
unpinResult: async (cellId) => {
|
||||
try {
|
||||
const result = await window.api.notebooks.updateCell(cellId, { pinnedResult: null })
|
||||
if (result.success) {
|
||||
set((state) => {
|
||||
if (!state.activeNotebook) return state
|
||||
const cells = state.activeNotebook.cells.map((c) =>
|
||||
c.id === cellId ? { ...c, pinnedResult: null } : c
|
||||
)
|
||||
return { activeNotebook: { ...state.activeNotebook, cells } }
|
||||
})
|
||||
} else {
|
||||
console.error('Failed to unpin result:', result.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to unpin result:', error)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
|
@ -32,6 +32,7 @@ export type TabType =
|
|||
| 'data-generator'
|
||||
| 'pg-notifications'
|
||||
| 'health-monitor'
|
||||
| 'notebook'
|
||||
|
||||
// Base tab interface
|
||||
interface BaseTab {
|
||||
|
|
@ -109,6 +110,12 @@ export interface HealthMonitorTab extends BaseTab {
|
|||
type: 'health-monitor'
|
||||
}
|
||||
|
||||
// Notebook tab
|
||||
export interface NotebookTab extends BaseTab {
|
||||
type: 'notebook'
|
||||
notebookId: string
|
||||
}
|
||||
|
||||
export type Tab =
|
||||
| QueryTab
|
||||
| TablePreviewTab
|
||||
|
|
@ -117,6 +124,7 @@ export type Tab =
|
|||
| DataGeneratorTab
|
||||
| PgNotificationsTab
|
||||
| HealthMonitorTab
|
||||
| NotebookTab
|
||||
|
||||
// Persisted tab data (minimal for storage)
|
||||
interface PersistedTab {
|
||||
|
|
@ -130,6 +138,7 @@ interface PersistedTab {
|
|||
schemaName?: string
|
||||
tableName?: string
|
||||
mode?: 'create' | 'edit'
|
||||
notebookId?: string
|
||||
}
|
||||
|
||||
interface TabState {
|
||||
|
|
@ -152,6 +161,7 @@ interface TabState {
|
|||
createDataGeneratorTab: (connectionId: string, schemaName: string, tableName?: string) => string
|
||||
createPgNotificationsTab: (connectionId: string) => string
|
||||
createHealthMonitorTab: (connectionId: string) => string
|
||||
createNotebookTab: (connectionId: string, notebookId: string, title: string) => string
|
||||
closeTab: (tabId: string) => void
|
||||
closeAllTabs: () => void
|
||||
closeOtherTabs: (tabId: string) => void
|
||||
|
|
@ -535,6 +545,33 @@ export const useTabStore = create<TabState>()(
|
|||
return id
|
||||
},
|
||||
|
||||
createNotebookTab: (connectionId, notebookId, title) => {
|
||||
const existingTab = get().tabs.find(
|
||||
(t) => t.type === 'notebook' && (t as NotebookTab).notebookId === notebookId
|
||||
)
|
||||
if (existingTab) {
|
||||
set({ activeTabId: existingTab.id })
|
||||
return existingTab.id
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID()
|
||||
const tab: NotebookTab = {
|
||||
id,
|
||||
type: 'notebook',
|
||||
title,
|
||||
isPinned: false,
|
||||
connectionId,
|
||||
createdAt: Date.now(),
|
||||
order: get().tabs.length,
|
||||
notebookId
|
||||
}
|
||||
set((state) => ({
|
||||
tabs: [...state.tabs, tab],
|
||||
activeTabId: id
|
||||
}))
|
||||
return id
|
||||
},
|
||||
|
||||
closeTab: (tabId) => {
|
||||
const tab = get().tabs.find((t) => t.id === tabId)
|
||||
if (!tab || tab.isPinned) return
|
||||
|
|
@ -662,7 +699,8 @@ export const useTabStore = create<TabState>()(
|
|||
t.type === 'table-designer' ||
|
||||
t.type === 'data-generator' ||
|
||||
t.type === 'pg-notifications' ||
|
||||
t.type === 'health-monitor'
|
||||
t.type === 'health-monitor' ||
|
||||
t.type === 'notebook'
|
||||
)
|
||||
return t
|
||||
// Only update executionId if provided, otherwise clear it when not executing
|
||||
|
|
@ -682,7 +720,8 @@ export const useTabStore = create<TabState>()(
|
|||
t.type !== 'table-designer' &&
|
||||
t.type !== 'data-generator' &&
|
||||
t.type !== 'pg-notifications' &&
|
||||
t.type !== 'health-monitor'
|
||||
t.type !== 'health-monitor' &&
|
||||
t.type !== 'notebook'
|
||||
? { ...t, savedQuery: t.query }
|
||||
: t
|
||||
)
|
||||
|
|
@ -814,7 +853,8 @@ export const useTabStore = create<TabState>()(
|
|||
tab.type === 'table-designer' ||
|
||||
tab.type === 'data-generator' ||
|
||||
tab.type === 'pg-notifications' ||
|
||||
tab.type === 'health-monitor'
|
||||
tab.type === 'health-monitor' ||
|
||||
tab.type === 'notebook'
|
||||
)
|
||||
return false
|
||||
return tab.query !== tab.savedQuery
|
||||
|
|
@ -828,7 +868,8 @@ export const useTabStore = create<TabState>()(
|
|||
tab.type === 'table-designer' ||
|
||||
tab.type === 'data-generator' ||
|
||||
tab.type === 'pg-notifications' ||
|
||||
tab.type === 'health-monitor'
|
||||
tab.type === 'health-monitor' ||
|
||||
tab.type === 'notebook'
|
||||
)
|
||||
return []
|
||||
if (!tab.result) return []
|
||||
|
|
@ -844,7 +885,8 @@ export const useTabStore = create<TabState>()(
|
|||
tab.type === 'table-designer' ||
|
||||
tab.type === 'data-generator' ||
|
||||
tab.type === 'pg-notifications' ||
|
||||
tab.type === 'health-monitor'
|
||||
tab.type === 'health-monitor' ||
|
||||
tab.type === 'notebook'
|
||||
)
|
||||
return 0
|
||||
if (!tab.result) return 0
|
||||
|
|
@ -859,7 +901,8 @@ export const useTabStore = create<TabState>()(
|
|||
tab.type === 'table-designer' ||
|
||||
tab.type === 'data-generator' ||
|
||||
tab.type === 'pg-notifications' ||
|
||||
tab.type === 'health-monitor'
|
||||
tab.type === 'health-monitor' ||
|
||||
tab.type === 'notebook'
|
||||
)
|
||||
return undefined
|
||||
if (!tab.multiResult?.statements) return undefined
|
||||
|
|
@ -874,7 +917,8 @@ export const useTabStore = create<TabState>()(
|
|||
tab.type === 'table-designer' ||
|
||||
tab.type === 'data-generator' ||
|
||||
tab.type === 'pg-notifications' ||
|
||||
tab.type === 'health-monitor'
|
||||
tab.type === 'health-monitor' ||
|
||||
tab.type === 'notebook'
|
||||
)
|
||||
return []
|
||||
return tab.multiResult?.statements ?? []
|
||||
|
|
@ -888,7 +932,8 @@ export const useTabStore = create<TabState>()(
|
|||
tab.type === 'table-designer' ||
|
||||
tab.type === 'data-generator' ||
|
||||
tab.type === 'pg-notifications' ||
|
||||
tab.type === 'health-monitor'
|
||||
tab.type === 'health-monitor' ||
|
||||
tab.type === 'notebook'
|
||||
)
|
||||
return []
|
||||
const statement = tab.multiResult?.statements?.[tab.activeResultIndex]
|
||||
|
|
@ -905,7 +950,8 @@ export const useTabStore = create<TabState>()(
|
|||
tab.type === 'table-designer' ||
|
||||
tab.type === 'data-generator' ||
|
||||
tab.type === 'pg-notifications' ||
|
||||
tab.type === 'health-monitor'
|
||||
tab.type === 'health-monitor' ||
|
||||
tab.type === 'notebook'
|
||||
)
|
||||
return 0
|
||||
const statement = tab.multiResult?.statements?.[tab.activeResultIndex]
|
||||
|
|
@ -1009,6 +1055,13 @@ export const useTabStore = create<TabState>()(
|
|||
return base
|
||||
}
|
||||
|
||||
if (t.type === 'notebook') {
|
||||
return {
|
||||
...base,
|
||||
notebookId: t.notebookId
|
||||
}
|
||||
}
|
||||
|
||||
// query or table-preview tabs
|
||||
return {
|
||||
...base,
|
||||
|
|
@ -1075,6 +1128,17 @@ export const useTabStore = create<TabState>()(
|
|||
} as HealthMonitorTab
|
||||
}
|
||||
|
||||
// Notebook tabs
|
||||
if (t.type === 'notebook') {
|
||||
const persisted = t as unknown as PersistedTab
|
||||
return {
|
||||
...t,
|
||||
type: 'notebook' as const,
|
||||
createdAt: Date.now(),
|
||||
notebookId: persisted.notebookId || ''
|
||||
} as NotebookTab
|
||||
}
|
||||
|
||||
const base = {
|
||||
...t,
|
||||
result: null,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
"foreign-key-drilldown",
|
||||
"saved-queries",
|
||||
"snippets",
|
||||
"sql-notebooks",
|
||||
"command-palette",
|
||||
"sidebar-omnibar",
|
||||
"quick-query",
|
||||
|
|
|
|||
90
apps/docs/content/docs/features/sql-notebooks.mdx
Normal file
90
apps/docs/content/docs/features/sql-notebooks.mdx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
---
|
||||
title: SQL Notebooks
|
||||
description: Create Jupyter-style notebooks mixing executable SQL cells with Markdown documentation
|
||||
---
|
||||
|
||||
# SQL Notebooks
|
||||
|
||||
SQL Notebooks let you mix executable SQL cells with Markdown documentation in a single document. The primary use case is team runbooks — documented, repeatable workflows that anyone on the team can open and run.
|
||||
|
||||
## Creating a Notebook
|
||||
|
||||
1. Click **Notebooks** in the sidebar
|
||||
2. Click **New Notebook**
|
||||
3. Give it a name and optionally assign a folder
|
||||
4. Start adding cells
|
||||
|
||||
## Cell Types
|
||||
|
||||
### SQL Cells
|
||||
|
||||
Each SQL cell is an independent query editor. Run the cell to see results inline, directly beneath it. Results stay attached to the cell — no separate results panel.
|
||||
|
||||
### Markdown Cells
|
||||
|
||||
Markdown cells accept standard Markdown syntax. Use them to add context, explain steps, document expected output, or leave notes for teammates. They render in place when not being edited.
|
||||
|
||||
## Running Queries
|
||||
|
||||
| Action | Shortcut |
|
||||
|--------|----------|
|
||||
| Run cell and advance to next | `Shift+Enter` |
|
||||
| Run cell (stay in cell) | `Cmd+Enter` |
|
||||
| Run all cells top to bottom | **Run All** button in the toolbar |
|
||||
|
||||
## Pinning Results
|
||||
|
||||
Click the **pin icon** on a cell's result set to persist those results across sessions. Pinned results reload as-is the next time you open the notebook — useful for preserving a known baseline or sharing a snapshot alongside the query.
|
||||
|
||||
Unpin at any time to clear the stored result and run fresh.
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `Shift+Enter` | Run cell and move to next |
|
||||
| `Cmd+Enter` | Run cell |
|
||||
| `Cmd+J` | Move to next cell |
|
||||
| `Cmd+K` | Move to previous cell |
|
||||
| `Enter` | Enter edit mode |
|
||||
| `Escape` | Exit edit mode |
|
||||
| `Cmd+Shift+D` | Delete current cell |
|
||||
|
||||
## Organizing with Folders
|
||||
|
||||
Notebooks support one level of folder organization. Assign a notebook to a folder when creating it, or rename/move it later via right-click in the sidebar.
|
||||
|
||||
Folders let you group notebooks by project, team, or workflow — for example: `Releases`, `Incident Response`, `Weekly Reports`.
|
||||
|
||||
## Exporting and Sharing
|
||||
|
||||
### .dpnb Format
|
||||
|
||||
Export a notebook as a `.dpnb` file — a JSON-based format that preserves all cells, markdown, and pinned results. Share the file with a teammate and they can import it directly into data-peek.
|
||||
|
||||
To export: open the notebook, click the **Export** button, and choose **Export as .dpnb**.
|
||||
|
||||
To import: drag a `.dpnb` file onto the sidebar, or use **File → Import Notebook**.
|
||||
|
||||
### Markdown Export
|
||||
|
||||
Export as Markdown to produce a readable document that works anywhere — GitHub, Notion, Confluence, or any text editor. SQL cells export as fenced code blocks; results export as Markdown tables.
|
||||
|
||||
To export: click **Export** and choose **Export as Markdown**.
|
||||
|
||||
## Duplicating to Another Connection
|
||||
|
||||
Reuse a runbook against a different environment without modifying the original:
|
||||
|
||||
1. Right-click the notebook in the sidebar
|
||||
2. Select **Duplicate to Connection**
|
||||
3. Choose the target connection (e.g. staging, production)
|
||||
|
||||
A copy of the notebook opens, connected to the target. Pinned results are not carried over — run the cells fresh against the new connection.
|
||||
|
||||
## Tips
|
||||
|
||||
- **Auto-save** runs 500ms after your last edit — no manual save needed.
|
||||
- **Between-cell insertion**: hover between any two cells to reveal an **+** button that inserts a new cell at that position.
|
||||
- Keep Markdown cells short and actionable — one paragraph per cell is easier to scan than a wall of text.
|
||||
- Pin results before sharing a `.dpnb` so recipients see your last run alongside the queries.
|
||||
BIN
apps/video/public/audio/bg-music-alt.mp3
Normal file
BIN
apps/video/public/audio/bg-music-alt.mp3
Normal file
Binary file not shown.
BIN
apps/video/public/audio/bg-music-notebooks.mp3
Normal file
BIN
apps/video/public/audio/bg-music-notebooks.mp3
Normal file
Binary file not shown.
|
|
@ -3,11 +3,31 @@ import { LaunchVideo } from './compositions/LaunchVideo'
|
|||
import { ReleaseVideo } from './compositions/ReleaseVideo'
|
||||
import { ReleaseVideo018 } from './compositions/ReleaseVideo018'
|
||||
import { ReleaseVideo019 } from './compositions/ReleaseVideo019'
|
||||
import { ReleaseVideo020 } from './compositions/ReleaseVideo020'
|
||||
import { NotebookDemo } from './compositions/NotebookDemo'
|
||||
import './global.css'
|
||||
|
||||
export const RemotionRoot = () => {
|
||||
return (
|
||||
<>
|
||||
<Composition
|
||||
id="NotebookDemo"
|
||||
component={NotebookDemo}
|
||||
durationInFrames={1350}
|
||||
fps={30}
|
||||
width={1920}
|
||||
height={1080}
|
||||
defaultProps={{}}
|
||||
/>
|
||||
<Composition
|
||||
id="ReleaseVideo-v0-20-0"
|
||||
component={ReleaseVideo020}
|
||||
durationInFrames={728}
|
||||
fps={30}
|
||||
width={1920}
|
||||
height={1080}
|
||||
defaultProps={{ version: '0.20.0' }}
|
||||
/>
|
||||
<Composition
|
||||
id="ReleaseVideo-v0-19-0"
|
||||
component={ReleaseVideo019}
|
||||
|
|
|
|||
147
apps/video/src/compositions/NotebookDemo/EndScene.tsx
Normal file
147
apps/video/src/compositions/NotebookDemo/EndScene.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
Sequence,
|
||||
} from 'remotion'
|
||||
import { brand } from '../../lib/colors'
|
||||
import { CyanGlow } from '../../components/CyanGlow'
|
||||
import { BookOpen } from 'lucide-react'
|
||||
|
||||
export const EndScene: React.FC = () => {
|
||||
const frame = useCurrentFrame()
|
||||
const { fps } = useVideoConfig()
|
||||
|
||||
const fadeOut = interpolate(
|
||||
frame,
|
||||
[100, 120],
|
||||
[1, 0],
|
||||
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
|
||||
)
|
||||
|
||||
const iconEntrance = spring({ frame, fps, config: { damping: 12, stiffness: 100 } })
|
||||
const iconScale = interpolate(iconEntrance, [0, 1], [0, 1])
|
||||
const iconRotate = interpolate(iconEntrance, [0, 1], [-180, 0])
|
||||
|
||||
const titleEntrance = spring({ frame: frame - 10, fps, config: { damping: 200 } })
|
||||
const titleOpacity = interpolate(titleEntrance, [0, 1], [0, 1])
|
||||
const titleY = interpolate(titleEntrance, [0, 1], [30, 0])
|
||||
|
||||
const versionEntrance = spring({ frame: frame - 25, fps, config: { damping: 200 } })
|
||||
const versionOpacity = interpolate(versionEntrance, [0, 1], [0, 1])
|
||||
const versionY = interpolate(versionEntrance, [0, 1], [20, 0])
|
||||
|
||||
const ctaEntrance = spring({ frame: frame - 45, fps, config: { damping: 200 } })
|
||||
const ctaOpacity = interpolate(ctaEntrance, [0, 1], [0, 1])
|
||||
const ctaY = interpolate(ctaEntrance, [0, 1], [20, 0])
|
||||
|
||||
const taglineEntrance = spring({ frame: frame - 60, fps, config: { damping: 200 } })
|
||||
const taglineOpacity = interpolate(taglineEntrance, [0, 1], [0, 1])
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 24,
|
||||
opacity: fadeOut,
|
||||
}}
|
||||
>
|
||||
<CyanGlow size={600} delay={0} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
transform: `scale(${iconScale}) rotate(${iconRotate}deg)`,
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
<BookOpen size={56} color={brand.accent} strokeWidth={1.5} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
opacity: titleOpacity,
|
||||
transform: `translateY(${titleY}px)`,
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 80,
|
||||
fontWeight: 700,
|
||||
color: brand.textPrimary,
|
||||
letterSpacing: '-0.05em',
|
||||
}}
|
||||
>
|
||||
SQL Notebooks
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
opacity: versionOpacity,
|
||||
transform: `translateY(${versionY}px)`,
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 22,
|
||||
color: brand.textMuted,
|
||||
letterSpacing: '-0.01em',
|
||||
}}
|
||||
>
|
||||
in{' '}
|
||||
<span style={{ color: brand.accent, fontWeight: 600 }}>
|
||||
data-peek v0.20.0
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Sequence from={45} layout="none">
|
||||
<div
|
||||
style={{
|
||||
opacity: ctaOpacity,
|
||||
transform: `translateY(${ctaY}px)`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 16,
|
||||
color: brand.textMuted,
|
||||
}}
|
||||
>
|
||||
Available now at
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 36,
|
||||
fontWeight: 700,
|
||||
color: brand.accent,
|
||||
borderBottom: `2px solid ${brand.accent}60`,
|
||||
paddingBottom: 4,
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
datapeek.dev
|
||||
</div>
|
||||
</div>
|
||||
</Sequence>
|
||||
|
||||
<Sequence from={60} layout="none">
|
||||
<div
|
||||
style={{
|
||||
opacity: taglineOpacity,
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 14,
|
||||
color: brand.textMuted,
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
Fast. Honest. Modern devtool.
|
||||
</div>
|
||||
</Sequence>
|
||||
</AbsoluteFill>
|
||||
)
|
||||
}
|
||||
305
apps/video/src/compositions/NotebookDemo/ExportScene.tsx
Normal file
305
apps/video/src/compositions/NotebookDemo/ExportScene.tsx
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
Sequence,
|
||||
} from 'remotion'
|
||||
import { brand } from '../../lib/colors'
|
||||
import { CyanGlow } from '../../components/CyanGlow'
|
||||
import { FileJson, FileText, ArrowRight } from 'lucide-react'
|
||||
|
||||
type FileCardProps = {
|
||||
icon: React.FC<{ size: number; color: string; strokeWidth: number }>
|
||||
extension: string
|
||||
title: string
|
||||
description: string
|
||||
previewLines: string[]
|
||||
accentColor: string
|
||||
delay: number
|
||||
}
|
||||
|
||||
const FileCard: React.FC<FileCardProps> = ({
|
||||
icon: Icon,
|
||||
extension,
|
||||
title,
|
||||
description,
|
||||
previewLines,
|
||||
accentColor,
|
||||
delay,
|
||||
}) => {
|
||||
const frame = useCurrentFrame()
|
||||
const { fps } = useVideoConfig()
|
||||
|
||||
const entrance = spring({ frame: frame - delay, fps, config: { damping: 200 } })
|
||||
const opacity = interpolate(entrance, [0, 1], [0, 1])
|
||||
const translateY = interpolate(entrance, [0, 1], [40, 0])
|
||||
const scale = interpolate(entrance, [0, 1], [0.9, 1])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
opacity,
|
||||
transform: `translateY(${translateY}px) scale(${scale})`,
|
||||
width: 380,
|
||||
backgroundColor: brand.surface,
|
||||
border: `1px solid ${accentColor}40`,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
boxShadow: `0 0 40px ${accentColor}10`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: `${accentColor}10`,
|
||||
borderBottom: `1px solid ${accentColor}30`,
|
||||
padding: '14px 18px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
backgroundColor: `${accentColor}20`,
|
||||
border: `1px solid ${accentColor}40`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Icon size={20} color={accentColor} strokeWidth={1.5} />
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
color: brand.textPrimary,
|
||||
}}
|
||||
>
|
||||
{extension}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 12,
|
||||
color: accentColor,
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '14px 18px' }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 12,
|
||||
color: brand.textSecondary,
|
||||
marginBottom: 12,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: brand.surfaceElevated,
|
||||
border: `1px solid ${brand.border}`,
|
||||
borderRadius: 6,
|
||||
padding: '10px 12px',
|
||||
}}
|
||||
>
|
||||
{previewLines.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 11,
|
||||
color: i === 0 ? accentColor : brand.textMuted,
|
||||
lineHeight: 1.6,
|
||||
opacity: i === 0 ? 1 : 0.7 - i * 0.1,
|
||||
}}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ExportScene: React.FC = () => {
|
||||
const frame = useCurrentFrame()
|
||||
const { fps } = useVideoConfig()
|
||||
|
||||
const headerEntrance = spring({ frame, fps, config: { damping: 200 } })
|
||||
const headerOpacity = interpolate(headerEntrance, [0, 1], [0, 1])
|
||||
const headerY = interpolate(headerEntrance, [0, 1], [30, 0])
|
||||
|
||||
const arrowEntrance = spring({ frame: frame - 30, fps, config: { damping: 200 } })
|
||||
const arrowOpacity = interpolate(arrowEntrance, [0, 1], [0, 1])
|
||||
|
||||
const notebookEntrance = spring({ frame, fps, config: { damping: 200 } })
|
||||
const notebookOpacity = interpolate(notebookEntrance, [0, 1], [0, 1])
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 48,
|
||||
}}
|
||||
>
|
||||
<CyanGlow size={400} x="20%" y="70%" delay={0} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
opacity: headerOpacity,
|
||||
transform: `translateY(${headerY}px)`,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 42,
|
||||
fontWeight: 700,
|
||||
color: brand.textPrimary,
|
||||
letterSpacing: '-0.03em',
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
Export anywhere
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 18,
|
||||
color: brand.textMuted,
|
||||
}}
|
||||
>
|
||||
Share as a reimportable notebook or readable Markdown
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 32,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
opacity: notebookOpacity,
|
||||
backgroundColor: brand.surface,
|
||||
border: `1px solid ${brand.border}`,
|
||||
borderRadius: 10,
|
||||
padding: '14px 20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 8,
|
||||
backgroundColor: `${brand.accent}15`,
|
||||
border: `1px solid ${brand.accent}30`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: brand.accent,
|
||||
}}
|
||||
>
|
||||
NB
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: brand.textPrimary,
|
||||
}}
|
||||
>
|
||||
Debug Payment Failures
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 11,
|
||||
color: brand.textMuted,
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
3 cells · 1 pinned result
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ opacity: arrowOpacity }}>
|
||||
<ArrowRight size={24} color={brand.textMuted} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 20 }}>
|
||||
<Sequence from={30} layout="none">
|
||||
<FileCard
|
||||
icon={FileJson}
|
||||
extension=".dpnb"
|
||||
title="Data Peek Notebook"
|
||||
description="Reimportable notebook format. Preserves all cells, results, and pinned state."
|
||||
previewLines={[
|
||||
'{ "version": "1.0",',
|
||||
' "cells": [',
|
||||
' { "type": "md", ... },',
|
||||
' { "type": "sql", ... }',
|
||||
' ]',
|
||||
'}',
|
||||
]}
|
||||
accentColor={brand.accent}
|
||||
delay={0}
|
||||
/>
|
||||
</Sequence>
|
||||
|
||||
<Sequence from={50} layout="none">
|
||||
<FileCard
|
||||
icon={FileText}
|
||||
extension=".md"
|
||||
title="Markdown"
|
||||
description="Human-readable export. Opens in any editor, renders on GitHub, shareable everywhere."
|
||||
previewLines={[
|
||||
'## Step 1: Check stuck payments',
|
||||
'',
|
||||
'```sql',
|
||||
'SELECT id, amount, status',
|
||||
'FROM payments',
|
||||
'```',
|
||||
]}
|
||||
accentColor="#10b981"
|
||||
delay={0}
|
||||
/>
|
||||
</Sequence>
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
)
|
||||
}
|
||||
343
apps/video/src/compositions/NotebookDemo/KeyboardScene.tsx
Normal file
343
apps/video/src/compositions/NotebookDemo/KeyboardScene.tsx
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
Sequence,
|
||||
} from 'remotion'
|
||||
import { brand } from '../../lib/colors'
|
||||
import { CyanGlow } from '../../components/CyanGlow'
|
||||
|
||||
type ShortcutKeyProps = {
|
||||
keys: string[]
|
||||
description: string
|
||||
delay: number
|
||||
highlight?: boolean
|
||||
}
|
||||
|
||||
const ShortcutKey: React.FC<ShortcutKeyProps> = ({ keys, description, delay, highlight }) => {
|
||||
const frame = useCurrentFrame()
|
||||
const { fps } = useVideoConfig()
|
||||
|
||||
const entrance = spring({ frame: frame - delay, fps, config: { damping: 200 } })
|
||||
const opacity = interpolate(entrance, [0, 1], [0, 1])
|
||||
const translateY = interpolate(entrance, [0, 1], [20, 0])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
opacity,
|
||||
transform: `translateY(${translateY}px)`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
padding: '14px 24px',
|
||||
backgroundColor: highlight ? `${brand.accent}10` : brand.surface,
|
||||
border: `1px solid ${highlight ? brand.accent : brand.border}`,
|
||||
borderRadius: 10,
|
||||
minWidth: 480,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
{keys.map((key, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{i > 0 && (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 12,
|
||||
color: brand.textMuted,
|
||||
marginRight: 2,
|
||||
}}
|
||||
>
|
||||
+
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: brand.surfaceElevated,
|
||||
border: `1px solid ${brand.border}`,
|
||||
borderBottom: `3px solid ${brand.border}`,
|
||||
borderRadius: 6,
|
||||
padding: '4px 10px',
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: highlight ? brand.accent : brand.textPrimary,
|
||||
minWidth: 32,
|
||||
textAlign: 'center',
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{key}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 16,
|
||||
color: highlight ? brand.textPrimary : brand.textSecondary,
|
||||
fontWeight: highlight ? 500 : 400,
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type FocusedCellProps = {
|
||||
focusOffset: number
|
||||
label: string
|
||||
isFocused: boolean
|
||||
}
|
||||
|
||||
const FocusedCell: React.FC<FocusedCellProps> = ({ focusOffset, label, isFocused }) => {
|
||||
const frame = useCurrentFrame()
|
||||
const { fps } = useVideoConfig()
|
||||
|
||||
const entrance = spring({ frame, fps, config: { damping: 200 } })
|
||||
const opacity = interpolate(entrance, [0, 1], [0, 1])
|
||||
|
||||
const ringPulse = interpolate(
|
||||
(frame % 60) / 60,
|
||||
[0, 0.5, 1],
|
||||
[0.6, 1, 0.6]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
opacity,
|
||||
position: 'relative',
|
||||
transform: `translateY(${focusOffset}px)`,
|
||||
transition: 'transform 0.3s',
|
||||
width: 600,
|
||||
}}
|
||||
>
|
||||
{isFocused && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: -2,
|
||||
borderRadius: 10,
|
||||
border: `2px solid ${brand.accent}`,
|
||||
opacity: ringPulse,
|
||||
pointerEvents: 'none',
|
||||
boxShadow: `0 0 20px ${brand.accent}40`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: brand.surface,
|
||||
border: `1px solid ${brand.border}`,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: brand.surfaceElevated,
|
||||
borderBottom: `1px solid ${brand.border}`,
|
||||
padding: '5px 12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: brand.accent,
|
||||
backgroundColor: `${brand.accent}15`,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
SQL
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 11,
|
||||
color: brand.textMuted,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ padding: '10px 16px' }}>
|
||||
<pre
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
margin: 0,
|
||||
color: brand.textSecondary,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#a855f7' }}>SELECT</span>
|
||||
<span style={{ color: brand.textSecondary }}> * </span>
|
||||
<span style={{ color: '#a855f7' }}>FROM</span>
|
||||
<span style={{ color: '#22d3ee' }}> {label === 'Cell 1' ? 'payments' : label === 'Cell 2' ? 'gateway_logs' : 'refunds'}</span>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const KeyboardScene: React.FC = () => {
|
||||
const frame = useCurrentFrame()
|
||||
const { fps } = useVideoConfig()
|
||||
|
||||
const focusOffset = (() => {
|
||||
if (frame < 40) return 0
|
||||
if (frame < 80) {
|
||||
const t = spring({ frame: frame - 40, fps, config: { damping: 200 } })
|
||||
return interpolate(t, [0, 1], [0, 80])
|
||||
}
|
||||
if (frame < 120) return 80
|
||||
if (frame < 160) {
|
||||
const t = spring({ frame: frame - 120, fps, config: { damping: 200 } })
|
||||
return interpolate(t, [0, 1], [80, 0])
|
||||
}
|
||||
return 0
|
||||
})()
|
||||
|
||||
const focusedCellIndex = (() => {
|
||||
if (frame < 40) return 0
|
||||
if (frame < 120) return 1
|
||||
return 0
|
||||
})()
|
||||
|
||||
const headerEntrance = spring({ frame, fps, config: { damping: 200 } })
|
||||
const headerOpacity = interpolate(headerEntrance, [0, 1], [0, 1])
|
||||
const headerY = interpolate(headerEntrance, [0, 1], [30, 0])
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 40,
|
||||
}}
|
||||
>
|
||||
<CyanGlow size={400} x="80%" y="20%" delay={0} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
opacity: headerOpacity,
|
||||
transform: `translateY(${headerY}px)`,
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 42,
|
||||
fontWeight: 700,
|
||||
color: brand.textPrimary,
|
||||
letterSpacing: '-0.03em',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
Keyboard-first navigation
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 80 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Sequence from={10} layout="none">
|
||||
<ShortcutKey
|
||||
keys={['⇧', '⏎']}
|
||||
description="Run & advance to next cell"
|
||||
delay={0}
|
||||
highlight
|
||||
/>
|
||||
</Sequence>
|
||||
<Sequence from={10} layout="none">
|
||||
<ShortcutKey
|
||||
keys={['⌘', '⏎']}
|
||||
description="Run cell in place"
|
||||
delay={15}
|
||||
/>
|
||||
</Sequence>
|
||||
<Sequence from={10} layout="none">
|
||||
<ShortcutKey
|
||||
keys={['⌘', 'J']}
|
||||
description="Move focus down"
|
||||
delay={30}
|
||||
/>
|
||||
</Sequence>
|
||||
<Sequence from={10} layout="none">
|
||||
<ShortcutKey
|
||||
keys={['⌘', 'K']}
|
||||
description="Move focus up"
|
||||
delay={45}
|
||||
/>
|
||||
</Sequence>
|
||||
<Sequence from={10} layout="none">
|
||||
<ShortcutKey
|
||||
keys={['⌘', 'P']}
|
||||
description="Pin / unpin results"
|
||||
delay={60}
|
||||
/>
|
||||
</Sequence>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Sequence from={5} layout="none">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<FocusedCell focusOffset={-focusOffset} label="Cell 1" isFocused={focusedCellIndex === 0} />
|
||||
<FocusedCell focusOffset={-focusOffset + 80} label="Cell 2" isFocused={focusedCellIndex === 1} />
|
||||
</div>
|
||||
</Sequence>
|
||||
|
||||
{frame >= 40 && frame < 80 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: -40,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 12,
|
||||
color: brand.accent,
|
||||
opacity: interpolate(frame, [40, 50], [0, 1], { extrapolateRight: 'clamp' }),
|
||||
}}
|
||||
>
|
||||
⌘+J — focus moves down
|
||||
</div>
|
||||
)}
|
||||
{frame >= 120 && frame < 160 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: -40,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 12,
|
||||
color: brand.accent,
|
||||
opacity: interpolate(frame, [120, 130], [0, 1], { extrapolateRight: 'clamp' }),
|
||||
}}
|
||||
>
|
||||
⌘+K — focus moves up
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
)
|
||||
}
|
||||
622
apps/video/src/compositions/NotebookDemo/NotebookMockup.tsx
Normal file
622
apps/video/src/compositions/NotebookDemo/NotebookMockup.tsx
Normal file
|
|
@ -0,0 +1,622 @@
|
|||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
Sequence,
|
||||
} from 'remotion'
|
||||
import { brand } from '../../lib/colors'
|
||||
import { Play, Pin, ChevronDown } from 'lucide-react'
|
||||
|
||||
const SQL_TEXT = `SELECT id, amount, status
|
||||
FROM payments
|
||||
WHERE status = 'processing'
|
||||
AND updated_at < NOW() - interval '30 mins'`
|
||||
|
||||
const GATEWAY_SQL = `SELECT id, gateway, error_code
|
||||
FROM gateway_logs
|
||||
WHERE created_at > NOW() - interval '1h'`
|
||||
|
||||
type SyntaxToken = { text: string; color: string }
|
||||
|
||||
function tokenizeSql(sql: string): SyntaxToken[] {
|
||||
const keywords = ['SELECT', 'FROM', 'WHERE', 'AND', 'OR', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'ON', 'AS', 'interval']
|
||||
const tableNames = ['payments', 'gateway_logs']
|
||||
const tokens: SyntaxToken[] = []
|
||||
|
||||
const parts = sql.split(/(\s+|,|'[^']*'|\bNOW\(\)|\b\w+\b)/)
|
||||
|
||||
for (const part of parts) {
|
||||
if (!part) continue
|
||||
const isKeyword = keywords.includes(part.toUpperCase()) || keywords.includes(part)
|
||||
const isTable = tableNames.includes(part)
|
||||
const isString = part.startsWith("'") && part.endsWith("'")
|
||||
const isFunction = /^\w+\(\)$/.test(part)
|
||||
const isOperator = ['>', '<', '=', '>=', '<=', '!=', '-'].includes(part)
|
||||
const isNumber = /^\d+$/.test(part)
|
||||
|
||||
if (isKeyword) {
|
||||
tokens.push({ text: part, color: '#a855f7' })
|
||||
} else if (isTable) {
|
||||
tokens.push({ text: part, color: '#22d3ee' })
|
||||
} else if (isString) {
|
||||
tokens.push({ text: part, color: '#fbbf24' })
|
||||
} else if (isFunction) {
|
||||
tokens.push({ text: part, color: '#22d3ee' })
|
||||
} else if (isOperator) {
|
||||
tokens.push({ text: part, color: brand.textMuted })
|
||||
} else if (isNumber) {
|
||||
tokens.push({ text: part, color: '#fbbf24' })
|
||||
} else {
|
||||
tokens.push({ text: part, color: brand.textSecondary })
|
||||
}
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
function renderSqlWithHighlighting(sql: string, visibleChars: number): React.ReactNode {
|
||||
const visibleText = sql.slice(0, visibleChars)
|
||||
const tokens = tokenizeSql(visibleText)
|
||||
|
||||
return (
|
||||
<span>
|
||||
{tokens.map((token, i) => (
|
||||
<span key={i} style={{ color: token.color }}>
|
||||
{token.text}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
type CellBadgeProps = {
|
||||
type: 'SQL' | 'MD'
|
||||
}
|
||||
|
||||
const CellBadge: React.FC<CellBadgeProps> = ({ type }) => (
|
||||
<div
|
||||
style={{
|
||||
color: type === 'SQL' ? brand.accent : brand.textMuted,
|
||||
backgroundColor: type === 'SQL' ? `${brand.accent}15` : `${brand.textMuted}20`,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 4,
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
{type}
|
||||
</div>
|
||||
)
|
||||
|
||||
type ResultTableProps = {
|
||||
opacity: number
|
||||
translateY: number
|
||||
}
|
||||
|
||||
const ResultTable: React.FC<ResultTableProps> = ({ opacity, translateY }) => {
|
||||
const rows = [
|
||||
{ id: 'pay_8xk2', amount: '$142.00', status: 'processing' },
|
||||
{ id: 'pay_9mf4', amount: '$89.50', status: 'processing' },
|
||||
{ id: 'pay_3qz7', amount: '$256.00', status: 'processing' },
|
||||
]
|
||||
const cols = ['id', 'amount', 'status']
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
opacity,
|
||||
transform: `translateY(${translateY}px)`,
|
||||
borderTop: `1px solid ${brand.border}`,
|
||||
padding: '8px 0',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr 1fr',
|
||||
padding: '4px 16px',
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
{cols.map((col) => (
|
||||
<div
|
||||
key={col}
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: brand.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
{col}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{rows.map((row, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr 1fr',
|
||||
padding: '5px 16px',
|
||||
backgroundColor: i % 2 === 0 ? `${brand.surfaceElevated}80` : 'transparent',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 12,
|
||||
color: brand.textSecondary,
|
||||
}}
|
||||
>
|
||||
{row.id}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 12,
|
||||
color: brand.textSecondary,
|
||||
}}
|
||||
>
|
||||
{row.amount}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 12,
|
||||
color: '#fbbf24',
|
||||
}}
|
||||
>
|
||||
{row.status}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
style={{
|
||||
padding: '4px 16px',
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 10,
|
||||
color: brand.textMuted,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
3 rows · 12ms
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type PinBadgeProps = {
|
||||
opacity: number
|
||||
scale: number
|
||||
}
|
||||
|
||||
const PinBadge: React.FC<PinBadgeProps> = ({ opacity, scale }) => (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
opacity,
|
||||
transform: `scale(${scale})`,
|
||||
backgroundColor: `${brand.accent}20`,
|
||||
border: `1px solid ${brand.accent}40`,
|
||||
borderRadius: 6,
|
||||
padding: '3px 8px',
|
||||
}}
|
||||
>
|
||||
<Pin size={10} color={brand.accent} />
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 10,
|
||||
color: brand.accent,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
pinned
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const NotebookMockup: React.FC = () => {
|
||||
const frame = useCurrentFrame()
|
||||
const { fps } = useVideoConfig()
|
||||
|
||||
const notebookEntrance = spring({ frame, fps, config: { damping: 200 } })
|
||||
const notebookOpacity = interpolate(notebookEntrance, [0, 1], [0, 1])
|
||||
const notebookY = interpolate(notebookEntrance, [0, 1], [40, 0])
|
||||
|
||||
const cell1Entrance = spring({ frame: frame - 60, fps, config: { damping: 200 } })
|
||||
const cell1Opacity = interpolate(cell1Entrance, [0, 1], [0, 1])
|
||||
const cell1Y = interpolate(cell1Entrance, [0, 1], [20, 0])
|
||||
|
||||
const cell2Entrance = spring({ frame: frame - 120, fps, config: { damping: 200 } })
|
||||
const cell2Opacity = interpolate(cell2Entrance, [0, 1], [0, 1])
|
||||
const cell2Y = interpolate(cell2Entrance, [0, 1], [20, 0])
|
||||
|
||||
const localSqlFrame = Math.max(0, frame - 120)
|
||||
const totalSqlChars = SQL_TEXT.length
|
||||
const framesPerChar = fps / 30
|
||||
const sqlCharsVisible = Math.min(totalSqlChars, Math.floor(localSqlFrame / framesPerChar))
|
||||
|
||||
const runPulseFrame = Math.max(0, frame - 200)
|
||||
const runPulse = interpolate(
|
||||
(runPulseFrame % 20) / 20,
|
||||
[0, 0.5, 1],
|
||||
[0.5, 1, 0.5]
|
||||
)
|
||||
const runVisible = frame >= 200 && frame < 240
|
||||
|
||||
const resultsEntrance = spring({ frame: frame - 240, fps, config: { damping: 200 } })
|
||||
const resultsOpacity = interpolate(resultsEntrance, [0, 1], [0, 1])
|
||||
const resultsY = interpolate(resultsEntrance, [0, 1], [20, 0])
|
||||
|
||||
const pinEntrance = spring({ frame: frame - 280, fps, config: { damping: 200 } })
|
||||
const pinOpacity = interpolate(pinEntrance, [0, 1], [0, 1])
|
||||
const pinScale = interpolate(pinEntrance, [0, 1], [0.5, 1])
|
||||
|
||||
const pinGlowOpacity = frame >= 280 ? interpolate(frame - 280, [0, 20], [0.8, 0.3], {
|
||||
extrapolateRight: 'clamp',
|
||||
}) : 0
|
||||
|
||||
const cell3Entrance = spring({ frame: frame - 300, fps, config: { damping: 200 } })
|
||||
const cell3Opacity = interpolate(cell3Entrance, [0, 1], [0, 1])
|
||||
const cell3Y = interpolate(cell3Entrance, [0, 1], [20, 0])
|
||||
|
||||
const localGatewaySqlFrame = Math.max(0, frame - 300)
|
||||
const gatewayCharsVisible = Math.min(
|
||||
GATEWAY_SQL.length,
|
||||
Math.floor(localGatewaySqlFrame / framesPerChar)
|
||||
)
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '80px 160px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 1100,
|
||||
opacity: notebookOpacity,
|
||||
transform: `translateY(${notebookY}px)`,
|
||||
backgroundColor: brand.surface,
|
||||
border: `1px solid ${brand.border}`,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
boxShadow: `0 0 60px ${brand.accent}10, 0 20px 60px rgba(0,0,0,0.5)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: brand.surfaceElevated,
|
||||
borderBottom: `1px solid ${brand.border}`,
|
||||
padding: '10px 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{['#ff5f57', '#ffbd2e', '#28ca41'].map((c, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{ width: 10, height: 10, borderRadius: '50%', backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: brand.textPrimary,
|
||||
}}
|
||||
>
|
||||
Debug Payment Failures
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
backgroundColor: `${brand.accent}15`,
|
||||
border: `1px solid ${brand.accent}30`,
|
||||
borderRadius: 6,
|
||||
padding: '4px 10px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#22c55e',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 11,
|
||||
color: brand.accent,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
payments-db
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<Sequence from={60} layout="none">
|
||||
<div
|
||||
style={{
|
||||
opacity: cell1Opacity,
|
||||
transform: `translateY(${cell1Y}px)`,
|
||||
backgroundColor: brand.surface,
|
||||
border: `1px solid ${brand.border}`,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: brand.surfaceElevated,
|
||||
borderBottom: `1px solid ${brand.border}`,
|
||||
padding: '5px 12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<CellBadge type="MD" />
|
||||
</div>
|
||||
<div style={{ padding: '10px 16px' }}>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
color: brand.textPrimary,
|
||||
}}
|
||||
>
|
||||
## Step 1: Check stuck payments
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Sequence>
|
||||
|
||||
<Sequence from={120} layout="none">
|
||||
<div
|
||||
style={{
|
||||
opacity: cell2Opacity,
|
||||
transform: `translateY(${cell2Y}px)`,
|
||||
backgroundColor: brand.surface,
|
||||
border: `2px solid ${brand.accent}60`,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
boxShadow: `0 0 20px ${brand.accent}15`,
|
||||
}}
|
||||
>
|
||||
{frame >= 280 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: 8,
|
||||
boxShadow: `inset 0 0 20px ${brand.accent}${Math.floor(pinGlowOpacity * 30).toString(16).padStart(2, '0')}`,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: brand.surfaceElevated,
|
||||
borderBottom: `1px solid ${brand.border}`,
|
||||
padding: '5px 12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<CellBadge type="SQL" />
|
||||
{runVisible && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
opacity: runPulse,
|
||||
}}
|
||||
>
|
||||
<Play size={10} color={brand.accent} fill={brand.accent} />
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 10,
|
||||
color: brand.accent,
|
||||
}}
|
||||
>
|
||||
running...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{frame >= 240 && (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 10,
|
||||
color: brand.textMuted,
|
||||
}}
|
||||
>
|
||||
3 rows · 12ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '12px 16px', position: 'relative' }}>
|
||||
<pre
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{renderSqlWithHighlighting(SQL_TEXT, sqlCharsVisible)}
|
||||
{sqlCharsVisible < SQL_TEXT.length && (
|
||||
<span style={{ opacity: Math.round(frame / 8) % 2 === 0 ? 1 : 0, color: brand.accent }}>
|
||||
|
|
||||
</span>
|
||||
)}
|
||||
</pre>
|
||||
|
||||
{frame >= 280 && (
|
||||
<PinBadge opacity={pinOpacity} scale={pinScale} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{frame >= 240 && (
|
||||
<ResultTable opacity={resultsOpacity} translateY={resultsY} />
|
||||
)}
|
||||
</div>
|
||||
</Sequence>
|
||||
|
||||
<Sequence from={300} layout="none">
|
||||
<div
|
||||
style={{
|
||||
opacity: cell3Opacity,
|
||||
transform: `translateY(${cell3Y}px)`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: brand.surface,
|
||||
border: `1px solid ${brand.border}`,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: brand.surfaceElevated,
|
||||
borderBottom: `1px solid ${brand.border}`,
|
||||
padding: '5px 12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<CellBadge type="MD" />
|
||||
</div>
|
||||
<div style={{ padding: '10px 16px' }}>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
color: brand.textPrimary,
|
||||
}}
|
||||
>
|
||||
## Step 2: Check gateway logs
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{frame >= 330 && (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: brand.surface,
|
||||
border: `1px solid ${brand.border}`,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: brand.surfaceElevated,
|
||||
borderBottom: `1px solid ${brand.border}`,
|
||||
padding: '5px 12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<CellBadge type="SQL" />
|
||||
</div>
|
||||
<div style={{ padding: '12px 16px' }}>
|
||||
<pre
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{renderSqlWithHighlighting(GATEWAY_SQL, gatewayCharsVisible)}
|
||||
{gatewayCharsVisible < GATEWAY_SQL.length && (
|
||||
<span
|
||||
style={{
|
||||
opacity: Math.round(frame / 8) % 2 === 0 ? 1 : 0,
|
||||
color: brand.accent,
|
||||
}}
|
||||
>
|
||||
|
|
||||
</span>
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Sequence>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
borderTop: `1px solid ${brand.border}`,
|
||||
padding: '6px 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
backgroundColor: brand.surfaceElevated,
|
||||
}}
|
||||
>
|
||||
<ChevronDown size={12} color={brand.textMuted} />
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 11,
|
||||
color: brand.textMuted,
|
||||
}}
|
||||
>
|
||||
+ Add cell
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
)
|
||||
}
|
||||
119
apps/video/src/compositions/NotebookDemo/TitleScene.tsx
Normal file
119
apps/video/src/compositions/NotebookDemo/TitleScene.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
Sequence,
|
||||
} from 'remotion'
|
||||
import { brand } from '../../lib/colors'
|
||||
import { CyanGlow } from '../../components/CyanGlow'
|
||||
import { BookOpen } from 'lucide-react'
|
||||
|
||||
export const TitleScene: React.FC = () => {
|
||||
const frame = useCurrentFrame()
|
||||
const { fps } = useVideoConfig()
|
||||
|
||||
const iconScale = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 100 },
|
||||
})
|
||||
const iconRotate = interpolate(iconScale, [0, 1], [-180, 0])
|
||||
|
||||
const titleEntrance = spring({
|
||||
frame: frame - 8,
|
||||
fps,
|
||||
config: { damping: 15, stiffness: 80 },
|
||||
})
|
||||
const titleOpacity = interpolate(frame, [5, 25], [0, 1], {
|
||||
extrapolateRight: 'clamp',
|
||||
})
|
||||
const titleY = interpolate(titleEntrance, [0, 1], [30, 0])
|
||||
|
||||
const subtitleEntrance = spring({
|
||||
frame: frame - 25,
|
||||
fps,
|
||||
config: { damping: 200 },
|
||||
})
|
||||
const subtitleOpacity = interpolate(subtitleEntrance, [0, 1], [0, 1])
|
||||
const subtitleY = interpolate(subtitleEntrance, [0, 1], [20, 0])
|
||||
|
||||
const logoEntrance = spring({
|
||||
frame: frame - 45,
|
||||
fps,
|
||||
config: { damping: 200 },
|
||||
})
|
||||
const logoOpacity = interpolate(logoEntrance, [0, 1], [0, 1])
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 20,
|
||||
}}
|
||||
>
|
||||
<CyanGlow size={600} delay={5} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
transform: `scale(${iconScale}) rotate(${iconRotate}deg)`,
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
<BookOpen size={72} color={brand.accent} strokeWidth={1.5} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
opacity: titleOpacity,
|
||||
transform: `translateY(${titleY}px)`,
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 96,
|
||||
fontWeight: 700,
|
||||
color: brand.textPrimary,
|
||||
letterSpacing: '-0.05em',
|
||||
}}
|
||||
>
|
||||
SQL Notebooks
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
opacity: subtitleOpacity,
|
||||
transform: `translateY(${subtitleY}px)`,
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 32,
|
||||
fontWeight: 400,
|
||||
color: brand.textMuted,
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
Runbooks that actually run.
|
||||
</div>
|
||||
|
||||
<Sequence from={45} layout="none">
|
||||
<div
|
||||
style={{
|
||||
opacity: logoOpacity,
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 18,
|
||||
fontWeight: 500,
|
||||
color: brand.textMuted,
|
||||
marginTop: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: brand.accent }}>data-peek</span>
|
||||
<span>·</span>
|
||||
<span>v0.20.0</span>
|
||||
</div>
|
||||
</Sequence>
|
||||
</AbsoluteFill>
|
||||
)
|
||||
}
|
||||
447
apps/video/src/compositions/NotebookDemo/index.tsx
Normal file
447
apps/video/src/compositions/NotebookDemo/index.tsx
Normal file
|
|
@ -0,0 +1,447 @@
|
|||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
interpolate,
|
||||
staticFile,
|
||||
spring,
|
||||
} from 'remotion'
|
||||
import { Audio } from '@remotion/media'
|
||||
import { TransitionSeries, linearTiming } from '@remotion/transitions'
|
||||
import { fade } from '@remotion/transitions/fade'
|
||||
import { slide } from '@remotion/transitions/slide'
|
||||
import { Background } from '../../components/Background'
|
||||
import { CyanGlow } from '../../components/CyanGlow'
|
||||
import { brand } from '../../lib/colors'
|
||||
import { ensureFonts } from '../../lib/fonts'
|
||||
import { TitleScene } from './TitleScene'
|
||||
import { NotebookMockup } from './NotebookMockup'
|
||||
import { KeyboardScene } from './KeyboardScene'
|
||||
import { ExportScene } from './ExportScene'
|
||||
import { EndScene } from './EndScene'
|
||||
|
||||
ensureFonts()
|
||||
|
||||
const TRANSITION_DURATION = 15
|
||||
const fadeTiming = linearTiming({ durationInFrames: TRANSITION_DURATION })
|
||||
const fadePresentation = fade()
|
||||
const slidePresentation = slide({ direction: 'from-right' })
|
||||
|
||||
const ProblemScene: React.FC = () => {
|
||||
const frame = useCurrentFrame()
|
||||
const { fps } = useVideoConfig()
|
||||
|
||||
const headerEntrance = spring({ frame, fps, config: { damping: 200 } })
|
||||
const headerOpacity = interpolate(headerEntrance, [0, 1], [0, 1])
|
||||
const headerY = interpolate(headerEntrance, [0, 1], [30, 0])
|
||||
|
||||
const leftEntrance = spring({ frame: frame - 20, fps, config: { damping: 200 } })
|
||||
const leftOpacity = interpolate(leftEntrance, [0, 1], [0, 1])
|
||||
const leftY = interpolate(leftEntrance, [0, 1], [30, 0])
|
||||
|
||||
const rightEntrance = spring({ frame: frame - 35, fps, config: { damping: 200 } })
|
||||
const rightOpacity = interpolate(rightEntrance, [0, 1], [0, 1])
|
||||
const rightY = interpolate(rightEntrance, [0, 1], [30, 0])
|
||||
|
||||
const crossOpacity = interpolate(frame, [60, 75], [0, 1], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
})
|
||||
|
||||
const solutionEntrance = spring({ frame: frame - 80, fps, config: { damping: 200 } })
|
||||
const solutionOpacity = interpolate(solutionEntrance, [0, 1], [0, 1])
|
||||
const solutionY = interpolate(solutionEntrance, [0, 1], [20, 0])
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 36,
|
||||
padding: '0 100px',
|
||||
}}
|
||||
>
|
||||
<CyanGlow size={400} x="50%" y="50%" delay={0} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
opacity: headerOpacity,
|
||||
transform: `translateY(${headerY}px)`,
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 28,
|
||||
color: brand.textMuted,
|
||||
letterSpacing: '-0.01em',
|
||||
}}
|
||||
>
|
||||
Documentation here. Queries there.
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 32, alignItems: 'stretch' }}>
|
||||
<div
|
||||
style={{
|
||||
opacity: leftOpacity,
|
||||
transform: `translateY(${leftY}px)`,
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: brand.surface,
|
||||
border: `1px solid ${brand.border}`,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: brand.surfaceElevated,
|
||||
borderBottom: `1px solid ${brand.border}`,
|
||||
padding: '8px 14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 3,
|
||||
backgroundColor: '#94a3b8',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 12,
|
||||
color: brand.textMuted,
|
||||
}}
|
||||
>
|
||||
Notion
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ padding: '20px 20px' }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
color: brand.textPrimary,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
Step 1: Check stuck payments
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 13,
|
||||
color: brand.textSecondary,
|
||||
marginBottom: 14,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
Run this query on the payments DB to find any processing orders stuck for {'>'}30 mins:
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: brand.background,
|
||||
border: `1px solid ${brand.border}`,
|
||||
borderRadius: 6,
|
||||
padding: '12px 14px',
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 12,
|
||||
color: brand.textSecondary,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
<div>SELECT id, amount, status</div>
|
||||
<div>FROM payments</div>
|
||||
<div>WHERE status = 'processing'</div>
|
||||
<div>AND updated_at {'<'} NOW() - interval '30 mins'</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 10,
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 12,
|
||||
color: brand.textMuted,
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
(copy this and run it in your SQL client)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{frame >= 60 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
opacity: crossOpacity,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 12,
|
||||
backgroundColor: `${brand.background}80`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 72,
|
||||
color: '#ef4444',
|
||||
fontWeight: 700,
|
||||
textShadow: '0 0 20px #ef444460',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
opacity: rightOpacity,
|
||||
transform: `translateY(${rightY}px)`,
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: brand.surface,
|
||||
border: `1px solid ${brand.border}`,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: brand.surfaceElevated,
|
||||
borderBottom: `1px solid ${brand.border}`,
|
||||
padding: '8px 14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: `${brand.accent}20`,
|
||||
border: `1px solid ${brand.accent}40`,
|
||||
borderRadius: 4,
|
||||
padding: '2px 8px',
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: brand.accent,
|
||||
}}
|
||||
>
|
||||
data-peek
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 12,
|
||||
color: brand.textMuted,
|
||||
}}
|
||||
>
|
||||
query_tab_1.sql
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ padding: '20px' }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
color: brand.textSecondary,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#a855f7' }}>SELECT</span>
|
||||
{' id, amount, status\n'}
|
||||
<span style={{ color: '#a855f7' }}>FROM</span>
|
||||
{' '}
|
||||
<span style={{ color: '#22d3ee' }}>payments</span>
|
||||
{'\n'}
|
||||
<span style={{ color: '#a855f7' }}>WHERE</span>
|
||||
{" status = "}
|
||||
<span style={{ color: '#fbbf24' }}>'processing'</span>
|
||||
{'\n'}
|
||||
<span style={{ color: '#a855f7' }}>AND</span>
|
||||
{' updated_at < '}
|
||||
<span style={{ color: '#22d3ee' }}>NOW()</span>
|
||||
{' - '}
|
||||
<span style={{ color: '#a855f7' }}>interval</span>
|
||||
{" "}
|
||||
<span style={{ color: '#fbbf24' }}>'30 mins'</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: '8px',
|
||||
backgroundColor: brand.background,
|
||||
border: `1px solid ${brand.border}`,
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 11,
|
||||
color: brand.textMuted,
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
Results
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 12,
|
||||
color: brand.textSecondary,
|
||||
}}
|
||||
>
|
||||
3 rows returned
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 12,
|
||||
color: brand.textMuted,
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
(where's the context though?)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{frame >= 60 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
opacity: crossOpacity,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 12,
|
||||
backgroundColor: `${brand.background}80`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 72,
|
||||
color: '#ef4444',
|
||||
fontWeight: 700,
|
||||
textShadow: '0 0 20px #ef444460',
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{frame >= 80 && (
|
||||
<div
|
||||
style={{
|
||||
opacity: solutionOpacity,
|
||||
transform: `translateY(${solutionY}px)`,
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 32,
|
||||
fontWeight: 700,
|
||||
color: brand.textPrimary,
|
||||
letterSpacing: '-0.03em',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
What if they were the{' '}
|
||||
<span style={{ color: brand.accent }}>same thing?</span>
|
||||
</div>
|
||||
)}
|
||||
</AbsoluteFill>
|
||||
)
|
||||
}
|
||||
|
||||
export const NotebookDemo: React.FC = () => {
|
||||
const frame = useCurrentFrame()
|
||||
const { fps, durationInFrames } = useVideoConfig()
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<Background />
|
||||
<Audio
|
||||
src={staticFile('audio/bg-music-notebooks.mp3')}
|
||||
volume={(f) =>
|
||||
interpolate(
|
||||
f,
|
||||
[0, 1 * fps, durationInFrames - 2 * fps, durationInFrames],
|
||||
[0, 0.15, 0.15, 0],
|
||||
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<TransitionSeries>
|
||||
<TransitionSeries.Sequence durationInFrames={90}>
|
||||
<TitleScene />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fadePresentation}
|
||||
timing={fadeTiming}
|
||||
/>
|
||||
<TransitionSeries.Sequence durationInFrames={120}>
|
||||
<ProblemScene />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={slidePresentation}
|
||||
timing={fadeTiming}
|
||||
/>
|
||||
<TransitionSeries.Sequence durationInFrames={360}>
|
||||
<NotebookMockup />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={slidePresentation}
|
||||
timing={fadeTiming}
|
||||
/>
|
||||
<TransitionSeries.Sequence durationInFrames={180}>
|
||||
<KeyboardScene />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fadePresentation}
|
||||
timing={fadeTiming}
|
||||
/>
|
||||
<TransitionSeries.Sequence durationInFrames={120}>
|
||||
<ExportScene />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fadePresentation}
|
||||
timing={fadeTiming}
|
||||
/>
|
||||
<TransitionSeries.Sequence durationInFrames={120}>
|
||||
<EndScene />
|
||||
</TransitionSeries.Sequence>
|
||||
</TransitionSeries>
|
||||
</AbsoluteFill>
|
||||
)
|
||||
}
|
||||
92
apps/video/src/compositions/ReleaseVideo020/Intro.tsx
Normal file
92
apps/video/src/compositions/ReleaseVideo020/Intro.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
Sequence,
|
||||
} from 'remotion'
|
||||
import { brand } from '../../lib/colors'
|
||||
import { VersionBadge } from '../../components/VersionBadge'
|
||||
import { TypewriterText } from '../../components/TypewriterText'
|
||||
import { CyanGlow } from '../../components/CyanGlow'
|
||||
import { BookOpen } from 'lucide-react'
|
||||
|
||||
type IntroProps = {
|
||||
version: string
|
||||
}
|
||||
|
||||
export const Intro: React.FC<IntroProps> = ({ version }) => {
|
||||
const frame = useCurrentFrame()
|
||||
const { fps } = useVideoConfig()
|
||||
|
||||
const iconScale = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 100 },
|
||||
})
|
||||
const iconRotate = interpolate(iconScale, [0, 1], [-180, 0])
|
||||
|
||||
const titleScale = spring({
|
||||
frame: frame - 8,
|
||||
fps,
|
||||
config: { damping: 15, stiffness: 80 },
|
||||
})
|
||||
const titleOpacity = interpolate(frame, [5, 25], [0, 1], {
|
||||
extrapolateRight: 'clamp',
|
||||
})
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 24,
|
||||
}}
|
||||
>
|
||||
<CyanGlow size={500} delay={5} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
transform: `scale(${iconScale}) rotate(${iconRotate}deg)`,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<BookOpen size={64} color={brand.accent} strokeWidth={1.5} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
opacity: titleOpacity,
|
||||
transform: `scale(${titleScale})`,
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 88,
|
||||
fontWeight: 700,
|
||||
color: brand.textPrimary,
|
||||
letterSpacing: '-0.05em',
|
||||
}}
|
||||
>
|
||||
data-peek
|
||||
</div>
|
||||
|
||||
<Sequence from={15} layout="none">
|
||||
<VersionBadge version={version} />
|
||||
</Sequence>
|
||||
|
||||
<Sequence from={35} layout="none">
|
||||
<TypewriterText
|
||||
text="SQL Notebooks. Runbooks that actually run."
|
||||
charsPerSecond={28}
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 30,
|
||||
fontWeight: 400,
|
||||
color: brand.textMuted,
|
||||
}}
|
||||
/>
|
||||
</Sequence>
|
||||
</AbsoluteFill>
|
||||
)
|
||||
}
|
||||
197
apps/video/src/compositions/ReleaseVideo020/Outro.tsx
Normal file
197
apps/video/src/compositions/ReleaseVideo020/Outro.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import {
|
||||
AbsoluteFill,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
Sequence,
|
||||
} from 'remotion'
|
||||
import { brand } from '../../lib/colors'
|
||||
import { CyanGlow } from '../../components/CyanGlow'
|
||||
import { BookOpen, Pin, Keyboard, Share2 } from 'lucide-react'
|
||||
|
||||
type OutroProps = {
|
||||
version: string
|
||||
}
|
||||
|
||||
const featureIcons = [
|
||||
{ icon: BookOpen, color: '#6b8cf5' },
|
||||
{ icon: Pin, color: '#f59e0b' },
|
||||
{ icon: Keyboard, color: '#a855f7' },
|
||||
{ icon: Share2, color: '#10b981' },
|
||||
]
|
||||
|
||||
export const Outro: React.FC<OutroProps> = ({ version }) => {
|
||||
const frame = useCurrentFrame()
|
||||
const { fps, durationInFrames } = useVideoConfig()
|
||||
|
||||
const fadeOut = interpolate(
|
||||
frame,
|
||||
[durationInFrames - 15, durationInFrames],
|
||||
[1, 0],
|
||||
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
|
||||
)
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 36,
|
||||
opacity: fadeOut,
|
||||
}}
|
||||
>
|
||||
<CyanGlow size={500} delay={0} />
|
||||
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
{featureIcons.map(({ icon: Icon, color }, i) => {
|
||||
const entrance = spring({
|
||||
frame: frame - i * 4,
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 100 },
|
||||
})
|
||||
const scale = interpolate(entrance, [0, 1], [0, 1])
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 14,
|
||||
backgroundColor: `${color}15`,
|
||||
border: `1px solid ${color}40`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transform: `scale(${scale})`,
|
||||
}}
|
||||
>
|
||||
<Icon size={22} color={color} strokeWidth={1.5} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Sequence from={15} layout="none">
|
||||
<TitleReveal version={version} />
|
||||
</Sequence>
|
||||
|
||||
<Sequence from={30} layout="none">
|
||||
<CtaReveal />
|
||||
</Sequence>
|
||||
</AbsoluteFill>
|
||||
)
|
||||
}
|
||||
|
||||
const TitleReveal: React.FC<{ version: string }> = ({ version }) => {
|
||||
const frame = useCurrentFrame()
|
||||
const { fps } = useVideoConfig()
|
||||
|
||||
const entrance = spring({ frame, fps, config: { damping: 200 } })
|
||||
const opacity = interpolate(entrance, [0, 1], [0, 1])
|
||||
const translateY = interpolate(entrance, [0, 1], [20, 0])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
opacity,
|
||||
transform: `translateY(${translateY}px)`,
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 56,
|
||||
fontWeight: 700,
|
||||
color: brand.textPrimary,
|
||||
letterSpacing: '-0.04em',
|
||||
}}
|
||||
>
|
||||
data-peek{' '}
|
||||
<span style={{ color: brand.accent }}>v{version}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 22,
|
||||
color: brand.textMuted,
|
||||
}}
|
||||
>
|
||||
SQL Notebooks — runbooks that run.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CtaReveal: React.FC = () => {
|
||||
const frame = useCurrentFrame()
|
||||
const { fps } = useVideoConfig()
|
||||
|
||||
const entrance = spring({ frame, fps, config: { damping: 200 } })
|
||||
const opacity = interpolate(entrance, [0, 1], [0, 1])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
opacity,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 24,
|
||||
color: brand.textMuted,
|
||||
}}
|
||||
>
|
||||
Try it now
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 20, alignItems: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 28,
|
||||
fontWeight: 500,
|
||||
color: brand.accent,
|
||||
borderBottom: `2px solid ${brand.accent}60`,
|
||||
paddingBottom: 4,
|
||||
}}
|
||||
>
|
||||
datapeek.dev
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 18,
|
||||
color: brand.textMuted,
|
||||
}}
|
||||
>
|
||||
|
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 28,
|
||||
fontWeight: 500,
|
||||
color: '#10b981',
|
||||
borderBottom: '2px solid #10b98160',
|
||||
paddingBottom: 4,
|
||||
}}
|
||||
>
|
||||
app.datapeek.dev
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
793
apps/video/src/compositions/ReleaseVideo020/illustrations.tsx
Normal file
793
apps/video/src/compositions/ReleaseVideo020/illustrations.tsx
Normal file
|
|
@ -0,0 +1,793 @@
|
|||
import {
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
spring,
|
||||
interpolate,
|
||||
} from 'remotion'
|
||||
import { brand } from '../../lib/colors'
|
||||
|
||||
export const NotebookCellsIllustration: React.FC = () => {
|
||||
const frame = useCurrentFrame()
|
||||
const { fps } = useVideoConfig()
|
||||
|
||||
const containerEntrance = spring({
|
||||
frame: frame - 5,
|
||||
fps,
|
||||
config: { damping: 200 },
|
||||
})
|
||||
const containerOpacity = interpolate(containerEntrance, [0, 1], [0, 1])
|
||||
|
||||
const mdEntrance = spring({
|
||||
frame: frame - 15,
|
||||
fps,
|
||||
config: { damping: 200 },
|
||||
})
|
||||
const mdOpacity = interpolate(mdEntrance, [0, 1], [0, 1])
|
||||
const mdTranslateY = interpolate(mdEntrance, [0, 1], [16, 0])
|
||||
|
||||
const sqlEntrance = spring({
|
||||
frame: frame - 35,
|
||||
fps,
|
||||
config: { damping: 200 },
|
||||
})
|
||||
const sqlOpacity = interpolate(sqlEntrance, [0, 1], [0, 1])
|
||||
const sqlTranslateY = interpolate(sqlEntrance, [0, 1], [16, 0])
|
||||
|
||||
const resultEntrance = spring({
|
||||
frame: frame - 60,
|
||||
fps,
|
||||
config: { damping: 200 },
|
||||
})
|
||||
const resultOpacity = interpolate(resultEntrance, [0, 1], [0, 1])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 580,
|
||||
height: 420,
|
||||
backgroundColor: brand.surface,
|
||||
borderRadius: 16,
|
||||
border: `1px solid ${brand.border}`,
|
||||
padding: 20,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 10,
|
||||
opacity: containerOpacity,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 12,
|
||||
color: brand.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
runbook.dpnb
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
opacity: mdOpacity,
|
||||
transform: `translateY(${mdTranslateY}px)`,
|
||||
backgroundColor: brand.surfaceElevated,
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${brand.border}`,
|
||||
borderLeft: `3px solid #6b8cf5`,
|
||||
padding: '12px 14px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 10,
|
||||
color: '#6b8cf5',
|
||||
padding: '1px 6px',
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#6b8cf510',
|
||||
border: '1px solid #6b8cf530',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
md
|
||||
</span>
|
||||
<span style={{ fontFamily: 'Geist Mono, monospace', fontSize: 10, color: brand.textMuted }}>
|
||||
markdown cell
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Geist, system-ui, sans-serif',
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
color: brand.textPrimary,
|
||||
}}
|
||||
>
|
||||
# Daily Active Users Report
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Geist, system-ui, sans-serif',
|
||||
fontSize: 12,
|
||||
color: brand.textSecondary,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
Check DAU for the last 7 days. Compare against baseline.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
opacity: sqlOpacity,
|
||||
transform: `translateY(${sqlTranslateY}px)`,
|
||||
backgroundColor: brand.surfaceElevated,
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${brand.border}`,
|
||||
borderLeft: `3px solid #f59e0b`,
|
||||
padding: '12px 14px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 10,
|
||||
color: '#f59e0b',
|
||||
padding: '1px 6px',
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#f59e0b10',
|
||||
border: '1px solid #f59e0b30',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
sql
|
||||
</span>
|
||||
<span style={{ fontFamily: 'Geist Mono, monospace', fontSize: 10, color: brand.textMuted }}>
|
||||
query cell
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
color: brand.textSecondary,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#6b8cf5' }}>SELECT</span>
|
||||
<span style={{ color: brand.textPrimary }}> date_trunc(</span>
|
||||
<span style={{ color: '#10b981' }}>'day'</span>
|
||||
<span style={{ color: brand.textPrimary }}>, created_at) </span>
|
||||
<span style={{ color: '#6b8cf5' }}>AS</span>
|
||||
<span style={{ color: brand.textPrimary }}> day,</span>
|
||||
<br />
|
||||
<span style={{ color: brand.textMuted }}>{' '}</span>
|
||||
<span style={{ color: '#f59e0b' }}>count</span>
|
||||
<span style={{ color: brand.textPrimary }}>(*) </span>
|
||||
<span style={{ color: '#6b8cf5' }}>AS</span>
|
||||
<span style={{ color: brand.textPrimary }}> users</span>
|
||||
<br />
|
||||
<span style={{ color: '#6b8cf5' }}>FROM</span>
|
||||
<span style={{ color: brand.textPrimary }}> events </span>
|
||||
<span style={{ color: '#6b8cf5' }}>GROUP BY</span>
|
||||
<span style={{ color: brand.textPrimary }}> 1 </span>
|
||||
<span style={{ color: '#6b8cf5' }}>ORDER BY</span>
|
||||
<span style={{ color: brand.textPrimary }}> 1 </span>
|
||||
<span style={{ color: '#6b8cf5' }}>DESC</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
opacity: resultOpacity,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
border: `1px solid ${brand.border}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
padding: '5px 12px',
|
||||
backgroundColor: brand.surfaceElevated,
|
||||
borderBottom: `1px solid ${brand.border}`,
|
||||
}}
|
||||
>
|
||||
{['day', 'users'].map((col) => (
|
||||
<span
|
||||
key={col}
|
||||
style={{
|
||||
flex: 1,
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 11,
|
||||
color: brand.textMuted,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{col}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{[
|
||||
{ day: '2024-04-12', users: '2,847' },
|
||||
{ day: '2024-04-11', users: '3,102' },
|
||||
{ day: '2024-04-10', users: '2,991' },
|
||||
].map((row, i) => {
|
||||
const rowEntrance = spring({
|
||||
frame: frame - 70 - i * 6,
|
||||
fps,
|
||||
config: { damping: 200 },
|
||||
})
|
||||
const rowOpacity = interpolate(rowEntrance, [0, 1], [0, 1])
|
||||
|
||||
return (
|
||||
<div
|
||||
key={row.day}
|
||||
style={{
|
||||
opacity: rowOpacity,
|
||||
display: 'flex',
|
||||
padding: '6px 12px',
|
||||
borderBottom: `1px solid ${brand.border}`,
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1, fontFamily: 'Geist Mono, monospace', fontSize: 12, color: brand.textSecondary }}>
|
||||
{row.day}
|
||||
</span>
|
||||
<span style={{ flex: 1, fontFamily: 'Geist Mono, monospace', fontSize: 12, color: '#10b981' }}>
|
||||
{row.users}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PinnedResultsIllustration: React.FC = () => {
|
||||
const frame = useCurrentFrame()
|
||||
const { fps } = useVideoConfig()
|
||||
|
||||
const containerEntrance = spring({
|
||||
frame: frame - 5,
|
||||
fps,
|
||||
config: { damping: 200 },
|
||||
})
|
||||
const containerOpacity = interpolate(containerEntrance, [0, 1], [0, 1])
|
||||
|
||||
const badgeEntrance = spring({
|
||||
frame: frame - 25,
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 100 },
|
||||
})
|
||||
const badgeScale = interpolate(badgeEntrance, [0, 1], [0.7, 1])
|
||||
const badgeOpacity = interpolate(badgeEntrance, [0, 1], [0, 1])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 580,
|
||||
height: 400,
|
||||
backgroundColor: brand.surface,
|
||||
borderRadius: 16,
|
||||
border: `1px solid ${brand.border}`,
|
||||
padding: 24,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
opacity: containerOpacity,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 12,
|
||||
color: brand.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
pinned query result
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: brand.surfaceElevated,
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${brand.border}`,
|
||||
borderLeft: `3px solid #f59e0b`,
|
||||
padding: '10px 14px',
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 13,
|
||||
color: brand.textSecondary,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#6b8cf5' }}>SELECT</span>
|
||||
{' * '}
|
||||
<span style={{ color: '#6b8cf5' }}>FROM</span>
|
||||
{' users '}
|
||||
<span style={{ color: '#6b8cf5' }}>WHERE</span>
|
||||
{' plan = '}
|
||||
<span style={{ color: '#10b981' }}>'pro'</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
opacity: badgeOpacity,
|
||||
transform: `scale(${badgeScale})`,
|
||||
transformOrigin: 'left center',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#f59e0b10',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #f59e0b30',
|
||||
alignSelf: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#f59e0b',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 12,
|
||||
color: '#f59e0b',
|
||||
}}
|
||||
>
|
||||
Pinned — ran Apr 12, 09:14
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
border: `1px solid ${brand.border}`,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
padding: '6px 12px',
|
||||
backgroundColor: brand.surfaceElevated,
|
||||
borderBottom: `1px solid ${brand.border}`,
|
||||
}}
|
||||
>
|
||||
{['id', 'email', 'plan', 'mrr'].map((col) => (
|
||||
<span
|
||||
key={col}
|
||||
style={{
|
||||
flex: 1,
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 11,
|
||||
color: brand.textMuted,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{col}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{[
|
||||
{ id: '1', email: 'alice@co.com', plan: 'pro', mrr: '$49' },
|
||||
{ id: '2', email: 'bob@co.com', plan: 'pro', mrr: '$49' },
|
||||
{ id: '3', email: 'carol@co.com', plan: 'pro', mrr: '$99' },
|
||||
{ id: '4', email: 'dan@co.com', plan: 'pro', mrr: '$49' },
|
||||
].map((row, i) => {
|
||||
const rowEntrance = spring({
|
||||
frame: frame - 35 - i * 8,
|
||||
fps,
|
||||
config: { damping: 200 },
|
||||
})
|
||||
const rowOpacity = interpolate(rowEntrance, [0, 1], [0, 1])
|
||||
|
||||
return (
|
||||
<div
|
||||
key={row.id}
|
||||
style={{
|
||||
opacity: rowOpacity,
|
||||
display: 'flex',
|
||||
padding: '7px 12px',
|
||||
borderBottom: `1px solid ${brand.border}`,
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1, fontFamily: 'Geist Mono, monospace', fontSize: 12, color: brand.textMuted }}>
|
||||
{row.id}
|
||||
</span>
|
||||
<span style={{ flex: 1, fontFamily: 'Geist Mono, monospace', fontSize: 12, color: brand.textPrimary }}>
|
||||
{row.email}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 12,
|
||||
color: '#f59e0b',
|
||||
}}
|
||||
>
|
||||
{row.plan}
|
||||
</span>
|
||||
<span style={{ flex: 1, fontFamily: 'Geist Mono, monospace', fontSize: 12, color: '#10b981' }}>
|
||||
{row.mrr}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const KeyboardNavIllustration: React.FC = () => {
|
||||
const frame = useCurrentFrame()
|
||||
const { fps } = useVideoConfig()
|
||||
|
||||
const focusIndex = Math.floor(frame / 28) % 3
|
||||
|
||||
const cells = [
|
||||
{ label: 'SELECT count(*) FROM users', type: 'sql' },
|
||||
{ label: '## Results look normal', type: 'md' },
|
||||
{ label: 'SELECT * FROM events LIMIT 20', type: 'sql' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 580,
|
||||
height: 400,
|
||||
backgroundColor: brand.surface,
|
||||
borderRadius: 16,
|
||||
border: `1px solid ${brand.border}`,
|
||||
padding: 24,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 12,
|
||||
color: brand.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
keyboard navigation
|
||||
</div>
|
||||
|
||||
{cells.map((cell, i) => {
|
||||
const isFocused = focusIndex === i
|
||||
const entrance = spring({
|
||||
frame: frame - 8 - i * 12,
|
||||
fps,
|
||||
config: { damping: 200 },
|
||||
})
|
||||
const opacity = interpolate(entrance, [0, 1], [0, 1])
|
||||
const translateY = interpolate(entrance, [0, 1], [12, 0])
|
||||
|
||||
const accentColor = cell.type === 'sql' ? '#f59e0b' : '#6b8cf5'
|
||||
const focusGlow = isFocused
|
||||
? interpolate(
|
||||
Math.sin(frame * 0.15),
|
||||
[-1, 1],
|
||||
[0.4, 1]
|
||||
)
|
||||
: 0.3
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
opacity,
|
||||
transform: `translateY(${translateY}px)`,
|
||||
backgroundColor: brand.surfaceElevated,
|
||||
borderRadius: 10,
|
||||
border: `2px solid ${isFocused ? accentColor : brand.border}`,
|
||||
borderLeft: `4px solid ${accentColor}`,
|
||||
padding: '12px 14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
boxShadow: isFocused ? `0 0 0 2px ${accentColor}30` : 'none',
|
||||
transition: 'border-color 0.15s',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 10,
|
||||
color: accentColor,
|
||||
opacity: isFocused ? 1 : focusGlow,
|
||||
padding: '1px 6px',
|
||||
borderRadius: 4,
|
||||
backgroundColor: `${accentColor}12`,
|
||||
border: `1px solid ${accentColor}30`,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{cell.type}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 13,
|
||||
color: isFocused ? brand.textPrimary : brand.textSecondary,
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{cell.label}
|
||||
</span>
|
||||
{isFocused && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 10,
|
||||
color: accentColor,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
focused
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', marginTop: 4 }}>
|
||||
{['Shift+Enter — run & advance', 'Cmd+J — next cell', 'Cmd+K — prev cell'].map((kb, i) => {
|
||||
const entrance = spring({
|
||||
frame: frame - 50 - i * 8,
|
||||
fps,
|
||||
config: { damping: 200 },
|
||||
})
|
||||
const opacity = interpolate(entrance, [0, 1], [0, 1])
|
||||
|
||||
return (
|
||||
<span
|
||||
key={kb}
|
||||
style={{
|
||||
opacity,
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 10,
|
||||
color: '#a855f7',
|
||||
padding: '3px 10px',
|
||||
borderRadius: 6,
|
||||
backgroundColor: '#a855f710',
|
||||
border: '1px solid #a855f725',
|
||||
}}
|
||||
>
|
||||
{kb}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ExportShareIllustration: React.FC = () => {
|
||||
const frame = useCurrentFrame()
|
||||
const { fps } = useVideoConfig()
|
||||
|
||||
const sourceEntrance = spring({
|
||||
frame: frame - 8,
|
||||
fps,
|
||||
config: { damping: 200 },
|
||||
})
|
||||
const sourceOpacity = interpolate(sourceEntrance, [0, 1], [0, 1])
|
||||
const sourceTranslateX = interpolate(sourceEntrance, [0, 1], [-24, 0])
|
||||
|
||||
const arrowEntrance = spring({
|
||||
frame: frame - 30,
|
||||
fps,
|
||||
config: { damping: 200 },
|
||||
})
|
||||
const arrowOpacity = interpolate(arrowEntrance, [0, 1], [0, 1])
|
||||
|
||||
const dpnbEntrance = spring({
|
||||
frame: frame - 45,
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 100 },
|
||||
})
|
||||
const dpnbScale = interpolate(dpnbEntrance, [0, 1], [0.7, 1])
|
||||
const dpnbOpacity = interpolate(dpnbEntrance, [0, 1], [0, 1])
|
||||
|
||||
const mdEntrance = spring({
|
||||
frame: frame - 65,
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 100 },
|
||||
})
|
||||
const mdScale = interpolate(mdEntrance, [0, 1], [0.7, 1])
|
||||
const mdOpacity = interpolate(mdEntrance, [0, 1], [0, 1])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 580,
|
||||
height: 400,
|
||||
backgroundColor: brand.surface,
|
||||
borderRadius: 16,
|
||||
border: `1px solid ${brand.border}`,
|
||||
padding: 28,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 12,
|
||||
color: brand.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
alignSelf: 'flex-start',
|
||||
}}
|
||||
>
|
||||
export & share
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
opacity: sourceOpacity,
|
||||
transform: `translateX(${sourceTranslateX}px)`,
|
||||
padding: '14px 20px',
|
||||
backgroundColor: brand.surfaceElevated,
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${brand.border}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 8,
|
||||
backgroundColor: `${brand.accent}15`,
|
||||
border: `1px solid ${brand.accent}30`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontFamily: 'Geist Mono, monospace', fontSize: 14, color: brand.accent }}>
|
||||
NB
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<span style={{ fontFamily: 'Geist Mono, monospace', fontSize: 14, color: brand.textPrimary, fontWeight: 500 }}>
|
||||
runbook.dpnb
|
||||
</span>
|
||||
<span style={{ fontFamily: 'Geist Mono, monospace', fontSize: 11, color: brand.textMuted }}>
|
||||
4 cells · last run Apr 12
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
opacity: arrowOpacity,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 40,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{ fontFamily: 'Geist Mono, monospace', fontSize: 18, color: brand.textMuted }}>↙</div>
|
||||
<span style={{ fontFamily: 'Geist Mono, monospace', fontSize: 11, color: brand.textMuted }}>reimportable</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{ fontFamily: 'Geist Mono, monospace', fontSize: 18, color: brand.textMuted }}>↘</div>
|
||||
<span style={{ fontFamily: 'Geist Mono, monospace', fontSize: 11, color: brand.textMuted }}>readable anywhere</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 24 }}>
|
||||
<div
|
||||
style={{
|
||||
opacity: dpnbOpacity,
|
||||
transform: `scale(${dpnbScale})`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '16px 24px',
|
||||
backgroundColor: brand.surfaceElevated,
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${brand.accent}40`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 44,
|
||||
height: 52,
|
||||
borderRadius: 8,
|
||||
backgroundColor: `${brand.accent}15`,
|
||||
border: `1px solid ${brand.accent}40`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontFamily: 'Geist Mono, monospace', fontSize: 11, color: brand.accent, fontWeight: 700 }}>
|
||||
.dpnb
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontFamily: 'Geist Mono, monospace', fontSize: 12, color: brand.accent }}>
|
||||
Export as .dpnb
|
||||
</span>
|
||||
<span style={{ fontFamily: 'Geist Mono, monospace', fontSize: 10, color: brand.textMuted }}>
|
||||
data-peek native
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
opacity: mdOpacity,
|
||||
transform: `scale(${mdScale})`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '16px 24px',
|
||||
backgroundColor: brand.surfaceElevated,
|
||||
borderRadius: 12,
|
||||
border: `1px solid #10b98140`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 44,
|
||||
height: 52,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#10b98115',
|
||||
border: '1px solid #10b98140',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontFamily: 'Geist Mono, monospace', fontSize: 11, color: '#10b981', fontWeight: 700 }}>
|
||||
.md
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontFamily: 'Geist Mono, monospace', fontSize: 12, color: '#10b981' }}>
|
||||
Export as .md
|
||||
</span>
|
||||
<span style={{ fontFamily: 'Geist Mono, monospace', fontSize: 10, color: brand.textMuted }}>
|
||||
readable anywhere
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
120
apps/video/src/compositions/ReleaseVideo020/index.tsx
Normal file
120
apps/video/src/compositions/ReleaseVideo020/index.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { AbsoluteFill } from 'remotion'
|
||||
import { Audio } from '@remotion/media'
|
||||
import { staticFile, interpolate, useCurrentFrame, useVideoConfig } from 'remotion'
|
||||
import { TransitionSeries, linearTiming } from '@remotion/transitions'
|
||||
import { fade } from '@remotion/transitions/fade'
|
||||
import { slide } from '@remotion/transitions/slide'
|
||||
import { BookOpen, Pin, Keyboard, Share2 } from 'lucide-react'
|
||||
import { Fragment } from 'react'
|
||||
import { Background } from '../../components/Background'
|
||||
import { FixScene } from '../ReleaseVideo/FixScene'
|
||||
import { Intro } from './Intro'
|
||||
import { Outro } from './Outro'
|
||||
import {
|
||||
NotebookCellsIllustration,
|
||||
PinnedResultsIllustration,
|
||||
KeyboardNavIllustration,
|
||||
ExportShareIllustration,
|
||||
} from './illustrations'
|
||||
import { ensureFonts } from '../../lib/fonts'
|
||||
|
||||
ensureFonts()
|
||||
|
||||
type ReleaseVideoProps = {
|
||||
version: string
|
||||
}
|
||||
|
||||
const TRANSITION_DURATION = 12
|
||||
const fadeTiming = linearTiming({ durationInFrames: TRANSITION_DURATION })
|
||||
const fadePresentation = fade()
|
||||
const slidePresentation = slide({ direction: 'from-right' })
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: BookOpen,
|
||||
title: 'SQL Notebooks',
|
||||
description:
|
||||
'Mix executable SQL cells with Markdown documentation. Jupyter-style, wired to your database.',
|
||||
color: '#6b8cf5',
|
||||
illustration: NotebookCellsIllustration,
|
||||
},
|
||||
{
|
||||
icon: Pin,
|
||||
title: 'Pin Results',
|
||||
description:
|
||||
"Pin query output so it persists across sessions. Your runbook shows what 'normal' looks like.",
|
||||
color: '#f59e0b',
|
||||
illustration: PinnedResultsIllustration,
|
||||
},
|
||||
{
|
||||
icon: Keyboard,
|
||||
title: 'Keyboard-First',
|
||||
description:
|
||||
'Shift+Enter to run and advance. Cmd+J/K to navigate. Jupyter muscle memory.',
|
||||
color: '#a855f7',
|
||||
illustration: KeyboardNavIllustration,
|
||||
},
|
||||
{
|
||||
icon: Share2,
|
||||
title: 'Export & Share',
|
||||
description:
|
||||
'Export as .dpnb (reimportable) or Markdown (readable anywhere). Same runbook, any connection.',
|
||||
color: '#10b981',
|
||||
illustration: ExportShareIllustration,
|
||||
},
|
||||
]
|
||||
|
||||
export const ReleaseVideo020: React.FC<ReleaseVideoProps> = ({ version }) => {
|
||||
const frame = useCurrentFrame()
|
||||
const { fps, durationInFrames } = useVideoConfig()
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<Background />
|
||||
<Audio
|
||||
src={staticFile('audio/bg-music-notebooks.mp3')}
|
||||
volume={(f) =>
|
||||
interpolate(
|
||||
f,
|
||||
[0, 1 * fps, durationInFrames - 2 * fps, durationInFrames],
|
||||
[0, 0.15, 0.15, 0],
|
||||
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
|
||||
)
|
||||
}
|
||||
/>
|
||||
<TransitionSeries>
|
||||
<TransitionSeries.Sequence durationInFrames={100}>
|
||||
<Intro version={version} />
|
||||
</TransitionSeries.Sequence>
|
||||
|
||||
{features.map((feat, i) => (
|
||||
<Fragment key={feat.title}>
|
||||
<TransitionSeries.Transition
|
||||
presentation={i === 0 ? fadePresentation : slidePresentation}
|
||||
timing={fadeTiming}
|
||||
/>
|
||||
<TransitionSeries.Sequence
|
||||
durationInFrames={120}
|
||||
>
|
||||
<FixScene
|
||||
icon={feat.icon}
|
||||
title={feat.title}
|
||||
description={feat.description}
|
||||
color={feat.color}
|
||||
illustration={feat.illustration}
|
||||
/>
|
||||
</TransitionSeries.Sequence>
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={fadePresentation}
|
||||
timing={fadeTiming}
|
||||
/>
|
||||
<TransitionSeries.Sequence durationInFrames={100}>
|
||||
<Outro version={version} />
|
||||
</TransitionSeries.Sequence>
|
||||
</TransitionSeries>
|
||||
</AbsoluteFill>
|
||||
)
|
||||
}
|
||||
135
docs/blog/11-sql-notebooks.md
Normal file
135
docs/blog/11-sql-notebooks.md
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
---
|
||||
title: "Adding Jupyter-Style SQL Notebooks to a Desktop App"
|
||||
published: false
|
||||
description: "Why I added Jupyter-style notebooks to data-peek, how they work, and what makes them different from running SQL in a cell-based Python notebook."
|
||||
tags: electron, sql, typescript, developer-tools
|
||||
series: "Building data-peek"
|
||||
cover_image:
|
||||
---
|
||||
|
||||
# Adding Jupyter-Style SQL Notebooks to a Desktop App
|
||||
|
||||
A few months ago I added a feature I wasn't sure anyone would ask for: SQL notebooks. Cells of SQL and Markdown mixed together, each cell executable inline, results pinnable to the document. Jupyter for your database, without the kernel management.
|
||||
|
||||
The reaction was better than expected. The use case that kept coming up in feedback wasn't what I'd imagined. People weren't using notebooks for exploratory data analysis — they were using them for **team runbooks**.
|
||||
|
||||
## The Runbook Problem
|
||||
|
||||
Here's the actual problem: your team has a production database, and over time you accumulate a collection of queries that everyone needs occasionally. "How many active users signed up in the last 30 days?" "Which accounts have the broken subscription state?" "What's the current queue depth?"
|
||||
|
||||
These queries usually live in:
|
||||
- A Notion doc where the SQL loses its formatting
|
||||
- A Slack thread you can never find again
|
||||
- Someone's personal `queries.sql` file that leaves when they do
|
||||
|
||||
What you actually want is a document where the SQL is the document. Not a screenshot of results next to code — the live, runnable query *is* the content. And when someone updates the query, the next person who opens it gets the updated version, not a stale screenshot.
|
||||
|
||||
That's the runbook use case. And it's a good fit for a notebook UI.
|
||||
|
||||
## How It Works
|
||||
|
||||
A notebook in data-peek is a tab that holds a list of cells. Each cell is either a SQL cell or a Markdown cell.
|
||||
|
||||
**SQL cells** show the query text. Click to edit, Shift+Enter to run and move to the next cell, Cmd+Enter to run in place. Results appear inline below the query.
|
||||
|
||||
**Markdown cells** render as formatted text when not focused — headings, bold, bullet lists. Click to edit, Escape to go back to rendered mode. These are the "runbook" parts: the explanation of what the query does, when to run it, what to look for in the results.
|
||||
|
||||
The mix looks like this in practice:
|
||||
|
||||
```
|
||||
## Daily Active Users Check
|
||||
|
||||
Run this every morning before standup.
|
||||
|
||||
[SQL cell]
|
||||
SELECT COUNT(DISTINCT user_id)
|
||||
FROM events
|
||||
WHERE created_at > NOW() - INTERVAL '24 hours'
|
||||
AND event_type = 'session_start'
|
||||
|
||||
[Markdown cell]
|
||||
If the number is below 500, check whether the auth service had any
|
||||
errors overnight. Alert #ops if below 200.
|
||||
```
|
||||
|
||||
The Markdown gives context. The SQL is always current. Anyone on the team can open the notebook and run it without digging through docs.
|
||||
|
||||
## Pinning Results
|
||||
|
||||
SQL cells have a "pin result" option in the cell menu. Pinning freezes the current result set into the cell and persists it to disk. The pinned result survives closing and reopening the notebook.
|
||||
|
||||
This is useful for runbooks because you want to see what the baseline looked like. Pinned results display with a timestamp: "Pinned — ran 3 days ago · 12ms". You can re-run to get fresh numbers, or keep the pin as a reference.
|
||||
|
||||
The pin metadata includes `executedAt`, `durationMs`, `rowCount`, and the full result rows. It's stored as a JSON column in SQLite. More on the storage design in the next post.
|
||||
|
||||
## The .dpnb Format
|
||||
|
||||
Notebooks can be exported as `.dpnb` files (data-peek notebook, JSON) or as Markdown. The `.dpnb` format is straightforward:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"title": "Daily Checks",
|
||||
"folder": "ops",
|
||||
"cells": [
|
||||
{
|
||||
"type": "markdown",
|
||||
"content": "## Daily Active Users\n\nRun before standup."
|
||||
},
|
||||
{
|
||||
"type": "sql",
|
||||
"content": "SELECT COUNT(*) FROM events WHERE ...",
|
||||
"pinnedResult": {
|
||||
"columns": ["count"],
|
||||
"rows": [[1234]],
|
||||
"rowCount": 1,
|
||||
"executedAt": 1704067200000,
|
||||
"durationMs": 12
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
This is designed to be committed to a git repo. The cells are plain text, diffable, mergeable. Pinned results make the diffs noisier, but the tradeoff is that you get a historical record of what the query returned.
|
||||
|
||||
The Markdown export is intended for sharing outside the tool — paste into Confluence, commit to a wiki. It renders the pinned result as a Markdown table.
|
||||
|
||||
## What Makes This Different from Jupyter
|
||||
|
||||
The most common question when I describe this feature is "why not just use Jupyter with a PostgreSQL kernel?" Fair question. A few differences:
|
||||
|
||||
**Bound to a connection, not a kernel.** Every notebook is created against a specific database connection. You don't manage kernels, environments, or driver installations. Open the notebook, it's connected.
|
||||
|
||||
**No execution state.** In Jupyter, cells can define variables and functions that affect later cells. Data-peek notebooks have no shared state between cells — each SQL cell is a standalone query. This is a deliberate simplification. It means notebooks are always reproducible regardless of execution order.
|
||||
|
||||
**Instant startup.** No kernel to boot. Opening a notebook is as fast as opening any other tab.
|
||||
|
||||
**Desktop-first.** The notebook is a native desktop feature, not a browser tab. It gets native shortcuts, native file dialogs for export, and it lives alongside your other data-peek tabs.
|
||||
|
||||
The tradeoff is no Python cells, no pandas, no plotting. If you need that, Jupyter is the right tool. data-peek notebooks are specifically for SQL-first workflows.
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
The keyboard model follows Jupyter's conventions where it made sense:
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| Shift+Enter | Run cell and advance to next |
|
||||
| Cmd+Enter | Run cell, stay in place |
|
||||
| Escape | Exit cell edit mode |
|
||||
| Cmd+J | Move focus to next cell |
|
||||
| Cmd+K | Move focus to previous cell |
|
||||
| Cmd+Shift+D | Delete focused cell |
|
||||
|
||||
The hint bar at the bottom of the editor always shows these. I wanted the feature to be discoverable without reading docs.
|
||||
|
||||
## What's Next
|
||||
|
||||
Two things I want to add:
|
||||
|
||||
**AI cells.** A third cell type that takes a natural language prompt and generates SQL. data-peek already has AI SQL generation in the query editor — bringing that into notebooks would make it easier to build runbooks interactively.
|
||||
|
||||
**Cloud sync.** Right now notebooks are local to one machine. For team runbooks you want a shared library. The architecture is already designed for it — notebooks have a `connectionId` but the storage is pluggable. Adding a sync backend is the missing piece.
|
||||
|
||||
The runbook use case made me realize data-peek was missing a way to put SQL *in context*. A notebook is just a document where SQL is a first-class citizen. That sounds obvious in retrospect.
|
||||
254
docs/blog/12-notebook-storage-architecture.md
Normal file
254
docs/blog/12-notebook-storage-architecture.md
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
---
|
||||
title: "Designing Local-First Storage for SQL Notebooks with SQLite"
|
||||
published: false
|
||||
description: "Why I chose SQLite over JSON files for notebook storage, how I designed the schema, and the tradeoffs involved in storing pinned query results as JSON columns."
|
||||
tags: sqlite, electron, architecture, typescript
|
||||
series: "Building data-peek"
|
||||
cover_image:
|
||||
---
|
||||
|
||||
# Designing Local-First Storage for SQL Notebooks with SQLite
|
||||
|
||||
data-peek already uses SQLite for connection configs, query history, and saved queries — all managed through `better-sqlite3` in the Electron main process. When I added notebooks, I had a choice: extend the existing database, or give notebooks their own file.
|
||||
|
||||
I went with a separate file: `notebooks.db`. Here's the full reasoning, and the design decisions that followed.
|
||||
|
||||
## Why Not electron-store?
|
||||
|
||||
The simple path would have been `electron-store`, which is what I use for most app settings. It persists JSON to disk, handles serialization automatically, and has a clean API. For most features it's fine.
|
||||
|
||||
Notebooks don't fit the JSON file model for three reasons:
|
||||
|
||||
**Relational data.** A notebook has many cells, and operations on cells need to not clobber the whole notebook. With JSON files, every cell edit rewrites the entire notebook object. With SQLite, `UPDATE notebook_cells SET content = ? WHERE id = ?` touches one row.
|
||||
|
||||
**Large pinned results.** A pinned query result can be thousands of rows. Storing that inside a JSON file means deserializing the entire notebook on load just to render the title. With SQLite, pinned results are a column in `notebook_cells` — they're fetched only when needed.
|
||||
|
||||
**Individual cell writes.** The auto-save pattern I wanted — debounced 500ms writes per cell — doesn't compose well with a JSON file. If two cells are being edited simultaneously (or close in time), the writes race. With SQLite and individual `UPDATE` statements, concurrent cell writes are serialized by the database without data loss.
|
||||
|
||||
## The Schema
|
||||
|
||||
Two tables, intentionally simple:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS notebooks (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
connection_id TEXT NOT NULL,
|
||||
folder TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notebook_cells (
|
||||
id TEXT PRIMARY KEY,
|
||||
notebook_id TEXT NOT NULL REFERENCES notebooks(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL CHECK(type IN ('sql', 'markdown')),
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
pinned_result TEXT,
|
||||
order_index INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notebook_cells_notebook_id
|
||||
ON notebook_cells(notebook_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notebooks_updated_at
|
||||
ON notebooks(updated_at DESC);
|
||||
```
|
||||
|
||||
A few decisions worth explaining:
|
||||
|
||||
**IDs as TEXT UUIDs.** I'm using `randomUUID()` from Node's built-in `crypto` module. This keeps IDs stable across duplication and future sync scenarios — integer auto-increment IDs would collide when merging notebooks from two machines.
|
||||
|
||||
**`order_index` as a float.** Not explicitly in the schema (it's `INTEGER DEFAULT 0`), but in practice I use fractional values to insert cells between existing ones without renumbering. When you insert after index 3 and before index 4, the new cell gets `order = 3.5`. This avoids a full reindex on every insertion. The `reorderCells` operation normalizes all indices back to integers using a transaction.
|
||||
|
||||
**`updated_at` on notebooks.** This gets bumped by `touchNotebook()` on every cell mutation. The notebook list is sorted by `updated_at DESC`, so recently-edited notebooks float to the top without reading cell data.
|
||||
|
||||
**`ON DELETE CASCADE`.** Deleting a notebook deletes all its cells atomically. I chose the database constraint over application-level cleanup to prevent orphaned cells if the app crashes mid-delete.
|
||||
|
||||
## Pinned Results as JSON
|
||||
|
||||
The `pinned_result` column is `TEXT` — a serialized JSON blob. The `PinnedResult` type:
|
||||
|
||||
```typescript
|
||||
interface PinnedResult {
|
||||
columns: string[]
|
||||
rows: unknown[][]
|
||||
rowCount: number
|
||||
executedAt: number
|
||||
durationMs: number
|
||||
error: string | null
|
||||
}
|
||||
```
|
||||
|
||||
I considered a separate `pinned_results` table. The argument for it: you could index by `executed_at`, store large result sets more efficiently with compression, and keep the cells table lean.
|
||||
|
||||
I went with the JSON column for three reasons:
|
||||
|
||||
1. **Query simplicity.** `SELECT * FROM notebook_cells WHERE notebook_id = ?` gives me everything in one query. A join would add complexity without any practical benefit for the access patterns I have.
|
||||
|
||||
2. **Notebook portability.** The `.dpnb` export format includes pinned results inline. Having them in a separate table would require a join on export, and the mismatch between storage shape and export shape would create more conversion code.
|
||||
|
||||
3. **Result sets are bounded.** The cell renders at most 100 rows in the UI (`MAX_DISPLAY_ROWS = 100`). The export Markdown table caps at 50. So while pinned results can hold more, the practical size is bounded.
|
||||
|
||||
The tradeoff: if `pinned_result` JSON is corrupt (truncated write, manual editing), parsing fails silently and returns null. The `parsePinnedResult` function handles this:
|
||||
|
||||
```typescript
|
||||
function parsePinnedResult(raw: string): PinnedResult | null {
|
||||
try {
|
||||
return JSON.parse(raw) as PinnedResult
|
||||
} catch {
|
||||
log.warn('Corrupt pinned_result JSON, falling back to null')
|
||||
return null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The notebook still opens and works — you just lose the pinned result. Given that pinned results are non-critical (you can always re-run the query), silent fallback is the right behavior.
|
||||
|
||||
## Transactions for Reorder and Duplicate
|
||||
|
||||
Two operations require transactions: cell reordering and notebook duplication.
|
||||
|
||||
**Reordering** updates `order_index` for every cell in a notebook. Without a transaction, a crash midway through leaves cells with inconsistent ordering. The transaction wraps the entire batch update:
|
||||
|
||||
```typescript
|
||||
reorderCells(notebookId: string, orderedIds: string[]): void {
|
||||
const reorderInTransaction = this.db.transaction(() => {
|
||||
for (let i = 0; i < orderedIds.length; i++) {
|
||||
this.db
|
||||
.prepare('UPDATE notebook_cells SET order_index = ? WHERE id = ? AND notebook_id = ?')
|
||||
.run(i, orderedIds[i], notebookId)
|
||||
}
|
||||
this.touchNotebook(notebookId)
|
||||
})
|
||||
reorderInTransaction()
|
||||
}
|
||||
```
|
||||
|
||||
**Duplication** creates a new notebook row and copies every cell row atomically:
|
||||
|
||||
```typescript
|
||||
const duplicateInTransaction = this.db.transaction(() => {
|
||||
const newNotebookId = randomUUID()
|
||||
// INSERT new notebook...
|
||||
for (const cell of original.cells) {
|
||||
const newCellId = randomUUID()
|
||||
// INSERT new cell with new IDs...
|
||||
}
|
||||
return newNotebookId
|
||||
})
|
||||
const newId = duplicateInTransaction()
|
||||
```
|
||||
|
||||
`better-sqlite3` exposes `db.transaction()` as a higher-order function that wraps any function in an implicit `BEGIN`/`COMMIT` with automatic rollback on throw. It's synchronous (no `await`), which matches the rest of `better-sqlite3`'s API style.
|
||||
|
||||
## WAL Mode
|
||||
|
||||
The database is opened with `PRAGMA journal_mode = WAL`. WAL (Write-Ahead Logging) allows readers to proceed concurrently with a writer without blocking. In Electron, this matters because:
|
||||
|
||||
The main process handles all database writes via IPC. When a user is editing a cell (generating 500ms debounced writes), and simultaneously the app is loading schema data or running a query in another tab, the concurrent access is safe. In default journal mode, writes would acquire an exclusive lock that blocks all readers.
|
||||
|
||||
```typescript
|
||||
constructor(userDataPath: string) {
|
||||
const dbPath = join(userDataPath, 'notebooks.db')
|
||||
this.db = new Database(dbPath)
|
||||
this.db.pragma('journal_mode = WAL')
|
||||
this.db.pragma('foreign_keys = ON')
|
||||
this.init()
|
||||
}
|
||||
```
|
||||
|
||||
I also enable `foreign_keys = ON` explicitly. SQLite doesn't enforce foreign keys by default — you have to opt in per connection. With it enabled, deleting a notebook cascades to its cells at the database level.
|
||||
|
||||
## The Thin Tab + Rich Store Pattern
|
||||
|
||||
The tab system in data-peek stores minimal state per tab. A notebook tab holds only a `notebookId` and a `connectionId`:
|
||||
|
||||
```typescript
|
||||
interface NotebookTab {
|
||||
id: string
|
||||
type: 'notebook'
|
||||
notebookId: string
|
||||
connectionId: string | null
|
||||
label: string
|
||||
}
|
||||
```
|
||||
|
||||
When the tab renders, it calls `loadNotebook(notebookId)` which fetches the full `NotebookWithCells` from IPC and drops it into the Zustand store. All cell state, loading state, and save state live in `notebook-store.ts`.
|
||||
|
||||
This keeps the tab serialization small (tabs are persisted to electron-store for session restore), and means multiple tabs could theoretically share the same store state — though in practice you'd only have one notebook open at a time per ID.
|
||||
|
||||
## How Auto-Save Works
|
||||
|
||||
The auto-save flow is split into two operations: an optimistic in-memory update and a debounced IPC write.
|
||||
|
||||
When a user types in a cell:
|
||||
|
||||
1. `updateCellContent(cellId, content)` fires immediately — a synchronous Zustand mutation that updates the in-memory cell. The UI reflects the change instantly.
|
||||
|
||||
2. A 500ms debounce timer is started. If the user keeps typing, the timer resets.
|
||||
|
||||
3. When the timer fires, `flushCellContent(cellId, content)` calls `window.api.notebooks.updateCell` via IPC. The main process runs `UPDATE notebook_cells SET content = ? WHERE id = ?`.
|
||||
|
||||
4. On success, `lastSavedAt` is updated in the store. The header shows "Saved 3s ago".
|
||||
|
||||
```typescript
|
||||
const handleContentChange = useCallback(
|
||||
(value: string) => {
|
||||
updateCellContent(cell.id, value)
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => {
|
||||
flushCellContent(cell.id, value)
|
||||
}, 500)
|
||||
},
|
||||
[cell.id, updateCellContent, flushCellContent]
|
||||
)
|
||||
```
|
||||
|
||||
The debounce ref is per-cell (stored on the cell component via `useRef`), so editing multiple cells in quick succession doesn't collapse into a single write — each cell manages its own timer.
|
||||
|
||||
## The Export Format
|
||||
|
||||
The `.dpnb` format is designed to be portable and git-friendly:
|
||||
|
||||
```typescript
|
||||
export function exportAsDpnb(notebook: NotebookWithCells): string {
|
||||
const file: DpnbFile = {
|
||||
version: 1,
|
||||
title: notebook.title,
|
||||
folder: notebook.folder,
|
||||
cells: notebook.cells.map((cell) => ({
|
||||
type: cell.type,
|
||||
content: cell.content,
|
||||
...(cell.pinnedResult !== null ? { pinnedResult: cell.pinnedResult } : {})
|
||||
}))
|
||||
}
|
||||
return JSON.stringify(file, null, 2)
|
||||
}
|
||||
```
|
||||
|
||||
The `version: 1` field is there for forward compatibility. If I change the format, the importer can detect which version it's reading and apply a migration.
|
||||
|
||||
Connection information is intentionally excluded from exports. A `.dpnb` file contains queries and documentation, not credentials. When you import a notebook, you choose which connection to bind it to.
|
||||
|
||||
The Markdown export is a second format for sharing outside data-peek. It renders pinned results as pipe tables:
|
||||
|
||||
```
|
||||
# Daily Checks
|
||||
|
||||
## Active Users
|
||||
|
||||
```sql
|
||||
SELECT COUNT(*) FROM events WHERE ...
|
||||
```
|
||||
|
||||
| count |
|
||||
| --- |
|
||||
| 1234 |
|
||||
|
||||
*Last run: 2024-01-01T00:00:00.000Z (12ms, 1 rows)*
|
||||
```
|
||||
|
||||
This makes notebooks useful even for people who don't have data-peek installed.
|
||||
297
docs/blog/13-lazy-monaco-cells.md
Normal file
297
docs/blog/13-lazy-monaco-cells.md
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
---
|
||||
title: "Lazy-Loading Monaco Editor in a Cell-Based Notebook UI"
|
||||
published: false
|
||||
description: "How I solved the performance problem of multiple Monaco editor instances in a notebook UI by only mounting the editor on the focused cell."
|
||||
tags: react, monaco, performance, typescript
|
||||
series: "Building data-peek"
|
||||
cover_image:
|
||||
---
|
||||
|
||||
# Lazy-Loading Monaco Editor in a Cell-Based Notebook UI
|
||||
|
||||
The first version of the notebook UI mounted a Monaco editor for every SQL cell. It worked for 3 cells. With 10 cells it was sluggish. With 20 it was genuinely bad — slow initial render, janky scrolling, and memory usage that climbed noticeably on older machines.
|
||||
|
||||
Monaco is a full code editor. It initializes a worker thread for language services, builds a virtual DOM for the editor viewport, and manages its own event loop. One instance is fine. Twenty concurrent instances is not.
|
||||
|
||||
I needed a different approach.
|
||||
|
||||
## The Solution: One Live Editor at a Time
|
||||
|
||||
The core insight is that you can only type in one cell at a time. A notebook might have 30 cells, but only the focused cell needs a live Monaco instance. Every other cell just needs to show its content.
|
||||
|
||||
The implementation splits each SQL cell into two states:
|
||||
|
||||
- **Focused and editing**: a `<textarea>` (or Monaco for future enhancement) handles input
|
||||
- **Unfocused**: a static `<pre>` element renders the content
|
||||
|
||||
This is simpler than it sounds, but there are a few subtleties in how focus, state, and keyboard navigation interact.
|
||||
|
||||
## Cell Focus Management
|
||||
|
||||
Focus state is managed in the parent `NotebookEditor` component, not in individual cells. A single `focusedCellIndex` integer tracks which cell is active:
|
||||
|
||||
```typescript
|
||||
export function NotebookEditor({ tab }: NotebookEditorProps) {
|
||||
const [focusedCellIndex, setFocusedCellIndex] = useState<number>(0)
|
||||
// ...
|
||||
return (
|
||||
<>
|
||||
{cells.map((cell, index) => (
|
||||
<NotebookCell
|
||||
key={cell.id}
|
||||
cell={cell}
|
||||
isFocused={focusedCellIndex === index}
|
||||
onFocus={() => setFocusedCellIndex(index)}
|
||||
// ...
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Each `NotebookCell` receives `isFocused: boolean` as a prop. When `isFocused` is false, the cell renders its content as a static `<pre>` (for SQL) or rendered Markdown (for Markdown cells). When `isFocused` becomes true, the cell can enter edit mode.
|
||||
|
||||
There's a secondary bit of state inside each cell: `isEditing`. Focusing a cell doesn't immediately activate the editor — you have to click or press Enter to start typing. This matches Jupyter's modal behavior: a cell can be "selected" (focused) without being in "edit mode."
|
||||
|
||||
```typescript
|
||||
const NotebookCell = memo(function NotebookCell({ cell, isFocused, onFocus, ... }) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFocused) {
|
||||
setIsEditing(false)
|
||||
}
|
||||
}, [isFocused])
|
||||
|
||||
// SQL cell render logic:
|
||||
// isEditing && isFocused → textarea (live editor)
|
||||
// otherwise → <pre> (static view)
|
||||
})
|
||||
```
|
||||
|
||||
The `useEffect` on `isFocused` is important: when another cell takes focus, this cell exits edit mode automatically. Without this, you could have a cell stuck in edit mode after focus moved elsewhere.
|
||||
|
||||
## The Static Pre Element
|
||||
|
||||
When a cell isn't being edited, it renders as a `<pre>` with a click handler:
|
||||
|
||||
```typescript
|
||||
<pre
|
||||
className={cn(
|
||||
'font-mono text-sm text-foreground whitespace-pre-wrap break-all cursor-text min-h-[1.5rem]',
|
||||
!cell.content && 'text-muted-foreground/40 italic'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onFocus()
|
||||
setIsEditing(true)
|
||||
}}
|
||||
>
|
||||
{cell.content || 'Click to edit SQL…'}
|
||||
</pre>
|
||||
```
|
||||
|
||||
The `cursor-text` class signals that this is editable. Clicking it calls `onFocus()` (updates the parent's `focusedCellIndex`) and sets `isEditing(true)` locally. The two calls happen synchronously in the same event handler, so there's no visible flash.
|
||||
|
||||
For Markdown cells, the unfocused state renders through `ReactMarkdown` instead of `<pre>`. The click handler is on the outer `div`:
|
||||
|
||||
```typescript
|
||||
<div
|
||||
className="prose prose-sm dark:prose-invert max-w-none cursor-text"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onFocus()
|
||||
setIsEditing(true)
|
||||
}}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{cell.content}</ReactMarkdown>
|
||||
</div>
|
||||
```
|
||||
|
||||
## The Keyboard Model
|
||||
|
||||
The notebook has two layers of keyboard handling:
|
||||
|
||||
**Cell-level (inside a textarea):**
|
||||
|
||||
```typescript
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setIsEditing(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && e.shiftKey) {
|
||||
e.preventDefault()
|
||||
runQuery().then(() => onRunAndAdvance())
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
runQuery()
|
||||
return
|
||||
}
|
||||
},
|
||||
[runQuery, onRunAndAdvance]
|
||||
)
|
||||
```
|
||||
|
||||
`Shift+Enter` runs the query and calls `onRunAndAdvance`, which increments `focusedCellIndex` in the parent. This moves focus to the next cell automatically — the Jupyter "run and go to next" UX.
|
||||
|
||||
`Cmd+Enter` runs the query without moving focus — useful when you're iterating on a single query.
|
||||
|
||||
`Escape` exits edit mode, leaving the cell focused but not editing. This is the Jupyter "command mode" equivalent.
|
||||
|
||||
**Notebook-level (global keydown listener):**
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const mod = e.metaKey || e.ctrlKey
|
||||
if (!mod) return
|
||||
|
||||
if (e.key === 'j') {
|
||||
e.preventDefault()
|
||||
setFocusedCellIndex((prev) => Math.min(prev + 1, cells.length - 1))
|
||||
} else if (e.key === 'k') {
|
||||
e.preventDefault()
|
||||
setFocusedCellIndex((prev) => Math.max(prev - 1, 0))
|
||||
} else if (e.shiftKey && e.key === 'D') {
|
||||
e.preventDefault()
|
||||
const cell = cells[focusedCellIndex]
|
||||
if (cell) handleDeleteCell(cell.id, focusedCellIndex)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [cells, focusedCellIndex, handleDeleteCell])
|
||||
```
|
||||
|
||||
`Cmd+J` and `Cmd+K` navigate between cells. I chose J/K over arrow keys because arrow keys inside a textarea move the cursor, and intercepting them would require tracking cursor position. J/K only fires with the modifier, so they don't interfere with typing.
|
||||
|
||||
The global listener has a closure over `cells` and `focusedCellIndex`, so it needs to be re-registered when those change. The cleanup function (`removeEventListener`) ensures there's never more than one active listener.
|
||||
|
||||
## Between-Cell Insert Points
|
||||
|
||||
One UX problem with a cell list: how do you insert a cell between two existing cells? A button at the bottom only inserts at the end. A button on each cell is visually noisy.
|
||||
|
||||
I borrowed the pattern from Notion: insert points appear on hover between cells. They're invisible by default and expand on mouse enter:
|
||||
|
||||
```typescript
|
||||
function InsertPoint({ onInsert }: InsertPointProps) {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 py-0.5 transition-all duration-150',
|
||||
isHovered ? 'opacity-100' : 'opacity-0 hover:opacity-100'
|
||||
)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="flex-1 h-px bg-border/40" />
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => onInsert('sql')}>+ SQL</button>
|
||||
<button onClick={() => onInsert('markdown')}>+ Note</button>
|
||||
</div>
|
||||
<div className="flex-1 h-px bg-border/40" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
An `InsertPoint` appears before the first cell and after every cell. The `handleAddCell` function in the editor uses the `insertAfterIndex` parameter to calculate `order`:
|
||||
|
||||
```typescript
|
||||
const handleAddCell = useCallback(
|
||||
(type: 'sql' | 'markdown', insertAfterIndex?: number) => {
|
||||
if (!activeNotebook) return
|
||||
const order =
|
||||
insertAfterIndex !== undefined && cells[insertAfterIndex]
|
||||
? cells[insertAfterIndex].order + 0.5
|
||||
: cells.length
|
||||
addCell(activeNotebook.id, { type, content: '', order })
|
||||
setFocusedCellIndex(insertAfterIndex !== undefined ? insertAfterIndex + 1 : cells.length)
|
||||
},
|
||||
[activeNotebook, cells, addCell]
|
||||
)
|
||||
```
|
||||
|
||||
The fractional `order` value (`existingOrder + 0.5`) lets new cells land between existing ones without renumbering everything. The next `reorderCells` call (e.g. on drag-drop) normalizes all indices back to integers.
|
||||
|
||||
## Auto-Save Integration
|
||||
|
||||
The debounced auto-save runs inside each cell's `handleContentChange`:
|
||||
|
||||
```typescript
|
||||
const handleContentChange = useCallback(
|
||||
(value: string) => {
|
||||
updateCellContent(cell.id, value)
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => {
|
||||
flushCellContent(cell.id, value)
|
||||
}, 500)
|
||||
},
|
||||
[cell.id, updateCellContent, flushCellContent]
|
||||
)
|
||||
```
|
||||
|
||||
`updateCellContent` is a synchronous Zustand mutation — the UI reflects the new content immediately. `flushCellContent` fires 500ms later via IPC and writes to SQLite. The `debounceRef` is local to the cell component via `useRef`, so each cell manages its own debounce timer independently.
|
||||
|
||||
One subtlety: the `useCallback` dependency array includes `cell.id`. If a cell is deleted and re-created with the same position but a new ID (which doesn't happen in the current implementation, but is a future risk), the callback will correctly use the new ID because the component re-mounts.
|
||||
|
||||
## Result Pinning UX
|
||||
|
||||
After running a SQL cell, a "Pin result" option appears in the cell's dropdown menu. Pinning captures the current `liveResult` into a `PinnedResult` object and persists it via IPC:
|
||||
|
||||
```typescript
|
||||
const handlePinResult = useCallback(() => {
|
||||
if (!liveResult) return
|
||||
const pinned: PinnedResult = {
|
||||
columns: liveResult.fields.map((f) => f.name),
|
||||
rows: liveResult.rows,
|
||||
rowCount: liveResult.rows.length,
|
||||
executedAt: Date.now(),
|
||||
durationMs: durationMs ?? 0,
|
||||
error: null
|
||||
}
|
||||
pinResult(cell.id, pinned)
|
||||
}, [cell.id, liveResult, durationMs, pinResult])
|
||||
```
|
||||
|
||||
The cell's result area follows this priority logic:
|
||||
|
||||
1. If there's a live result (just ran): show it
|
||||
2. Else if there's a pinned result: show it with a "Pinned" badge and timestamp
|
||||
3. Else if idle: show the "not yet executed" hint
|
||||
|
||||
This means re-running always shows fresh results, but the pinned result is always visible as a fallback. The "Unpin" option appears in the same menu to remove it.
|
||||
|
||||
## React.memo for Cell List Performance
|
||||
|
||||
Every cell is wrapped in `React.memo`:
|
||||
|
||||
```typescript
|
||||
export const NotebookCell = memo(function NotebookCell({ ... }) {
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
Without memo, every `focusedCellIndex` change would re-render all cells. With memo, cells only re-render when their own props change. The only prop that changes when you navigate between cells is `isFocused` — and that only changes on the two cells involved in the transition (the one losing focus and the one gaining it).
|
||||
|
||||
This makes Cmd+J/K navigation fast even with 30+ cells. The alternative — not using memo — would re-render the entire cell list on every keypress.
|
||||
|
||||
## What I'd Do Differently
|
||||
|
||||
The `textarea` approach works well but lacks SQL syntax highlighting in edit mode. The original goal was to use Monaco for the focused cell and `<pre>` for everything else — one live Monaco instance, swapped between cells on focus. That's still the right architectural direction.
|
||||
|
||||
The challenge is that Monaco requires a DOM container with stable dimensions to initialize correctly. If the container isn't visible yet when `monaco.create()` is called, the editor renders incorrectly. Solving this requires either prerendering the container (hidden, not unmounted) or carefully sequencing the focus transition before the Monaco initialization.
|
||||
|
||||
For now, the `textarea` approach is good enough — it's lightweight, has no initialization cost, and supports all the keyboard shortcuts. If syntax highlighting becomes a frequent request, the architecture already supports it: swap the `textarea` for a lazily-initialized Monaco instance on the focused cell, keep everything else as-is.
|
||||
92
docs/demo-script-notebooks.md
Normal file
92
docs/demo-script-notebooks.md
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# SQL Notebooks — Demo Script
|
||||
|
||||
Recording guide for the SQL Notebooks feature demo video.
|
||||
|
||||
## Setup
|
||||
|
||||
- Database: `localhost:5433` / `acme_saas` / `demo` / `demo` (Docker container `datapeek-demo`)
|
||||
- Start container: `docker start datapeek-demo`
|
||||
- Seed file: `seeds/acme_saas_seed.sql`
|
||||
- Demo runbook: `seeds/demo-runbook.dpnb` (import this or recreate live)
|
||||
|
||||
## Demo Flow (3-4 minutes)
|
||||
|
||||
### Scene 1: Create a Notebook (30s)
|
||||
|
||||
1. Open data-peek, connect to the ACME SaaS database
|
||||
2. In the sidebar, show the **Notebooks** section
|
||||
3. Click **+** to create a new notebook
|
||||
4. Title it "ACME SaaS Health Check"
|
||||
5. Show it opens as a new tab
|
||||
|
||||
### Scene 2: Add Markdown + SQL Cells (60s)
|
||||
|
||||
1. The notebook starts empty — click **+ Markdown cell**
|
||||
2. Type: `# Platform Overview` and a short description
|
||||
3. Click away — show it renders as formatted markdown
|
||||
4. Click **+ SQL cell**
|
||||
5. Type the platform overview query:
|
||||
```sql
|
||||
SELECT o.plan, COUNT(DISTINCT o.id) AS orgs,
|
||||
COUNT(DISTINCT m.user_id) AS users
|
||||
FROM organizations o
|
||||
LEFT JOIN memberships m ON m.organization_id = o.id
|
||||
GROUP BY o.plan ORDER BY orgs DESC
|
||||
```
|
||||
6. Press **Shift+Enter** — results appear inline, focus moves to next position
|
||||
7. Add another markdown cell: "## Revenue Health" with explanation
|
||||
8. Add another SQL cell with the subscription query
|
||||
9. **Shift+Enter** through it — show the run-and-advance flow
|
||||
|
||||
### Scene 3: Pin Results (30s)
|
||||
|
||||
1. On the platform overview cell, click the **...** menu
|
||||
2. Click **Pin result**
|
||||
3. Show the "Pinned — ran [date]" header appears
|
||||
4. Close the notebook tab
|
||||
5. Reopen from sidebar — pinned result is still there
|
||||
|
||||
### Scene 4: Keyboard Navigation (30s)
|
||||
|
||||
1. Press **Cmd+J** / **Cmd+K** — show focus moving between cells
|
||||
2. Press **Enter** on a SQL cell — enters edit mode
|
||||
3. Press **Escape** — exits back to cell navigation
|
||||
4. Press **Cmd+Shift+D** — deletes a cell
|
||||
5. Show the bottom shortcut bar
|
||||
|
||||
### Scene 5: Import the Full Runbook (30s)
|
||||
|
||||
1. Delete the notebook you just made (or create a new one)
|
||||
2. Import `seeds/demo-runbook.dpnb` (File → Import or drag to sidebar)
|
||||
3. Show the full 7-step health check runbook loads
|
||||
4. **Cmd+Shift+Enter** (Run All) — watch the execution wave flow through each cell
|
||||
5. Show results appearing one by one
|
||||
|
||||
### Scene 6: Export & Share (30s)
|
||||
|
||||
1. From the toolbar **...** menu, click **Export as Markdown**
|
||||
2. Open the exported `.md` file — show it's readable with SQL blocks and result tables
|
||||
3. Mention: "Send this to a teammate, they can read it without data-peek"
|
||||
4. Show **Export as .dpnb** — "Or send this, and they can import it and run it themselves"
|
||||
|
||||
### Scene 7: Duplicate to Connection (15s)
|
||||
|
||||
1. Right-click the notebook in the sidebar
|
||||
2. Click **Duplicate to connection...**
|
||||
3. Pick a different connection (e.g. staging)
|
||||
4. Show the copy appears with "(copy)" suffix
|
||||
|
||||
## Key Moments to Highlight
|
||||
|
||||
- **Shift+Enter flow** — the Jupyter muscle memory, run-and-advance
|
||||
- **Pinned results persisting** — close and reopen, data is still there
|
||||
- **The runbook use case** — "your Notion doc + SQL client, collapsed into one"
|
||||
- **Export as Markdown** — readable anywhere, no vendor lock-in
|
||||
|
||||
## Recording Tips
|
||||
|
||||
- Use dark mode (primary design target)
|
||||
- Keep the sidebar open to show the Notebooks section
|
||||
- Zoom to ~125% so cell content is readable in the video
|
||||
- No narration needed if adding captions — keep it tight
|
||||
- Target 1080p, 60fps for smooth scroll/transitions
|
||||
118
docs/release-notes-v0.20.0.md
Normal file
118
docs/release-notes-v0.20.0.md
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
# Release Notes: v0.20.0
|
||||
|
||||
This document covers the changes from v0.19.0 to v0.20.0.
|
||||
|
||||
---
|
||||
|
||||
## SQL Notebooks
|
||||
|
||||
SQL Notebooks is a major new feature that brings a Jupyter-style cell-based editor to data-peek. Create documents that mix executable SQL cells and Markdown cells in a single view — the primary use case is team runbooks, like a shared "how to debug stuck payments" guide that actually runs the queries.
|
||||
|
||||
---
|
||||
|
||||
## What's New
|
||||
|
||||
### SQL + Markdown Cells
|
||||
|
||||
Notebooks are composed of two cell types:
|
||||
|
||||
- **SQL cells** — a Monaco editor with inline results displayed directly below the query. Results show row count, execution duration, and a full scrollable result table.
|
||||
- **Markdown cells** — rich text with GitHub Flavored Markdown (tables, task lists, strikethrough). Click to edit, click away to render.
|
||||
|
||||
Add cells from the toolbar or by hovering between existing cells — a `+` button appears on the dividing line and lets you pick the type.
|
||||
|
||||
### Pinned Results
|
||||
|
||||
Run a diagnostic query and pin the output. Pinned results persist across sessions in SQLite, so when you reopen a notebook the last known state is always visible — useful for runbooks that track a baseline or show an example of what "broken" looks like.
|
||||
|
||||
Pin via the cell overflow menu. An optional notebook-level "Auto-pin results" setting pins every successful run automatically.
|
||||
|
||||
### Folders for Organization
|
||||
|
||||
Group notebooks into folders from the sidebar. Folders are lightweight — they exist implicitly when any notebook references them. No separate folder management; remove the last notebook from a folder and the folder disappears.
|
||||
|
||||
### Export
|
||||
|
||||
- **Export as `.dpnb`** — a portable JSON format that includes all cells and pinned results. Reimportable into any data-peek instance. Connection IDs are stripped on export; you pick which connection to bind on import.
|
||||
- **Export as Markdown** — SQL cells become fenced `sql` code blocks, pinned results become Markdown tables, Markdown cells export as-is. Readable without data-peek.
|
||||
|
||||
Import `.dpnb` files from File → Import Notebook, or drag a `.dpnb` file onto the sidebar.
|
||||
|
||||
### Duplicate to Connection
|
||||
|
||||
Right-click a notebook in the sidebar → "Duplicate to connection..." — pick any of your saved connections. Creates a full copy with all cells and pinned results bound to the new connection. The original is unchanged. Useful for running a production runbook against staging.
|
||||
|
||||
### Sidebar Integration
|
||||
|
||||
A "Notebooks" section appears below Saved Queries in the sidebar. Each entry shows the notebook title, connected database name, and last-edited timestamp. The existing sidebar search filters notebooks alongside tables and saved queries.
|
||||
|
||||
---
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
|---|---|
|
||||
| `Shift+Enter` | Run cell and move focus to next cell |
|
||||
| `Cmd+Enter` | Run cell, stay in place |
|
||||
| `Cmd+Shift+Enter` | Run All (top to bottom, sequential) |
|
||||
| `Cmd+J` | Move focus to next cell |
|
||||
| `Cmd+K` | Move focus to previous cell |
|
||||
| `Enter` | Enter editor mode on focused cell |
|
||||
| `Escape` | Exit editor, return to cell-level navigation |
|
||||
| `Cmd+Shift+D` | Delete focused cell |
|
||||
|
||||
Execution stops on the first error — if a diagnostic SELECT fails, the UPDATE cells below won't run.
|
||||
|
||||
---
|
||||
|
||||
## How to Use
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. Open the Notebooks section in the sidebar and click `+` to create a notebook.
|
||||
2. Give it a title. The notebook binds to your active connection.
|
||||
3. Add a Markdown cell to describe what the notebook does.
|
||||
4. Add a SQL cell and write your query.
|
||||
5. Press `Shift+Enter` to run and advance — or `Cmd+Enter` to run in place.
|
||||
6. Pin a result via the cell `...` menu if you want it to persist across sessions.
|
||||
|
||||
### Building a Runbook
|
||||
|
||||
A typical runbook pattern:
|
||||
|
||||
1. Markdown cell — context and prerequisites ("Run this when payments stop processing")
|
||||
2. SQL cell — diagnostic query ("Show all payments stuck in `processing` state")
|
||||
3. Markdown cell — what to look for in the results
|
||||
4. SQL cell — follow-up query or remediation step
|
||||
|
||||
Export as `.dpnb` and share with your team. They import it, pick their connection, and the whole runbook is ready to run.
|
||||
|
||||
### Running on Multiple Environments
|
||||
|
||||
Right-click a notebook → "Duplicate to connection..." to create a version bound to your staging or dev database. Run the same queries against different environments without maintaining separate notebooks.
|
||||
|
||||
---
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- Cell content auto-saves with a 500ms debounce — no save button needed. The toolbar shows "Saved [time ago]".
|
||||
- Only the focused cell loads a Monaco instance. Unfocused cells render as static `<pre>` elements — notebooks with many cells stay lightweight.
|
||||
- Result tables use the same virtualized rendering as query tabs. Large result sets don't slow down the notebook.
|
||||
- Pinned results are capped at 500 rows per cell.
|
||||
- Run All executes SQL cells sequentially and skips Markdown cells.
|
||||
|
||||
---
|
||||
|
||||
## Stats Summary
|
||||
|
||||
- New tab type: `notebook`
|
||||
- New IPC namespace: `window.api.notebooks`
|
||||
- New SQLite tables: `notebooks`, `notebook_cells`
|
||||
- New stores: `notebook-store.ts`
|
||||
- New components: `NotebookTab`, `NotebookCell`, `NotebookSidebar`
|
||||
|
||||
---
|
||||
|
||||
## Upgrade
|
||||
|
||||
Download the latest release from the [releases page](https://github.com/Rohithgilla12/data-peek/releases) or the app will auto-update if you have v0.19.x installed.
|
||||
308
docs/social-posts-notebooks.md
Normal file
308
docs/social-posts-notebooks.md
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
# Social Posts — SQL Notebooks (v0.20.0)
|
||||
|
||||
Ready-to-post content for the SQL Notebooks launch.
|
||||
|
||||
---
|
||||
|
||||
## Twitter/X
|
||||
|
||||
### Post 1 — Launch Announcement
|
||||
|
||||
```
|
||||
data-peek v0.20.0: SQL Notebooks.
|
||||
|
||||
Mix SQL cells and Markdown in a single document. Run queries inline. Pin results so they survive restarts.
|
||||
|
||||
The main use case: team runbooks. "Here's how to debug stuck payments" — with the actual queries that run against your actual database.
|
||||
|
||||
→ Export as .dpnb (reimportable) or Markdown (readable anywhere)
|
||||
→ Duplicate a notebook to a different connection (dev → staging → prod)
|
||||
→ Folders for organization
|
||||
→ Jupyter shortcuts: Shift+Enter, Cmd+J/K
|
||||
|
||||
datapeek.dev
|
||||
```
|
||||
|
||||
### Post 2 — "Here's what it looks like in practice"
|
||||
|
||||
```
|
||||
Here's what a SQL notebook looks like in practice:
|
||||
|
||||
Markdown cell: "Run this when payments stop processing. Check for rows stuck in `processing` state for more than 5 minutes."
|
||||
|
||||
SQL cell:
|
||||
SELECT id, amount, status, created_at
|
||||
FROM payments
|
||||
WHERE status = 'processing'
|
||||
AND created_at < NOW() - INTERVAL '5 minutes'
|
||||
|
||||
Pin the result. Next time you open the notebook, the last known baseline is there.
|
||||
|
||||
Export it as a .dpnb file. Your teammate imports it, picks their connection, and the whole runbook is ready to go.
|
||||
|
||||
That's what SQL Notebooks in data-peek does — datapeek.dev
|
||||
```
|
||||
|
||||
### Post 3 — Developer Tip (Keyboard Shortcuts + Pinning)
|
||||
|
||||
```
|
||||
Some things I learned while building SQL Notebooks for data-peek:
|
||||
|
||||
Shift+Enter is the right default. Run and advance — same as Jupyter. You don't think, you just run.
|
||||
|
||||
Cmd+J / Cmd+K to move between cells without reaching for the mouse. The notebook starts feeling like the terminal.
|
||||
|
||||
Pinned results are underrated. Run a query on prod, pin the output, and now every teammate who opens the notebook can see what "normal" looked like — before they run it again.
|
||||
|
||||
Cmd+Shift+Enter runs everything top to bottom. Stops on first error so you don't accidentally run an UPDATE if the diagnostic SELECT above failed.
|
||||
|
||||
datapeek.dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reddit
|
||||
|
||||
### r/SQL or r/database — "I added Jupyter-style notebooks to my SQL client"
|
||||
|
||||
**Title:** `I added Jupyter-style notebooks to my SQL client (data-peek)`
|
||||
|
||||
```
|
||||
I've been building data-peek — a SQL client for developers who find DBeaver too heavy and DataGrip too expensive for quick work. Today I'm shipping v0.20.0: SQL Notebooks.
|
||||
|
||||
**What they are**
|
||||
|
||||
Notebooks mix SQL cells and Markdown cells in a single document. SQL cells execute inline and show results directly below the query. Markdown cells render documentation, notes, and instructions. It's the same concept as Jupyter, but wired to your database rather than Python.
|
||||
|
||||
**Why I built this**
|
||||
|
||||
The thing I kept running into: I'd debug a tricky production issue, figure it out, and then write a Notion doc explaining what to do next time. The doc had SQL in code blocks that you had to copy somewhere else to run. That's friction. A notebook collapses the documentation and the execution into the same place.
|
||||
|
||||
**The features that I think matter**
|
||||
|
||||
- **Pinned results** — after running a query, pin the output. It persists across sessions. Useful for runbooks that show "here's what the data looks like when this is broken."
|
||||
- **Duplicate to connection** — right-click a notebook, duplicate it to a different connection. Run the same runbook against dev and staging without maintaining two copies.
|
||||
- **Export as .dpnb or Markdown** — .dpnb is a JSON format you can reimport; Markdown is a .md file you can read without data-peek.
|
||||
- **Folders** — organize notebooks into groups. Folders are implicit; no separate management UI.
|
||||
|
||||
**Keyboard shortcuts** (follow Jupyter conventions)
|
||||
|
||||
- `Shift+Enter` — run cell, advance focus
|
||||
- `Cmd+Enter` — run cell, stay in place
|
||||
- `Cmd+Shift+Enter` — run all (stops on first error)
|
||||
- `Cmd+J` / `Cmd+K` — move focus down / up
|
||||
|
||||
**Tech note** — only the focused cell loads Monaco. Unfocused cells render as static `<pre>`. Notebooks with 20+ cells don't become sluggish.
|
||||
|
||||
**Pricing** — free for personal use, $29 one-time for commercial.
|
||||
|
||||
Website: https://datapeek.dev
|
||||
GitHub: https://github.com/Rohithgilla12/data-peek
|
||||
|
||||
Happy to answer questions. What kinds of notebooks or runbooks would you actually use this for?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### r/programming — "SQL notebooks in a desktop client: technical implementation notes"
|
||||
|
||||
**Title:** `SQL notebooks in a desktop Electron app: some implementation notes`
|
||||
|
||||
```
|
||||
I just shipped SQL Notebooks for data-peek (Electron + React SQL client). A few decisions that might be interesting if you're building something similar.
|
||||
|
||||
**Cell architecture: thin tab, rich store**
|
||||
|
||||
The notebook tab holds only a `notebookId`. All cell state lives in a Zustand store backed by SQLite via IPC. Cell content auto-saves on a 500ms debounce — no explicit save button, toolbar shows "Saved 2m ago". This is the same pattern as saved queries in the app.
|
||||
|
||||
**Monaco only on focus**
|
||||
|
||||
Loading Monaco for every cell in a notebook would be expensive. Unfocused cells render as syntax-highlighted `<pre>` elements. On focus, Monaco replaces the `<pre>` lazily. This keeps notebooks with many cells fast.
|
||||
|
||||
**Pinned results in SQLite**
|
||||
|
||||
After executing a SQL cell, you can pin the result. It serializes to JSON and stores in a `notebook_cells.pinned_result` column (capped at 500 rows). On next load, the pinned result renders before any query is run. Useful for runbooks where you want to show the last known state.
|
||||
|
||||
**Run All stops on first error**
|
||||
|
||||
When you "Run All", SQL cells execute sequentially and Markdown cells are skipped. Execution stops on the first error. This is intentional — if a diagnostic SELECT fails, you don't want an UPDATE to run below it.
|
||||
|
||||
**Export formats**
|
||||
|
||||
`.dpnb` is a JSON file: version field, title, cells array. Connection IDs are stripped (they're machine-local); on import the user picks which connection to bind. Markdown export renders SQL cells as fenced code blocks and pinned results as Markdown tables.
|
||||
|
||||
**Result tables**
|
||||
|
||||
Same virtualized rendering as the main query tab — TanStack Virtual. Large result sets in notebooks don't cause DOM bloat.
|
||||
|
||||
Stack: Electron, React, TypeScript, Monaco, Zustand, better-sqlite3, TanStack Table + Virtual.
|
||||
|
||||
https://datapeek.dev | https://github.com/Rohithgilla12/data-peek
|
||||
|
||||
Curious if anyone's done something similar and ran into edge cases I haven't thought about yet.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dev.to / Blog Teaser
|
||||
|
||||
**Post title:** `SQL Notebooks in data-peek — mix queries and docs in one place`
|
||||
|
||||
```
|
||||
data-peek v0.20.0 ships SQL Notebooks: a Jupyter-style editor that mixes executable SQL cells and Markdown cells in a single document.
|
||||
|
||||
The idea came from a workflow problem. When debugging a production issue, you end up with two separate things: the Notion doc explaining what happened, and the SQL queries you run to diagnose it. The doc has code blocks you copy somewhere else to execute. That's unnecessary friction.
|
||||
|
||||
SQL Notebooks collapse them. Write the explanation in a Markdown cell. Put the actual query in a SQL cell right below it. Run it inline. Pin the result so it's there when your teammate opens the notebook.
|
||||
|
||||
Key things in this release:
|
||||
|
||||
- SQL + Markdown cells with inline results
|
||||
- Pinned results that persist across sessions
|
||||
- Export as `.dpnb` (reimportable JSON) or Markdown (plain .md, readable anywhere)
|
||||
- Duplicate a notebook to a different connection — same runbook, different environment
|
||||
- Jupyter keyboard shortcuts (Shift+Enter, Cmd+J/K, Cmd+Shift+Enter for Run All)
|
||||
- Folders for organization
|
||||
- Monaco lazy-loaded per cell — notebooks with many cells stay fast
|
||||
|
||||
I wrote about the implementation decisions in more depth on the blog: [link].
|
||||
|
||||
data-peek is free for personal use, $29 one-time for commercial. macOS, Windows, Linux.
|
||||
|
||||
datapeek.dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Threads
|
||||
|
||||
### Post 1 — Launch (carousel-style thread)
|
||||
|
||||
```
|
||||
SQL Notebooks in data-peek.
|
||||
|
||||
Mix SQL and Markdown in one document. Run queries inline. Pin results.
|
||||
|
||||
Your debugging runbooks become executable.
|
||||
```
|
||||
|
||||
Reply 1:
|
||||
```
|
||||
The use case that made me build this:
|
||||
|
||||
Every time I debug a production issue, I write a Notion doc with the steps. The doc has SQL in code blocks that you copy-paste into a query tool.
|
||||
|
||||
Why are those two things separate?
|
||||
|
||||
Now they're not. The documentation IS the query tool.
|
||||
```
|
||||
|
||||
Reply 2:
|
||||
```
|
||||
What "pinning" actually does:
|
||||
|
||||
Run a query. See results. Click "Pin."
|
||||
|
||||
Close the notebook. Reopen it next week. The pinned result is still there — showing what the data looked like last time.
|
||||
|
||||
Your runbook now has a baseline built in.
|
||||
```
|
||||
|
||||
Reply 3:
|
||||
```
|
||||
Same runbook, different database:
|
||||
|
||||
Right-click → Duplicate to connection.
|
||||
|
||||
One notebook for "debug stuck payments." Use it on dev. Use it on staging. Use it on prod.
|
||||
|
||||
No copy-paste. No maintaining three versions.
|
||||
```
|
||||
|
||||
Reply 4:
|
||||
```
|
||||
Keyboard shortcuts (Jupyter conventions):
|
||||
|
||||
Shift+Enter → run cell, move to next
|
||||
Cmd+Enter → run cell, stay
|
||||
Cmd+J/K → navigate between cells
|
||||
Cmd+Shift+Enter → run all (stops on first error)
|
||||
|
||||
It feels like a terminal, not a form.
|
||||
|
||||
datapeek.dev
|
||||
```
|
||||
|
||||
### Post 2 — Behind the scenes (technical)
|
||||
|
||||
```
|
||||
Building SQL Notebooks taught me a few things about Electron performance:
|
||||
|
||||
20 Monaco editors on one page = bad time.
|
||||
|
||||
Solution: only the focused cell gets a live Monaco instance. Everything else is a static <pre> with syntax highlighting. Click to activate.
|
||||
|
||||
Result: notebooks with 30+ cells scroll smoothly.
|
||||
```
|
||||
|
||||
Reply 1:
|
||||
```
|
||||
Storage decision: SQLite over JSON files.
|
||||
|
||||
Notebooks have cells. Cells have pinned results (could be 500 rows of data). Updating one cell shouldn't rewrite the entire notebook.
|
||||
|
||||
better-sqlite3 with WAL mode. Two tables. Foreign key cascades. Individual cell updates.
|
||||
```
|
||||
|
||||
Reply 2:
|
||||
```
|
||||
The export format is a JSON file called .dpnb
|
||||
|
||||
It strips the connection ID (that's machine-local). When your teammate imports it, they pick which database to connect.
|
||||
|
||||
Or export as Markdown — readable on GitHub, Notion, anywhere. SQL in fenced code blocks, pinned results as tables.
|
||||
```
|
||||
|
||||
### Post 3 — Runbook showcase
|
||||
|
||||
```
|
||||
Here's an actual runbook I use:
|
||||
|
||||
"ACME SaaS Health Check"
|
||||
|
||||
Step 1: Platform overview (orgs by plan)
|
||||
Step 2: Revenue health (subscription status + MRR)
|
||||
Step 3: Unpaid invoices
|
||||
Step 4: Recent activity (event log)
|
||||
Step 5: Top orgs by usage
|
||||
Step 6: Stale API keys
|
||||
Step 7: Enterprise deep dive
|
||||
|
||||
7 SQL cells, 7 Markdown cells explaining what to look for. Pin results as you go. Takes 30 seconds to run through.
|
||||
|
||||
This used to be a Notion doc + 7 browser tabs.
|
||||
```
|
||||
|
||||
### Post 4 — Hot take / conversation starter
|
||||
|
||||
```
|
||||
Hot take: most SQL clients are designed for DBAs, not developers.
|
||||
|
||||
Developers don't need server management panels. They need to quickly check data, run a query, and get back to their code.
|
||||
|
||||
That's why data-peek exists. And SQL Notebooks are the latest example — runbooks for developers, not database reports for managers.
|
||||
|
||||
datapeek.dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## General Notes
|
||||
|
||||
1. Post Reddit threads on weekday mornings (9–11am US Eastern). r/sql and r/database are lower-traffic; the technical angle in r/programming tends to do better there.
|
||||
2. Screenshots and a short screen recording of notebook execution will significantly improve engagement on Twitter/X and Threads.
|
||||
3. The r/programming post is technical by design — that audience responds better to implementation details than feature lists.
|
||||
4. Don't cross-post identical content across subreddits simultaneously.
|
||||
5. Engage with every reply in the first hour.
|
||||
6. **Threads strategy:** Post the carousel-style thread (Post 1) as the launch post. The technical post (Post 2) can go out 2-3 days later to stay in feeds without flooding. The runbook showcase (Post 3) works well as a weekend post. Post 4 is a standalone conversation starter — save it for a slow day.
|
||||
7. **Threads format:** Each reply in a thread is its own mini-post. Keep each one self-contained — people scroll past the first one and should still understand the reply they land on.
|
||||
|
|
@ -1,3 +1,14 @@
|
|||
export type {
|
||||
Notebook,
|
||||
NotebookCell,
|
||||
PinnedResult,
|
||||
NotebookWithCells,
|
||||
CreateNotebookInput,
|
||||
UpdateNotebookInput,
|
||||
AddCellInput,
|
||||
UpdateCellInput
|
||||
} from './notebook-types'
|
||||
export { MAX_PINNED_ROWS } from './notebook-types'
|
||||
export { PG_TYPE_MAP, resolvePostgresType } from "./type-maps";
|
||||
export {
|
||||
escapeSQLValue,
|
||||
|
|
|
|||
56
packages/shared/src/notebook-types.ts
Normal file
56
packages/shared/src/notebook-types.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
export interface Notebook {
|
||||
id: string
|
||||
title: string
|
||||
connectionId: string
|
||||
folder: string | null
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface NotebookCell {
|
||||
id: string
|
||||
notebookId: string
|
||||
type: 'sql' | 'markdown'
|
||||
content: string
|
||||
pinnedResult: PinnedResult | null
|
||||
order: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface PinnedResult {
|
||||
columns: string[]
|
||||
rows: unknown[][]
|
||||
rowCount: number
|
||||
executedAt: number
|
||||
durationMs: number
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface NotebookWithCells extends Notebook {
|
||||
cells: NotebookCell[]
|
||||
}
|
||||
|
||||
export interface CreateNotebookInput {
|
||||
title: string
|
||||
connectionId: string
|
||||
folder?: string | null
|
||||
}
|
||||
|
||||
export interface UpdateNotebookInput {
|
||||
title?: string
|
||||
folder?: string | null
|
||||
}
|
||||
|
||||
export interface AddCellInput {
|
||||
type: 'sql' | 'markdown'
|
||||
content: string
|
||||
order: number
|
||||
}
|
||||
|
||||
export interface UpdateCellInput {
|
||||
content?: string
|
||||
pinnedResult?: PinnedResult | null
|
||||
}
|
||||
|
||||
export const MAX_PINNED_ROWS = 500
|
||||
|
|
@ -195,9 +195,15 @@ importers:
|
|||
react-grid-layout:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
react-markdown:
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0(@types/react@19.2.7)(react@19.2.0)
|
||||
recharts:
|
||||
specifier: ^3.5.1
|
||||
version: 3.5.1(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react-is@16.13.1)(react@19.2.0)(redux@5.0.1)
|
||||
remark-gfm:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
sql-formatter:
|
||||
specifier: ^15.6.10
|
||||
version: 15.6.10
|
||||
|
|
@ -449,13 +455,13 @@ importers:
|
|||
dependencies:
|
||||
'@clerk/nextjs':
|
||||
specifier: ^6.35.5
|
||||
version: 6.35.5(next@16.0.7(@opentelemetry/api@1.9.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
version: 6.35.5(next@16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-slot':
|
||||
specifier: ^1.2.4
|
||||
version: 1.2.4(@types/react@19.2.7)(react@19.2.0)
|
||||
'@sentry/nextjs':
|
||||
specifier: ^10.47.0
|
||||
version: 10.47.0(@opentelemetry/context-async-hooks@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(encoding@0.1.13)(next@16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(webpack@5.105.0(esbuild@0.25.12))
|
||||
version: 10.47.0(@opentelemetry/context-async-hooks@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(encoding@0.1.13)(next@16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(webpack@5.105.0)
|
||||
'@vercel/analytics':
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1(next@16.0.7(@opentelemetry/api@1.9.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)
|
||||
|
|
@ -567,7 +573,7 @@ importers:
|
|||
dependencies:
|
||||
'@clerk/nextjs':
|
||||
specifier: ^6.35.5
|
||||
version: 6.35.5(next@16.0.7(@opentelemetry/api@1.9.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
version: 6.35.5(next@16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@clerk/themes':
|
||||
specifier: ^2.4.57
|
||||
version: 2.4.57(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
|
|
@ -7104,6 +7110,9 @@ packages:
|
|||
html-to-image@1.11.13:
|
||||
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
|
||||
|
||||
html-url-attributes@3.0.1:
|
||||
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
|
||||
|
||||
html-void-elements@3.0.0:
|
||||
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
|
||||
|
||||
|
|
@ -9013,6 +9022,12 @@ packages:
|
|||
react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
|
||||
react-markdown@10.1.0:
|
||||
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '>=18'
|
||||
react: '>=18'
|
||||
|
||||
react-medium-image-zoom@5.4.3:
|
||||
resolution: {integrity: sha512-cDIwdn35fRUPsGnnj/cG6Pacll+z+Mfv6EWU2wDO5ngbZjg5uLRb2ZhEnh92ufbXCJDFvXHekb8G3+oKqUcv5g==}
|
||||
peerDependencies:
|
||||
|
|
@ -10895,7 +10910,7 @@ snapshots:
|
|||
react-dom: 19.2.0(react@19.2.0)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@clerk/nextjs@6.35.5(next@16.0.7(@opentelemetry/api@1.9.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||
'@clerk/nextjs@6.35.5(next@16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||
dependencies:
|
||||
'@clerk/backend': 2.24.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@clerk/clerk-react': 5.57.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
|
|
@ -13633,7 +13648,7 @@ snapshots:
|
|||
|
||||
'@sentry/core@10.47.0': {}
|
||||
|
||||
'@sentry/nextjs@10.47.0(@opentelemetry/context-async-hooks@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(encoding@0.1.13)(next@16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(webpack@5.105.0(esbuild@0.25.12))':
|
||||
'@sentry/nextjs@10.47.0(@opentelemetry/context-async-hooks@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(encoding@0.1.13)(next@16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(webpack@5.105.0)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/semantic-conventions': 1.40.0
|
||||
|
|
@ -13645,7 +13660,7 @@ snapshots:
|
|||
'@sentry/opentelemetry': 10.47.0(@opentelemetry/api@1.9.1)(@opentelemetry/context-async-hooks@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.40.0)
|
||||
'@sentry/react': 10.47.0(react@19.2.0)
|
||||
'@sentry/vercel-edge': 10.47.0
|
||||
'@sentry/webpack-plugin': 5.1.1(encoding@0.1.13)(webpack@5.105.0(esbuild@0.25.12))
|
||||
'@sentry/webpack-plugin': 5.1.1(encoding@0.1.13)(webpack@5.105.0)
|
||||
next: 16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
rollup: 4.53.3
|
||||
stacktrace-parser: 0.1.11
|
||||
|
|
@ -13735,11 +13750,11 @@ snapshots:
|
|||
'@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1)
|
||||
'@sentry/core': 10.47.0
|
||||
|
||||
'@sentry/webpack-plugin@5.1.1(encoding@0.1.13)(webpack@5.105.0(esbuild@0.25.12))':
|
||||
'@sentry/webpack-plugin@5.1.1(encoding@0.1.13)(webpack@5.105.0)':
|
||||
dependencies:
|
||||
'@sentry/bundler-plugin-core': 5.1.1(encoding@0.1.13)
|
||||
uuid: 9.0.1
|
||||
webpack: 5.105.0(esbuild@0.25.12)
|
||||
webpack: 5.105.0(esbuild@0.25.0)
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
|
|
@ -17323,6 +17338,8 @@ snapshots:
|
|||
|
||||
html-to-image@1.11.13: {}
|
||||
|
||||
html-url-attributes@3.0.1: {}
|
||||
|
||||
html-void-elements@3.0.0: {}
|
||||
|
||||
htmlparser2@10.0.0:
|
||||
|
|
@ -19593,6 +19610,24 @@ snapshots:
|
|||
|
||||
react-is@16.13.1: {}
|
||||
|
||||
react-markdown@10.1.0(@types/react@19.2.7)(react@19.2.0):
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/mdast': 4.0.4
|
||||
'@types/react': 19.2.7
|
||||
devlop: 1.1.0
|
||||
hast-util-to-jsx-runtime: 2.3.6
|
||||
html-url-attributes: 3.0.1
|
||||
mdast-util-to-hast: 13.2.1
|
||||
react: 19.2.0
|
||||
remark-parse: 11.0.0
|
||||
remark-rehype: 11.1.2
|
||||
unified: 11.0.5
|
||||
unist-util-visit: 5.1.0
|
||||
vfile: 6.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
react-medium-image-zoom@5.4.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||
dependencies:
|
||||
react: 19.2.0
|
||||
|
|
@ -20657,16 +20692,6 @@ snapshots:
|
|||
optionalDependencies:
|
||||
esbuild: 0.25.0
|
||||
|
||||
terser-webpack-plugin@5.4.0(esbuild@0.25.12)(webpack@5.105.0(esbuild@0.25.12)):
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
jest-worker: 27.5.1
|
||||
schema-utils: 4.3.3
|
||||
terser: 5.46.1
|
||||
webpack: 5.105.0(esbuild@0.25.12)
|
||||
optionalDependencies:
|
||||
esbuild: 0.25.12
|
||||
|
||||
terser@5.46.1:
|
||||
dependencies:
|
||||
'@jridgewell/source-map': 0.3.11
|
||||
|
|
@ -21230,38 +21255,6 @@ snapshots:
|
|||
- esbuild
|
||||
- uglify-js
|
||||
|
||||
webpack@5.105.0(esbuild@0.25.12):
|
||||
dependencies:
|
||||
'@types/eslint-scope': 3.7.7
|
||||
'@types/estree': 1.0.8
|
||||
'@types/json-schema': 7.0.15
|
||||
'@webassemblyjs/ast': 1.14.1
|
||||
'@webassemblyjs/wasm-edit': 1.14.1
|
||||
'@webassemblyjs/wasm-parser': 1.14.1
|
||||
acorn: 8.15.0
|
||||
acorn-import-phases: 1.0.4(acorn@8.15.0)
|
||||
browserslist: 4.28.1
|
||||
chrome-trace-event: 1.0.4
|
||||
enhanced-resolve: 5.20.1
|
||||
es-module-lexer: 2.0.0
|
||||
eslint-scope: 5.1.1
|
||||
events: 3.3.0
|
||||
glob-to-regexp: 0.4.1
|
||||
graceful-fs: 4.2.11
|
||||
json-parse-even-better-errors: 2.3.1
|
||||
loader-runner: 4.3.1
|
||||
mime-types: 2.1.35
|
||||
neo-async: 2.6.2
|
||||
schema-utils: 4.3.3
|
||||
tapable: 2.3.0
|
||||
terser-webpack-plugin: 5.4.0(esbuild@0.25.12)(webpack@5.105.0(esbuild@0.25.12))
|
||||
watchpack: 2.5.1
|
||||
webpack-sources: 3.3.4
|
||||
transitivePeerDependencies:
|
||||
- '@swc/core'
|
||||
- esbuild
|
||||
- uglify-js
|
||||
|
||||
whatwg-encoding@3.1.1:
|
||||
dependencies:
|
||||
iconv-lite: 0.6.3
|
||||
|
|
|
|||
71
seeds/demo-runbook.dpnb
Normal file
71
seeds/demo-runbook.dpnb
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
{
|
||||
"version": 1,
|
||||
"title": "ACME SaaS Health Check Runbook",
|
||||
"folder": "runbooks",
|
||||
"cells": [
|
||||
{
|
||||
"type": "markdown",
|
||||
"content": "# ACME SaaS Health Check Runbook\n\nUse this notebook to diagnose issues with the ACME SaaS platform.\nRun each step in order — if something looks off, the markdown cells explain what to look for."
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"content": "## Step 1: Platform Overview\n\nGet a quick snapshot of the platform — how many orgs, users, and projects we have, broken down by plan."
|
||||
},
|
||||
{
|
||||
"type": "sql",
|
||||
"content": "SELECT\n o.plan,\n COUNT(DISTINCT o.id) AS orgs,\n COUNT(DISTINCT m.user_id) AS users,\n COUNT(DISTINCT p.id) AS projects\nFROM organizations o\nLEFT JOIN memberships m ON m.organization_id = o.id\nLEFT JOIN projects p ON p.organization_id = o.id\nGROUP BY o.plan\nORDER BY orgs DESC"
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"content": "## Step 2: Revenue Health\n\nCheck subscription status and MRR. Look for:\n- High `past_due` count = payment processing issues\n- Spike in `canceled` = churn problem\n- Low `trialing` = acquisition slowdown"
|
||||
},
|
||||
{
|
||||
"type": "sql",
|
||||
"content": "SELECT\n s.status,\n COUNT(*) AS count,\n SUM(i.amount_cents) / 100.0 AS total_revenue_usd\nFROM subscriptions s\nLEFT JOIN invoices i ON i.subscription_id = s.id AND i.status = 'paid'\nGROUP BY s.status\nORDER BY count DESC"
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"content": "## Step 3: Unpaid Invoices\n\nFind invoices that are past due or failed. These need immediate attention from the billing team."
|
||||
},
|
||||
{
|
||||
"type": "sql",
|
||||
"content": "SELECT\n i.id,\n o.name AS organization,\n o.plan,\n i.amount_cents / 100.0 AS amount_usd,\n i.status,\n i.due_date,\n i.created_at\nFROM invoices i\nJOIN organizations o ON o.id = i.organization_id\nWHERE i.status IN ('pending', 'failed')\nORDER BY i.amount_cents DESC\nLIMIT 20"
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"content": "## Step 4: Recent Activity\n\nCheck the event log for the last 24 hours. Look for unusual patterns:\n- Burst of `api_key.created` = possible security concern\n- No `user.created` events = signup flow might be broken\n- Lots of `subscription.canceled` = churn event"
|
||||
},
|
||||
{
|
||||
"type": "sql",
|
||||
"content": "SELECT\n type,\n COUNT(*) AS count,\n MIN(created_at) AS earliest,\n MAX(created_at) AS latest\nFROM events\nWHERE created_at > NOW() - INTERVAL '24 hours'\nGROUP BY type\nORDER BY count DESC"
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"content": "## Step 5: Top Organizations by Usage\n\nIdentify the heaviest users — these are the accounts most likely to upgrade (or most at risk if something breaks)."
|
||||
},
|
||||
{
|
||||
"type": "sql",
|
||||
"content": "SELECT\n o.name,\n o.plan,\n COUNT(DISTINCT m.user_id) AS members,\n COUNT(DISTINCT p.id) AS projects,\n COUNT(DISTINCT ak.id) AS api_keys\nFROM organizations o\nLEFT JOIN memberships m ON m.organization_id = o.id\nLEFT JOIN projects p ON p.organization_id = o.id\nLEFT JOIN api_keys ak ON ak.organization_id = o.id AND ak.revoked_at IS NULL\nGROUP BY o.id, o.name, o.plan\nORDER BY members DESC, projects DESC\nLIMIT 10"
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"content": "## Step 6: Stale API Keys\n\nFind API keys that haven't been used in 90+ days or have expired. These should be flagged for rotation or revocation."
|
||||
},
|
||||
{
|
||||
"type": "sql",
|
||||
"content": "SELECT\n ak.name AS key_name,\n ak.key_prefix,\n o.name AS organization,\n ak.scopes,\n ak.last_used_at,\n ak.expires_at,\n CASE\n WHEN ak.revoked_at IS NOT NULL THEN 'revoked'\n WHEN ak.expires_at < NOW() THEN 'expired'\n WHEN ak.last_used_at < NOW() - INTERVAL '90 days' THEN 'stale'\n WHEN ak.last_used_at IS NULL THEN 'never used'\n ELSE 'active'\n END AS status\nFROM api_keys ak\nJOIN organizations o ON o.id = ak.organization_id\nWHERE ak.revoked_at IS NULL\n AND (ak.last_used_at IS NULL\n OR ak.last_used_at < NOW() - INTERVAL '90 days'\n OR ak.expires_at < NOW())\nORDER BY ak.last_used_at ASC NULLS FIRST"
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"content": "## Step 7: Enterprise Org Deep Dive\n\nCheck the health of enterprise accounts specifically — these are the highest-value customers."
|
||||
},
|
||||
{
|
||||
"type": "sql",
|
||||
"content": "SELECT\n o.name,\n o.slug,\n o.billing_email,\n s.status AS sub_status,\n s.current_period_end,\n COUNT(DISTINCT m.user_id) AS team_size,\n o.metadata->>'industry' AS industry\nFROM organizations o\nJOIN subscriptions s ON s.organization_id = o.id\nLEFT JOIN memberships m ON m.organization_id = o.id\nWHERE o.plan = 'enterprise'\nGROUP BY o.id, o.name, o.slug, o.billing_email, s.status, s.current_period_end, o.metadata\nORDER BY team_size DESC"
|
||||
},
|
||||
{
|
||||
"type": "markdown",
|
||||
"content": "---\n\n**All clear?** If no red flags above, the platform is healthy. If anything looks off, escalate to the relevant team:\n- Billing issues → #billing-ops\n- Security concerns → #security-oncall\n- Usage anomalies → #product-eng"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in a new issue