From 088429c716d22be5c7cb25f2025319847ddf07b9 Mon Sep 17 00:00:00 2001 From: wajrock Date: Thu, 8 Oct 2026 18:54:52 +0200 Subject: [PATCH 1/3] feat(share): add a share modal and renew short links for 30 days when opened --- src/App.test.tsx | 21 ++++ .../layout/Footer/Footer.module.scss | 3 +- .../layout/Header/Header.module.scss | 10 -- src/components/layout/Header/Header.tsx | 81 +++----------- .../Header/ShareModal/ShareModal.module.scss | 48 ++++++++ .../layout/Header/ShareModal/ShareModal.tsx | 104 ++++++++++++++++++ src/components/pages/home/Faq/Faq.tsx | 4 +- src/components/pages/plan/PlanPage.test.tsx | 46 +++++++- .../pages/plan/PlanView/PlanView.module.scss | 1 + src/components/ui/Modal/Modal.module.scss | 90 +++++++++++++++ src/components/ui/Modal/Modal.tsx | 81 ++++++++++++++ .../TabularisPromoModal.module.scss | 91 --------------- .../TabularisPromoModal.tsx | 90 ++++++--------- src/test/setup.ts | 3 + worker/src/index.ts | 15 ++- 15 files changed, 454 insertions(+), 234 deletions(-) create mode 100644 src/components/layout/Header/ShareModal/ShareModal.module.scss create mode 100644 src/components/layout/Header/ShareModal/ShareModal.tsx create mode 100644 src/components/ui/Modal/Modal.module.scss create mode 100644 src/components/ui/Modal/Modal.tsx 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..6f23148 --- /dev/null +++ b/src/components/layout/Header/ShareModal/ShareModal.tsx @@ -0,0 +1,104 @@ +import {Check, Clock, Copy, Link2, LoaderCircle, Lock, RotateCw, Users, type LucideIcon} from 'lucide-react'; +import {useEffect, useState} from 'react'; +import {createShortLink} 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 works for 30 days. 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) : createShortLink(); + 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..d1fa54b 100644 --- a/src/components/pages/home/Faq/Faq.tsx +++ b/src/components/pages/home/Faq/Faq.tsx @@ -45,8 +45,8 @@ 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. ), }, diff --git a/src/components/pages/plan/PlanPage.test.tsx b/src/components/pages/plan/PlanPage.test.tsx index 02f8a03..d061177 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,17 +38,31 @@ describe('PlanPage', () => { vi.unstubAllGlobals(); }); - it('shows the local plan and copies a short link that opens it', async () => { + it('explains the short 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: /create and copy link/i})); + expect(await within(dialog).findByText('Copied')).toBeInTheDocument(); const link = await navigator.clipboard.readText(); expect(link).toMatch(/\/plan#s=plan000001,[\w-]{22}$/); + expect(within(dialog).getByRole('textbox', {name: 'Short link'})).toHaveValue(link); + + await user.keyboard('{Escape}'); + expect(screen.queryByRole('dialog', {name: 'Share this plan'})).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', {name: /share/i})); + expect(screen.getByRole('textbox', {name: 'Short link'})).toHaveValue(link); + expect(store.size).toBe(1); cleanup(); sessionStorage.clear(); @@ -56,6 +70,32 @@ describe('PlanPage', () => { expect(await screen.findByRole('tab', {name: 'Graph'})).toBeInTheDocument(); }); + it('reuses the current short 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: /create and copy link/i})); + const link = await navigator.clipboard.readText(); + + cleanup(); + renderAt(`/plan${link.slice(link.indexOf('#'))}`); + await user.click(await screen.findByRole('button', {name: /share/i})); + expect(screen.getByRole('textbox', {name: 'Short link'})).toHaveValue(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: /create and copy link/i})); + expect(await screen.findByRole('alert')).toHaveTextContent(/could not be created/i); + }); + it('explains that a short link has expired', async () => { renderAt('/plan#s=plan999999,AAAAAAAAAAAAAAAAAAAAAA'); expect(await screen.findByRole('heading', {name: /expired or does not exist/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/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/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); }, From 1ca589a4ed91f824925970d84b4a1cef8d56563f Mon Sep 17 00:00:00 2001 From: wajrock Date: Thu, 8 Oct 2026 20:17:31 +0200 Subject: [PATCH 2/3] fix(share): describe link renewal and rename short links to sharing links --- README.md | 8 +++--- .../layout/Header/ShareModal/ShareModal.tsx | 9 ++++--- src/components/pages/home/Faq/Faq.tsx | 5 ++-- .../pages/home/Highlights/Highlights.tsx | 2 +- src/components/pages/plan/PlanPage.test.tsx | 27 ++++++++++--------- src/lib/share/share.test.ts | 10 +++---- src/lib/share/share.ts | 6 ++--- 7 files changed, 37 insertions(+), 30 deletions(-) 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/src/components/layout/Header/ShareModal/ShareModal.tsx b/src/components/layout/Header/ShareModal/ShareModal.tsx index 6f23148..6820430 100644 --- a/src/components/layout/Header/ShareModal/ShareModal.tsx +++ b/src/components/layout/Header/ShareModal/ShareModal.tsx @@ -1,6 +1,6 @@ import {Check, Clock, Copy, Link2, LoaderCircle, Lock, RotateCw, Users, type LucideIcon} from 'lucide-react'; import {useEffect, useState} from 'react'; -import {createShortLink} from '../../../../lib/share/share'; +import {createShareLink} from '../../../../lib/share/share'; import {Button} from '../../../ui/Button/Button'; import {Modal} from '../../../ui/Modal/Modal'; import styles from './ShareModal.module.scss'; @@ -12,7 +12,10 @@ 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 works for 30 days. After that, the encrypted plan is deleted.'}, + { + 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.'}, ]; @@ -58,7 +61,7 @@ export function ShareModal({link, onLinkCreated, onClose}: ShareModalProps) { if (status === 'creating') return; if (!link) setStatus('creating'); - const pending = link ? Promise.resolve(link) : createShortLink(); + const pending = link ? Promise.resolve(link) : createShareLink(); const copied = copyToClipboard(pending).then( () => true, () => false, diff --git a/src/components/pages/home/Faq/Faq.tsx b/src/components/pages/home/Faq/Faq.tsx index d1fa54b..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. ), }, @@ -46,7 +46,8 @@ const QUESTIONS: Question[] = [ answer: ( <> 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. + 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..d9f2a97 100644 --- a/src/components/pages/home/Highlights/Highlights.tsx +++ b/src/components/pages/home/Highlights/Highlights.tsx @@ -30,7 +30,7 @@ const HIGHLIGHTS: Highlight[] = [ { icon: Link2, kicker: 'Sharing', - title: 'Share a plan with a short link', + 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 work for 30 days.', }, ]; diff --git a/src/components/pages/plan/PlanPage.test.tsx b/src/components/pages/plan/PlanPage.test.tsx index d061177..466c3b7 100644 --- a/src/components/pages/plan/PlanPage.test.tsx +++ b/src/components/pages/plan/PlanPage.test.tsx @@ -38,7 +38,7 @@ describe('PlanPage', () => { vi.unstubAllGlobals(); }); - it('explains the short link, then creates and copies 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'); @@ -51,17 +51,18 @@ describe('PlanPage', () => { expect(dialog).toHaveTextContent(/30 days/i); expect(store.size).toBe(0); - await user.click(within(dialog).getByRole('button', {name: /create and copy link/i})); - expect(await within(dialog).findByText('Copied')).toBeInTheDocument(); + 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}$/); - expect(within(dialog).getByRole('textbox', {name: 'Short link'})).toHaveValue(link); 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})); - expect(screen.getByRole('textbox', {name: 'Short link'})).toHaveValue(link); + await user.click(screen.getByRole('button', {name: 'Copy link'})); + expect(await navigator.clipboard.readText()).toBe(link); expect(store.size).toBe(1); cleanup(); @@ -70,18 +71,20 @@ describe('PlanPage', () => { expect(await screen.findByRole('tab', {name: 'Graph'})).toBeInTheDocument(); }); - it('reuses the current short link instead of storing the plan again', 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: /create and copy link/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})); - expect(screen.getByRole('textbox', {name: 'Short link'})).toHaveValue(link); + await user.click(screen.getByRole('button', {name: 'Copy link'})); + expect(await navigator.clipboard.readText()).toBe(link); expect(store.size).toBe(1); }); @@ -92,17 +95,17 @@ describe('PlanPage', () => { 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: /create and copy link/i})); - expect(await screen.findByRole('alert')).toHaveTextContent(/could not be created/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 short link has expired', async () => { + 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/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); } From b83711c5dee722e3e29724a66707ef0550151e2b Mon Sep 17 00:00:00 2001 From: wajrock Date: Thu, 8 Oct 2026 20:22:29 +0200 Subject: [PATCH 3/3] fix(share): mention link renewal in the error screen and highlights --- index.html | 1 - src/components/pages/home/Highlights/Highlights.tsx | 2 +- src/components/pages/plan/SharedPlanError/SharedPlanError.tsx | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) 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/components/pages/home/Highlights/Highlights.tsx b/src/components/pages/home/Highlights/Highlights.tsx index d9f2a97..3f863b5 100644 --- a/src/components/pages/home/Highlights/Highlights.tsx +++ b/src/components/pages/home/Highlights/Highlights.tsx @@ -31,7 +31,7 @@ const HIGHLIGHTS: Highlight[] = [ icon: Link2, kicker: 'Sharing', 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 work for 30 days.', + 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/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',