From cca3e5d346920d7fecd116a4f1ed9cf79215e119 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 21:00:37 +0000 Subject: [PATCH] r3dr: URL shortener page under Projects Ports the r3dr_v2 frontend (MoonBase #1359 chunk 2) to /r3dr: shorten form with expiry chips, localStorage recent links, iili.uk short links. Nav entry moves from Elsewhere (r3dr.net, retiring) to Projects. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017HmkKHfFNgq4buWdcAFqB8 --- package-lock.json | 45 ++-- package.json | 3 +- src/App.tsx | 2 + src/apps/r3dr/__tests__/CopyButton.test.tsx | 49 ++++ src/apps/r3dr/__tests__/R3drPage.test.tsx | 63 +++++ src/apps/r3dr/__tests__/ShortenCard.test.tsx | 154 +++++++++++ src/apps/r3dr/__tests__/api.test.ts | 86 +++++++ src/apps/r3dr/__tests__/expiry.test.ts | 40 +++ src/apps/r3dr/__tests__/recent.test.ts | 81 ++++++ src/apps/r3dr/__tests__/urlInput.test.ts | 35 +++ src/apps/r3dr/api.ts | 70 +++++ .../r3dr/components/CopyButton.module.css | 45 ++++ src/apps/r3dr/components/CopyButton.tsx | 65 +++++ .../r3dr/components/RecentLinks.module.css | 106 ++++++++ src/apps/r3dr/components/RecentLinks.tsx | 44 ++++ .../r3dr/components/ShortenCard.module.css | 240 ++++++++++++++++++ src/apps/r3dr/components/ShortenCard.tsx | 118 +++++++++ src/apps/r3dr/expiry.ts | 33 +++ src/apps/r3dr/pages/R3drPage.module.css | 79 ++++++ src/apps/r3dr/pages/R3drPage.tsx | 49 ++++ src/apps/r3dr/recent.ts | 58 +++++ src/apps/r3dr/urlInput.ts | 24 ++ src/shared/components/Navigation.tsx | 2 +- .../components/__tests__/Navigation.test.tsx | 13 +- 24 files changed, 1471 insertions(+), 33 deletions(-) create mode 100644 src/apps/r3dr/__tests__/CopyButton.test.tsx create mode 100644 src/apps/r3dr/__tests__/R3drPage.test.tsx create mode 100644 src/apps/r3dr/__tests__/ShortenCard.test.tsx create mode 100644 src/apps/r3dr/__tests__/api.test.ts create mode 100644 src/apps/r3dr/__tests__/expiry.test.ts create mode 100644 src/apps/r3dr/__tests__/recent.test.ts create mode 100644 src/apps/r3dr/__tests__/urlInput.test.ts create mode 100644 src/apps/r3dr/api.ts create mode 100644 src/apps/r3dr/components/CopyButton.module.css create mode 100644 src/apps/r3dr/components/CopyButton.tsx create mode 100644 src/apps/r3dr/components/RecentLinks.module.css create mode 100644 src/apps/r3dr/components/RecentLinks.tsx create mode 100644 src/apps/r3dr/components/ShortenCard.module.css create mode 100644 src/apps/r3dr/components/ShortenCard.tsx create mode 100644 src/apps/r3dr/expiry.ts create mode 100644 src/apps/r3dr/pages/R3drPage.module.css create mode 100644 src/apps/r3dr/pages/R3drPage.tsx create mode 100644 src/apps/r3dr/recent.ts create mode 100644 src/apps/r3dr/urlInput.ts diff --git a/package-lock.json b/package-lock.json index 707183b..2f81e44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.5", "@types/react": "^19.1.15", "@types/react-dom": "^19.2.4", "@typescript/native": "npm:typescript@^7.0.2", @@ -2152,9 +2153,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2172,9 +2170,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2192,9 +2187,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2212,9 +2204,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2232,9 +2221,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2252,9 +2238,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2442,6 +2425,20 @@ } } }, + "node_modules/@testing-library/user-event": { + "version": "14.6.5", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.5.tgz", + "integrity": "sha512-FhqjldLTpteueBaKflhNFlMT3+PM0O5fiBUivht6b9CZ1eesJyy7+g3Jr7XwJzt/Hip3ZG5hWwK1MX1FuDiE4w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -4762,9 +4759,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4786,9 +4780,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4810,9 +4801,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4834,9 +4822,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index cb0df55..308f9f4 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "test:ui": "vitest --ui", "typecheck": "tsc --noEmit", "test:run": "vitest run", - "local-server": "cross-env VITE_THOUGHTS_WEBSOCKET_URL=ws://localhost:2015/games/v1/thoughts-ws VITE_GOLF_WEBSOCKET_URL=ws://localhost:2015/games/v1/golf-ws VITE_THOUGHTS_SIMULATED=false VITE_METRICS_API_URL=http://localhost:2015/metrics/v1 VITE_MITHRIL_API_URL=http://localhost:2015/mithril/v1/wordchain VITE_TRACY_API_URL=http://localhost:2015/portrait/v1/trace VITE_POSTERIZE_API_URL=http://localhost:2015/imagine/v1 vite", + "local-server": "cross-env VITE_THOUGHTS_WEBSOCKET_URL=ws://localhost:2015/games/v1/thoughts-ws VITE_GOLF_WEBSOCKET_URL=ws://localhost:2015/games/v1/golf-ws VITE_THOUGHTS_SIMULATED=false VITE_METRICS_API_URL=http://localhost:2015/metrics/v1 VITE_MITHRIL_API_URL=http://localhost:2015/mithril/v1/wordchain VITE_TRACY_API_URL=http://localhost:2015/portrait/v1/trace VITE_POSTERIZE_API_URL=http://localhost:2015/imagine/v1 VITE_R3DR_API_URL=http://localhost:2015/r3dr/v2 vite", "deploy": "wrangler deploy" }, "dependencies": { @@ -30,6 +30,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.5", "@types/react": "^19.1.15", "@types/react-dom": "^19.2.4", "@typescript/native": "npm:typescript@^7.0.2", diff --git a/src/App.tsx b/src/App.tsx index dbc71c4..237b14e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import SystemsPage from './apps/metrics-systems/pages/SystemsPage' import ResilienceGamePage from './apps/metrics-systems/pages/ResilienceGamePage' import MetricsPage from './apps/metrics-systems/pages/MetricsPage' import WordchainsPage from './apps/wordchains/pages/WordchainsPage' +import R3drPage from './apps/r3dr/pages/R3drPage' import NotFoundPage from './core/pages/NotFoundPage' function App() { @@ -30,6 +31,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/apps/r3dr/__tests__/CopyButton.test.tsx b/src/apps/r3dr/__tests__/CopyButton.test.tsx new file mode 100644 index 0000000..2693261 --- /dev/null +++ b/src/apps/r3dr/__tests__/CopyButton.test.tsx @@ -0,0 +1,49 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import CopyButton from '../components/CopyButton' + +// fireEvent, not user-event: userEvent.setup() installs its own clipboard +// stub, which would shadow the one under test. + +function stubClipboard(writeText: (text: string) => Promise) { + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }) +} + +const TEXT = 'https://iili.uk/r/AQA' + +describe('CopyButton', () => { + it('copies the exact text and confirms', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + stubClipboard(writeText) + render() + + fireEvent.click(screen.getByRole('button', { name: `Copy ${TEXT}` })) + + expect(await screen.findByRole('button', { name: 'Copied' })).toHaveTextContent('Copied ✓') + expect(writeText).toHaveBeenCalledWith(TEXT) + }) + + it('admits failure when no copy path works', async () => { + stubClipboard(vi.fn().mockRejectedValue(new Error('denied'))) + document.execCommand = vi.fn().mockReturnValue(false) + render() + + fireEvent.click(screen.getByRole('button', { name: /^Copy / })) + + expect(await screen.findByRole('button', { name: 'Copy failed' })).toBeDefined() + }) + + it('falls back to execCommand when the clipboard API is refused', async () => { + stubClipboard(vi.fn().mockRejectedValue(new Error('denied'))) + document.execCommand = vi.fn().mockReturnValue(true) + render() + + fireEvent.click(screen.getByRole('button', { name: /^Copy / })) + + expect(await screen.findByRole('button', { name: 'Copied' })).toBeDefined() + expect(document.execCommand).toHaveBeenCalledWith('copy') + }) +}) diff --git a/src/apps/r3dr/__tests__/R3drPage.test.tsx b/src/apps/r3dr/__tests__/R3drPage.test.tsx new file mode 100644 index 0000000..0766112 --- /dev/null +++ b/src/apps/r3dr/__tests__/R3drPage.test.tsx @@ -0,0 +1,63 @@ +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import R3drPage from '../pages/R3drPage' +import * as api from '../api' + +vi.mock('../api', { spy: true }) +// The shared nav pulls in router-dependent pieces this page doesn't test. +vi.mock('@/shared/components/Navigation', () => ({ default: () =>