diff --git a/README.md b/README.md index 7b232ee..e7e3deb 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ A free online EXPLAIN plan visualizer. Paste the EXPLAIN output of a **PostgreSQ - **Four views:** an interactive graph, a compact diagram, a sortable table and summary statistics. - **Automatic findings:** hotspots, bad row estimates, disk sorts, large sequential scans and cache misses are flagged on each node. -- **Encrypted short links:** the plan is encrypted in the browser before it is stored, and the key exists only in the link, so the server cannot read it. Links expire after 30 days. +- **Encrypted sharing links:** the plan is encrypted in the browser before it is stored, and the key exists only in the link, so the server cannot read it. Links expire 30 days after they were last opened. - **Auto-detection:** the engine and format are detected from the pasted text, or you can pick them by hand. > [!NOTE] @@ -80,7 +80,7 @@ WHERE plan_id = (SELECT MAX(plan_id) FROM plan_table); - [`@tabularis/explain`](https://www.npmjs.com/package/@tabularis/explain) for parsing, analysis and the plan views - [`@tabularis/explain-sqlserver`](https://www.npmjs.com/package/@tabularis/explain-sqlserver) for SQL Server SHOWPLAN XML - [`@tabularis/explain-oracle`](https://www.npmjs.com/package/@tabularis/explain-oracle) for Oracle execution plans -- [Cloudflare Workers](https://developers.cloudflare.com/workers/) and [R2](https://developers.cloudflare.com/r2/) for the short links +- [Cloudflare Workers](https://developers.cloudflare.com/workers/) and [R2](https://developers.cloudflare.com/r2/) for the sharing links - [Vitest](https://vitest.dev) + Testing Library for tests ## Development @@ -111,9 +111,9 @@ vercel deploy (Framework preset: **Vite**, build command `pnpm build`, output directory `dist`.) -## Short links +## Sharing links -Short links are served by a small Cloudflare Worker in [`worker/`](./worker), deployed at `share.tabularis.dev`. The browser compresses and encrypts the plan with AES-GCM, sends only the ciphertext, and keeps the key in the part of the link after `#`, which browsers never send to a server. The Worker stores the ciphertext in an R2 bucket, where a lifecycle rule deletes it after 30 days. +Sharing links are served by a small Cloudflare Worker in [`worker/`](./worker), deployed at `share.tabularis.dev`. The browser compresses and encrypts the plan with AES-GCM, sends only the ciphertext, and keeps the key in the part of the link after `#`, which browsers never send to a server. The Worker stores the ciphertext in an R2 bucket, where a lifecycle rule deletes it 30 days after its last write. Opening a link rewrites a blob that is more than a day old, so links expire 30 days after they were last opened. ```bash cd worker diff --git a/index.html b/index.html index 6b337e4..e944aa7 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,6 @@
- diff --git a/src/App.test.tsx b/src/App.test.tsx index 9a38444..10a63c4 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -125,6 +125,27 @@ describe('App', () => { expect(screen.queryByRole('dialog', {name: /copy-paste/i})).not.toBeInTheDocument(); }); + it('keeps the promo open when the demo video is closed with Escape', async () => { + const user = userEvent.setup(); + renderApp(); + + await loadSample(user, 'SQLite sample'); + await visualize(user); + const dialog = await screen.findByRole('dialog', {name: /copy-paste/i}); + + await user.tab(); + expect(within(dialog).getByRole('button', {name: 'Close'})).toHaveFocus(); + + const preview = within(dialog).getByRole('button', {name: /watch the tabularis/i}); + preview.focus(); + await user.keyboard('{Enter}'); + expect(screen.getByRole('dialog', {name: /watch the tabularis/i})).toBeInTheDocument(); + await user.keyboard('{Escape}'); + + expect(screen.queryByRole('dialog', {name: /watch the tabularis/i})).not.toBeInTheDocument(); + expect(screen.getByRole('dialog', {name: /copy-paste/i})).toBeInTheDocument(); + }); + it('visualizes the Oracle sample and explains how to capture a plan', async () => { const user = userEvent.setup(); const {container} = renderApp(); diff --git a/src/components/layout/Footer/Footer.module.scss b/src/components/layout/Footer/Footer.module.scss index 93f0c2f..16c791a 100644 --- a/src/components/layout/Footer/Footer.module.scss +++ b/src/components/layout/Footer/Footer.module.scss @@ -1,6 +1,7 @@ .footerWrapper { width: 100%; - padding: 4rem 1.25rem 3rem; + margin-top: 4rem; + padding: 3rem 1.25rem; } .footerContent { diff --git a/src/components/layout/Header/Header.module.scss b/src/components/layout/Header/Header.module.scss index 9c29ed8..b085c99 100644 --- a/src/components/layout/Header/Header.module.scss +++ b/src/components/layout/Header/Header.module.scss @@ -46,13 +46,3 @@ white-space: nowrap; } } - -.buttonSpinner { - animation: buttonSpin 0.8s linear infinite; -} - -@keyframes buttonSpin { - to { - transform: rotate(360deg); - } -} diff --git a/src/components/layout/Header/Header.tsx b/src/components/layout/Header/Header.tsx index 1ec89e2..2344fa5 100644 --- a/src/components/layout/Header/Header.tsx +++ b/src/components/layout/Header/Header.tsx @@ -1,35 +1,10 @@ -import {Check, DownloadIcon, Link2, LoaderCircle, RotateCcwIcon, TriangleAlert} from 'lucide-react'; -import {useEffect, useRef, useState} from 'react'; +import {DownloadIcon, Link2, RotateCcwIcon} from 'lucide-react'; +import {useEffect, useState} from 'react'; import {Link, useLocation} from 'react-router-dom'; import {TABULARIS} from '../../../lib/links/links'; -import {createShortLink} from '../../../lib/share/share'; import {Button} from '../../ui/Button/Button'; import styles from './Header.module.scss'; - -type ShareStatus = 'idle' | 'sharing' | 'copied' | 'failed'; - -const STATUS_LABEL: Record+ The link could not be copied. Check your connection and try again. +
+ )} + + ); +} diff --git a/src/components/pages/home/Faq/Faq.tsx b/src/components/pages/home/Faq/Faq.tsx index ff64bd2..778f4bd 100644 --- a/src/components/pages/home/Faq/Faq.tsx +++ b/src/components/pages/home/Faq/Faq.tsx @@ -15,7 +15,7 @@ const QUESTIONS: Question[] = [ <> No. The plan is parsed in your browser and stays there while you work on it. Only if you click Share is it sent, and it is encrypted in your browser first. The key exists only in the link, so we cannot read - shared plans. They are deleted after 30 days. + shared plans. They are deleted 30 days after they were last opened. > ), }, @@ -45,8 +45,9 @@ const QUESTIONS: Question[] = [ question: 'How do I share a plan with my team?', answer: ( <> - Once the plan is displayed, click Share at the top of the page. A short link is copied to your - clipboard, and anyone who opens it sees the same views, without an account. Links work for 30 days. + Once the plan is displayed, click Share at the top of the page, then Copy link. Anyone who opens the + link sees the same views, without an account. The link stays available for 30 days after it was last + opened. > ), }, diff --git a/src/components/pages/home/Highlights/Highlights.tsx b/src/components/pages/home/Highlights/Highlights.tsx index 6a76e71..3f863b5 100644 --- a/src/components/pages/home/Highlights/Highlights.tsx +++ b/src/components/pages/home/Highlights/Highlights.tsx @@ -30,8 +30,8 @@ const HIGHLIGHTS: Highlight[] = [ { icon: Link2, kicker: 'Sharing', - title: 'Share a plan with a short link', - text: 'The plan is encrypted in your browser before it is stored, and the key exists only in the link, so we cannot read it. Links work for 30 days.', + title: 'Share a plan with a link', + text: 'The plan is encrypted in your browser before it is stored, and the key exists only in the link, so we cannot read it. Links stay available for 30 days after they were last opened.', }, ]; diff --git a/src/components/pages/plan/PlanPage.test.tsx b/src/components/pages/plan/PlanPage.test.tsx index 02f8a03..466c3b7 100644 --- a/src/components/pages/plan/PlanPage.test.tsx +++ b/src/components/pages/plan/PlanPage.test.tsx @@ -1,4 +1,4 @@ -import {cleanup, render, screen} from '@testing-library/react'; +import {cleanup, render, screen, within} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import {MemoryRouter, Route, Routes} from 'react-router-dom'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; @@ -38,31 +38,74 @@ describe('PlanPage', () => { vi.unstubAllGlobals(); }); - it('shows the local plan and copies a short link that opens it', async () => { + it('explains the sharing link, then creates and copies it', async () => { const user = userEvent.setup(); storeLocalPlan(SAMPLES[0].text, SAMPLES[0].engine); renderAt('/plan'); expect(await screen.findByRole('tab', {name: 'Graph'})).toBeInTheDocument(); await user.click(screen.getByRole('button', {name: /share/i})); - expect(await screen.findByText('Copied')).toBeInTheDocument(); + + const dialog = screen.getByRole('dialog', {name: 'Share this plan'}); + expect(dialog).toHaveTextContent(/encrypted in your browser/i); + expect(dialog).toHaveTextContent(/30 days/i); + expect(store.size).toBe(0); + + await user.click(within(dialog).getByRole('button', {name: 'Copy link'})); + expect(await within(dialog).findByRole('button', {name: 'Copied'})).toHaveFocus(); const link = await navigator.clipboard.readText(); expect(link).toMatch(/\/plan#s=plan000001,[\w-]{22}$/); + await user.keyboard('{Escape}'); + expect(screen.queryByRole('dialog', {name: 'Share this plan'})).not.toBeInTheDocument(); + await navigator.clipboard.writeText(''); + await user.click(screen.getByRole('button', {name: /share/i})); + await user.click(screen.getByRole('button', {name: 'Copy link'})); + expect(await navigator.clipboard.readText()).toBe(link); + expect(store.size).toBe(1); + cleanup(); sessionStorage.clear(); renderAt(`/plan${link.slice(link.indexOf('#'))}`); expect(await screen.findByRole('tab', {name: 'Graph'})).toBeInTheDocument(); }); - it('explains that a short link has expired', async () => { + it('reuses the current sharing link instead of storing the plan again', async () => { + const user = userEvent.setup(); + storeLocalPlan(SAMPLES[0].text, SAMPLES[0].engine); + renderAt('/plan'); + await user.click(await screen.findByRole('button', {name: /share/i})); + await user.click(screen.getByRole('button', {name: 'Copy link'})); + const link = await navigator.clipboard.readText(); + + cleanup(); + await navigator.clipboard.writeText(''); + renderAt(`/plan${link.slice(link.indexOf('#'))}`); + await user.click(await screen.findByRole('button', {name: /share/i})); + await user.click(screen.getByRole('button', {name: 'Copy link'})); + expect(await navigator.clipboard.readText()).toBe(link); + expect(store.size).toBe(1); + }); + + it('explains that the link could not be created', async () => { + const user = userEvent.setup(); + storeLocalPlan(SAMPLES[0].text, SAMPLES[0].engine); + renderAt('/plan'); + vi.stubGlobal('fetch', async () => new Response('Too many requests', {status: 429})); + + await user.click(await screen.findByRole('button', {name: /share/i})); + await user.click(screen.getByRole('button', {name: 'Copy link'})); + expect(await screen.findByRole('alert')).toHaveTextContent(/could not be copied/i); + }); + + it('explains that a sharing link has expired', async () => { renderAt('/plan#s=plan999999,AAAAAAAAAAAAAAAAAAAAAA'); expect(await screen.findByRole('heading', {name: /expired or does not exist/i})).toBeInTheDocument(); expect(screen.queryByRole('button', {name: /share/i})).not.toBeInTheDocument(); }); - it('explains that a short link is damaged', async () => { + it('explains that a sharing link is damaged', async () => { renderAt('/plan#s=plan000001'); expect(await screen.findByRole('heading', {name: /incomplete or damaged/i})).toBeInTheDocument(); }); diff --git a/src/components/pages/plan/PlanView/PlanView.module.scss b/src/components/pages/plan/PlanView/PlanView.module.scss index 46c6272..35866b6 100644 --- a/src/components/pages/plan/PlanView/PlanView.module.scss +++ b/src/components/pages/plan/PlanView/PlanView.module.scss @@ -5,4 +5,5 @@ flex-direction: column; gap: 1.5rem; padding: 0.5rem 0; + margin-bottom: 5rem; } diff --git a/src/components/pages/plan/SharedPlanError/SharedPlanError.tsx b/src/components/pages/plan/SharedPlanError/SharedPlanError.tsx index 9d570c8..e66b390 100644 --- a/src/components/pages/plan/SharedPlanError/SharedPlanError.tsx +++ b/src/components/pages/plan/SharedPlanError/SharedPlanError.tsx @@ -9,7 +9,7 @@ const MESSAGES = { }, missing: { title: 'This shared plan has expired or does not exist', - text: 'Shared links are kept for 30 days. Ask the person who shared it for a new link.', + text: 'Shared links expire 30 days after they were last opened. Ask the person who shared it for a new link.', }, failed: { title: 'This shared plan could not be loaded', diff --git a/src/components/ui/Modal/Modal.module.scss b/src/components/ui/Modal/Modal.module.scss new file mode 100644 index 0000000..a5f7bc7 --- /dev/null +++ b/src/components/ui/Modal/Modal.module.scss @@ -0,0 +1,90 @@ +.modalBackdrop { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + background: rgba(3, 7, 18, 0.75); + backdrop-filter: blur(4px); +} + +.modal { + width: 100%; + max-width: 28rem; + max-height: calc(100dvh - 2rem); + overflow-y: auto; + border: 0.1rem solid var(--color-border); + border-radius: var(--radius-md); + background-color: var(--color-surface); + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5); + + &:focus-visible { + outline: none; + } +} + +.modalHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 1.25rem 1.25rem 0; +} + +.modalHeading { + display: flex; + align-items: center; + gap: 0.625rem; + + img, + svg { + flex-shrink: 0; + } +} + +.modalTitle { + margin: 0; + color: var(--color-text-primary); + font-family: var(--font-display); + font-size: 1.125rem; + font-weight: 600; +} + +.modalClose { + display: inline-flex; + padding: 0.25rem; + border: none; + border-radius: var(--radius-sm); + background: none; + color: var(--color-text-secondary); + cursor: pointer; + transition: + color 0.15s, + background-color 0.15s; + + &:hover { + background-color: var(--color-surface-raised); + color: var(--color-text-primary); + } +} + +.modalBody { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem 1.25rem; + font-weight: 400; +} + +.modalFooter { + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + gap: 0.8rem; + padding: 0.9rem 1.25rem; + border-top: 0.1rem solid var(--color-border); + background-color: color-mix(in srgb, var(--color-bg) 50%, transparent); +} diff --git a/src/components/ui/Modal/Modal.tsx b/src/components/ui/Modal/Modal.tsx new file mode 100644 index 0000000..45736b8 --- /dev/null +++ b/src/components/ui/Modal/Modal.tsx @@ -0,0 +1,81 @@ +import {X} from 'lucide-react'; +import {useEffect, useId, useRef, type KeyboardEvent, type ReactNode} from 'react'; +import styles from './Modal.module.scss'; + +const FOCUSABLE = + 'a[href], button:not([disabled]), input:not([disabled]), video[controls], [tabindex]:not([tabindex="-1"])'; + +interface ModalProps { + title: string; + icon?: ReactNode; + footer?: ReactNode; + onClose: () => void; + children: ReactNode; +} + +export function Modal({title, icon, footer, onClose, children}: ModalProps) { + const titleId = useId(); + const modalRef = useRef- Tabularis is a free desktop SQL workspace that runs{' '} - EXPLAIN directly from the editor, no copying plans around, and adds what a - paste-in tool can't: -
- -+ Tabularis is a free desktop SQL workspace that runs EXPLAIN directly from the editor, + no copying plans around, and adds what a paste-in tool can't: +
+ +