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 = { - idle: 'Share', - sharing: 'Sharing', - copied: 'Copied', - failed: 'Share failed', -}; - -const STATUS_ICON = { - idle: Link2, - sharing: LoaderCircle, - copied: Check, - failed: TriangleAlert, -}; - -async function copyToClipboard(link: Promise) { - if (typeof ClipboardItem === 'undefined') { - await navigator.clipboard.writeText(await link); - return; - } - const blob = link.then((text) => new Blob([text], {type: 'text/plain'})); - await navigator.clipboard.write([new ClipboardItem({'text/plain': blob})]); -} +import {ShareModal} from './ShareModal/ShareModal'; interface HeaderProps { isHomePage?: boolean; @@ -38,38 +13,13 @@ interface HeaderProps { export function Header({isHomePage = true, shareable = false}: HeaderProps) { const {hash} = useLocation(); - const [status, setStatus] = useState('idle'); - const shortLink = useRef | null>(null); + const [shareOpen, setShareOpen] = useState(false); + const [shareLink, setShareLink] = useState(null); useEffect(() => { - shortLink.current = null; + setShareLink(hash.startsWith('#s=') ? `${window.location.origin}/plan${hash}` : null); }, [hash]); - useEffect(() => { - if (status !== 'copied' && status !== 'failed') return; - const timer = setTimeout(() => setStatus('idle'), 2000); - return () => clearTimeout(timer); - }, [status]); - - const share = async () => { - if (status === 'sharing') return; - setStatus('sharing'); - shortLink.current ??= ( - hash.startsWith('#s=') ? Promise.resolve(window.location.href) : createShortLink() - ).catch((error: unknown) => { - shortLink.current = null; - throw error; - }); - try { - await copyToClipboard(shortLink.current); - setStatus('copied'); - } catch { - setStatus('failed'); - } - }; - - const StatusIcon = STATUS_ICON[status]; - return (
@@ -83,17 +33,12 @@ export function Header({isHomePage = true, shareable = false}: HeaderProps) { variant="secondary" size="sm" className={styles.button} - title="Copy a link to this plan" - onClick={share} + title="Share this plan" + aria-haspopup="dialog" + onClick={() => setShareOpen(true)} > -
); } diff --git a/src/components/layout/Header/ShareModal/ShareModal.module.scss b/src/components/layout/Header/ShareModal/ShareModal.module.scss new file mode 100644 index 0000000..12bbcc7 --- /dev/null +++ b/src/components/layout/Header/ShareModal/ShareModal.module.scss @@ -0,0 +1,48 @@ +.shareFacts { + display: flex; + flex-direction: column; + gap: 0.625rem; + padding: 0.5rem 0; + color: var(--color-text-secondary); + font-size: 0.875rem; + line-height: 1.5; + list-style: none; + + li { + display: flex; + align-items: flex-start; + gap: 0.5rem; + } + + svg { + flex-shrink: 0; + margin-top: 0.2rem; + color: var(--color-accent-teal); + } +} + +.shareButton { + align-self: flex-end; +} + +.shareSpinner { + animation: shareSpin 0.8s linear infinite; +} + +.shareError { + margin: 0; + color: var(--color-accent-error); + font-size: 0.8rem; +} + +@keyframes shareSpin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .shareSpinner { + animation: none; + } +} diff --git a/src/components/layout/Header/ShareModal/ShareModal.tsx b/src/components/layout/Header/ShareModal/ShareModal.tsx new file mode 100644 index 0000000..6820430 --- /dev/null +++ b/src/components/layout/Header/ShareModal/ShareModal.tsx @@ -0,0 +1,107 @@ +import {Check, Clock, Copy, Link2, LoaderCircle, Lock, RotateCw, Users, type LucideIcon} from 'lucide-react'; +import {useEffect, useState} from 'react'; +import {createShareLink} from '../../../../lib/share/share'; +import {Button} from '../../../ui/Button/Button'; +import {Modal} from '../../../ui/Modal/Modal'; +import styles from './ShareModal.module.scss'; + +type ShareStatus = 'idle' | 'creating' | 'copied' | 'failed'; + +const FACTS = [ + { + icon: Lock, + text: 'The plan is encrypted in your browser before it is sent. The key exists only in the link, so we cannot read it.', + }, + { + icon: Clock, + text: 'The link stays available for 30 days after it was last opened. After that, the encrypted plan is deleted.', + }, + {icon: Users, text: 'Anyone with the link can open the plan, without an account.'}, +]; + +const BUTTON_LABEL: Record = { + idle: 'Copy link', + creating: 'Creating link', + copied: 'Copied', + failed: 'Try again', +}; + +const BUTTON_ICON: Record = { + idle: Copy, + creating: LoaderCircle, + copied: Check, + failed: RotateCw, +}; + +async function copyToClipboard(link: Promise) { + if (typeof ClipboardItem === 'undefined') { + await navigator.clipboard.writeText(await link); + return; + } + const blob = link.then((text) => new Blob([text], {type: 'text/plain'})); + await navigator.clipboard.write([new ClipboardItem({'text/plain': blob})]); +} + +interface ShareModalProps { + link: string | null; + onLinkCreated: (link: string) => void; + onClose: () => void; +} + +export function ShareModal({link, onLinkCreated, onClose}: ShareModalProps) { + const [status, setStatus] = useState('idle'); + + useEffect(() => { + if (status !== 'copied') return; + const timer = setTimeout(() => setStatus('idle'), 2000); + return () => clearTimeout(timer); + }, [status]); + + const copyLink = async () => { + if (status === 'creating') return; + if (!link) setStatus('creating'); + + const pending = link ? Promise.resolve(link) : createShareLink(); + const copied = copyToClipboard(pending).then( + () => true, + () => false, + ); + try { + const value = await pending; + if (!link) onLinkCreated(value); + setStatus((await copied) ? 'copied' : 'failed'); + } catch { + setStatus('failed'); + } + }; + + const StatusIcon = BUTTON_ICON[status]; + + return ( + + ); +} 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(null); + + useEffect(() => { + const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null; + modalRef.current?.focus(); + return () => { + if (previous?.isConnected) previous.focus(); + }; + }, []); + + const handleKeyDown = (event: KeyboardEvent) => { + const modal = modalRef.current; + if (!modal || !modal.contains(event.target as Node) || modal.querySelector('[aria-expanded="true"]')) return; + + if (event.key === 'Escape') { + event.stopPropagation(); + onClose(); + return; + } + if (event.key !== 'Tab') return; + + const items = [...modal.querySelectorAll(FOCUSABLE)]; + if (items.length === 0) return; + event.preventDefault(); + const index = items.indexOf(document.activeElement as HTMLElement); + const next = event.shiftKey ? (index <= 0 ? items.length - 1 : index - 1) : (index + 1) % items.length; + items[next].focus(); + }; + + return ( +
{ + if (event.target === event.currentTarget) onClose(); + }} + > +
+
+
+ {icon} +

+ {title} +

+
+ +
+ +
{children}
+ + {footer &&
{footer}
} +
+
+ ); +} diff --git a/src/components/ui/TabularisPromoModal/TabularisPromoModal.module.scss b/src/components/ui/TabularisPromoModal/TabularisPromoModal.module.scss index 5f60fc5..4eade92 100644 --- a/src/components/ui/TabularisPromoModal/TabularisPromoModal.module.scss +++ b/src/components/ui/TabularisPromoModal/TabularisPromoModal.module.scss @@ -1,83 +1,3 @@ -.backdrop { - 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); -} - -.header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 1rem; - padding: 1.25rem 1.25rem 0; -} - -.heading { - display: flex; - align-items: center; - gap: 0.625rem; - - img { - flex-shrink: 0; - } -} - -.title { - margin: 0; - color: var(--color-text-primary); - font-family: var(--font-display); - font-size: 1.125rem; - font-weight: 600; -} - -.closeButton { - 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); - } - - &:focus-visible { - outline: 0.1rem solid var(--color-accent-teal); - outline-offset: 0.125rem; - } -} - -.body { - display: flex; - flex-direction: column; - gap: 1rem; - padding: 1rem 1.25rem; - font-weight: 400; -} - .text { margin: 0; color: var(--color-text-secondary); @@ -112,17 +32,6 @@ } } -.footer { - 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); -} - .checkbox { display: flex; align-items: center; diff --git a/src/components/ui/TabularisPromoModal/TabularisPromoModal.tsx b/src/components/ui/TabularisPromoModal/TabularisPromoModal.tsx index 4a2067c..16bdff2 100644 --- a/src/components/ui/TabularisPromoModal/TabularisPromoModal.tsx +++ b/src/components/ui/TabularisPromoModal/TabularisPromoModal.tsx @@ -1,7 +1,8 @@ -import {useEffect, useState} from 'react'; -import {Database, Download, Sparkles, X, Zap, type LucideIcon} from 'lucide-react'; +import {Database, Download, Sparkles, Zap, type LucideIcon} from 'lucide-react'; +import {useState} from 'react'; import {TABULARIS} from '../../../lib/links/links'; -import {Button} from '../../ui/Button/Button'; +import {Button} from '../Button/Button'; +import {Modal} from '../Modal/Modal'; import {VideoPreview} from '../VideoPreview/VideoPreview'; import styles from './TabularisPromoModal.module.scss'; @@ -49,58 +50,13 @@ export function TabularisPromoModal({onClose}: TabularisPromoModalProps) { onClose(); }; - useEffect(() => { - const handleKey = (event: KeyboardEvent) => { - if (event.key === 'Escape') close(); - }; - window.addEventListener('keydown', handleKey); - return () => window.removeEventListener('keydown', handleKey); - }); - return ( -
{ - if (event.target === event.currentTarget) close(); - }} - > -
-
-
- -

- Want to skip the copy-paste? -

-
- -
- -
-

- 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: -

- -
    - {FEATURES.map(({icon: Icon, color, text}) => ( -
  • -
  • - ))} -
- - -
- -
+ } + onClose={close} + footer={ + <>
- -
- + + } + > +

+ 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: +

+ +
    + {FEATURES.map(({icon: Icon, color, text}) => ( +
  • +
  • + ))} +
+ + + ); } diff --git a/src/lib/share/share.test.ts b/src/lib/share/share.test.ts index 76e9ba6..a7d6c8f 100644 --- a/src/lib/share/share.test.ts +++ b/src/lib/share/share.test.ts @@ -1,6 +1,6 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {SAMPLES} from '../../samples'; -import {createShortLink, loadPlan, storeLocalPlan} from './share'; +import {createShareLink, loadPlan, storeLocalPlan} from './share'; const linkHash = (link: string) => link.slice(link.indexOf('#')); @@ -33,10 +33,10 @@ describe('share', () => { } }); - it('creates a short link that opens the same plan', async () => { + it('creates a sharing link that opens the same plan', async () => { for (const {engine, text} of SAMPLES) { storeLocalPlan(text, engine); - const link = await createShortLink(); + const link = await createShareLink(); expect(link).toMatch(/\/plan#s=[\w-]{10},[\w-]{22}$/); const result = await loadPlan(linkHash(link)); expect(result.status).toBe('ok'); @@ -46,7 +46,7 @@ describe('share', () => { it('never sends the plan or the key to the server', async () => { storeLocalPlan(SAMPLES[0].text, SAMPLES[0].engine); - const link = await createShortLink(); + const link = await createShareLink(); const [body] = [...store.values()]; const sent = new TextDecoder().decode(body as Uint8Array); expect(sent).not.toContain('Hash Join'); @@ -69,7 +69,7 @@ describe('share', () => { it('reports a link with the wrong key or a missing part', async () => { storeLocalPlan(SAMPLES[0].text, SAMPLES[0].engine); - const link = await createShortLink(); + const link = await createShareLink(); expect((await loadPlan(linkHash(link).replace(/,.*/, ',AAAAAAAAAAAAAAAAAAAAAA'))).status).toBe('invalid'); expect((await loadPlan('#s=plan000000')).status).toBe('invalid'); }); diff --git a/src/lib/share/share.ts b/src/lib/share/share.ts index 440c07e..5af5881 100644 --- a/src/lib/share/share.ts +++ b/src/lib/share/share.ts @@ -67,7 +67,7 @@ function localPayload(): Uint8Array | null { } } -export async function createShortLink(): Promise { +export async function createShareLink(): Promise { const payload = localPayload(); if (!payload) throw new Error('No plan to share'); @@ -90,7 +90,7 @@ export async function createShortLink(): Promise { return `${window.location.origin}/plan#s=${id},${toBase64Url(rawKey)}`; } -async function loadShortLink(id: string, key: string): Promise { +async function loadShareLink(id: string, key: string): Promise { let response: Response; try { response = await fetch(`${SHARE_API}/api/share/${encodeURIComponent(id)}`); @@ -127,5 +127,5 @@ export async function loadPlan(hash: string): Promise { const [id, key] = shared.split(','); if (!id || !key) return {status: 'invalid'}; - return loadShortLink(id, key); + return loadShareLink(id, key); } diff --git a/src/test/setup.ts b/src/test/setup.ts index e24a071..9177cf1 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1,4 +1,7 @@ import '@testing-library/jest-dom/vitest'; +import {configure} from '@testing-library/react'; + +configure({asyncUtilTimeout: 5000}); class ResizeObserverStub { observe() {} diff --git a/worker/src/index.ts b/worker/src/index.ts index b3a1ec1..3856240 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -13,6 +13,7 @@ const MAX_SIZE = 1024 * 1024; const ID_LENGTH = 10; const ID_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; const ID_PATTERN = /^[A-Za-z0-9_-]{10}$/; +const RENEW_AFTER_MS = 24 * 60 * 60 * 1000; function createId(): string { const bytes = crypto.getRandomValues(new Uint8Array(ID_LENGTH)); @@ -46,24 +47,30 @@ async function createShare(request: Request, env: Env): Promise { return respond(env, JSON.stringify({id}), 201, {'Content-Type': 'application/json'}); } -async function getShare(id: string, env: Env): Promise { +async function getShare(id: string, env: Env, ctx: ExecutionContext): Promise { const object = await env.PLANS.get(id); if (!object) return respond(env, 'Not found', 404); - return respond(env, object.body, 200, { + + const body = await object.arrayBuffer(); + if (Date.now() - object.uploaded.getTime() > RENEW_AFTER_MS) { + ctx.waitUntil(env.PLANS.put(id, body.slice(0), {httpMetadata: {contentType: 'application/octet-stream'}})); + } + + return respond(env, body, 200, { 'Content-Type': 'application/octet-stream', 'Cache-Control': 'no-store', }); } export default { - async fetch(request, env): Promise { + async fetch(request, env, ctx): Promise { if (request.method === 'OPTIONS') return respond(env, null, 204); const {pathname} = new URL(request.url); if (pathname === '/api/share' && request.method === 'POST') return createShare(request, env); const id = pathname.match(/^\/api\/share\/([^/]+)$/)?.[1]; - if (id && request.method === 'GET' && ID_PATTERN.test(id)) return getShare(id, env); + if (id && request.method === 'GET' && ID_PATTERN.test(id)) return getShare(id, env, ctx); return respond(env, 'Not found', 404); },