diff --git a/packages/web/package.json b/packages/web/package.json index 994c4d1..8105b3e 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "@ns/core": "workspace:*", - "@ns/ui-utils": "workspace:*" + "@ns/ui-utils": "workspace:*", + "lzutf8": "^0.6.3" }, "devDependencies": { "@types/node": "^25.5.2", diff --git a/packages/web/src/components/ComparisonView.tsx b/packages/web/src/components/ComparisonView.tsx index 7bc516c..a340cae 100644 --- a/packages/web/src/components/ComparisonView.tsx +++ b/packages/web/src/components/ComparisonView.tsx @@ -5,6 +5,7 @@ import ArrowRightIcon from 'lucide-solid/icons/arrow-right'; import ArrowDownIcon from 'lucide-solid/icons/arrow-down'; import ArrowUpIcon from 'lucide-solid/icons/arrow-up'; import XIcon from 'lucide-solid/icons/x'; +import ShareIcon from 'lucide-solid/icons/share'; import FileUpload from './FileUpload'; interface ComparisonViewProps { @@ -17,6 +18,10 @@ interface ComparisonViewProps { onPasteStats: (text: string, name: string) => ComparisonEntry | null; onFileLoad: (data: StatsData, raw: Record) => void; onTextLoad: (text: string) => void; + onGenerateShareUrl: (left: ComparisonEntry, right: ComparisonEntry) => void; + initialLeftId?: number | null; + initialRightId?: number | null; + onInitialSelectionUsed?: () => void; } const ComparisonView: Component = props => { @@ -47,6 +52,22 @@ const ComparisonView: Component = props => { onMount(() => { document.addEventListener('paste', handlePaste); + + if (props.initialLeftId !== null && props.initialLeftId !== undefined) { + const left = props.entries.find(e => e.id === props.initialLeftId); + if (left) { + setLeftEntry(left); + } + } + if (props.initialRightId !== null && props.initialRightId !== undefined) { + const right = props.entries.find(e => e.id === props.initialRightId); + if (right) { + setRightEntry(right); + } + } + if (props.initialLeftId !== null || props.initialRightId !== null) { + props.onInitialSelectionUsed?.(); + } }); onCleanup(() => { @@ -192,6 +213,16 @@ const ComparisonView: Component = props => { Replace + + + diff --git a/packages/web/src/index.tsx b/packages/web/src/index.tsx index e6c601f..fa56eaa 100644 --- a/packages/web/src/index.tsx +++ b/packages/web/src/index.tsx @@ -1,13 +1,59 @@ import { createSignal, Show, For, onMount, createEffect, lazy } from 'solid-js'; import { render } from 'solid-js/web'; +import LZUTF8 from 'lzutf8'; import SaveIcon from 'lucide-solid/icons/save'; import UploadIcon from 'lucide-solid/icons/upload'; import Trash2Icon from 'lucide-solid/icons/trash-2'; import XIcon from 'lucide-solid/icons/x'; +import ShareIcon from 'lucide-solid/icons/link-2'; import FileUpload from './components/FileUpload'; import { StatsData, ComparisonEntry, parseStats } from '@ns/core'; import './styles.css'; +type ShareState = + | { type: 'analysis'; data: Record; name: string } + | { + type: 'compare'; + left: Record; + right: Record; + leftName: string; + rightName: string; + }; + +function encodeShareUrl(state: ShareState): string { + const json = JSON.stringify(state); + const encoded = LZUTF8.compress(json, { outputEncoding: 'Base64' }) as string; + return encoded.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +function decodeShareUrl(encoded: string): ShareState | null { + try { + const base64 = encoded.replace(/-/g, '+').replace(/_/g, '/'); + const json = LZUTF8.decompress(base64, { + inputEncoding: 'Base64', + outputEncoding: 'String', + }) as string | null; + if (!json) { + return null; + } + const state = JSON.parse(json) as ShareState; + if (state.type === 'analysis') { + if (!state.data || !state.name) { + return null; + } + } else if (state.type === 'compare') { + if (!state.left || !state.right || !state.leftName || !state.rightName) { + return null; + } + } else { + return null; + } + return state; + } catch { + return null; + } +} + function debounce) => ReturnType>( fn: T, delay: number, @@ -35,6 +81,9 @@ function App() { const [isLoading, setIsLoading] = createSignal(true); const [showOverrideModal, setShowOverrideModal] = createSignal(false); const [pendingOverrideText, setPendingOverrideText] = createSignal(''); + const [showShareToast, setShowShareToast] = createSignal(false); + const [initialLeftId, setInitialLeftId] = createSignal(null); + const [initialRightId, setInitialRightId] = createSignal(null); const STORAGE_KEY = 'ns-data'; @@ -71,6 +120,51 @@ function App() { } catch (e) { console.warn('Failed to load saved data:', e); } + + const params = new URLSearchParams(window.location.search); + const shareParam = params.get('share'); + if (shareParam) { + const shareState = decodeShareUrl(shareParam); + if (shareState) { + try { + if (shareState.type === 'analysis') { + const data = parseStats(shareState.data); + setCurrentStats(data); + setCurrentRaw(shareState.data); + setSnapshotName(shareState.name); + setView('analysis'); + window.history.replaceState({}, '', window.location.pathname); + } else if (shareState.type === 'compare') { + const leftData = parseStats(shareState.left); + const rightData = parseStats(shareState.right); + const leftId = Date.now(); + const rightId = Date.now() + 1; + const leftEntry: ComparisonEntry = { + id: leftId, + name: shareState.leftName, + data: leftData, + raw: shareState.left, + timestamp: new Date(), + }; + const rightEntry: ComparisonEntry = { + id: rightId, + name: shareState.rightName, + data: rightData, + raw: shareState.right, + timestamp: new Date(), + }; + setSnapshots([leftEntry, rightEntry]); + setInitialLeftId(leftId); + setInitialRightId(rightId); + setView('compare'); + window.history.replaceState({}, '', window.location.pathname); + } + } catch (e) { + console.warn('Failed to load shared data:', e); + } + } + } + setIsLoading(false); const handleKeyDown = (e: KeyboardEvent) => { @@ -166,6 +260,20 @@ function App() { setShowSaveDialog(false); }; + const shareAnalysis = () => { + const stats = currentStats(); + const raw = currentRaw(); + if (!stats || !raw) return; + const name = snapshotName().trim() || `Analysis ${Date.now()}`; + const state: ShareState = { type: 'analysis', data: raw, name }; + const encoded = encodeShareUrl(state); + const url = `${window.location.origin}${window.location.pathname}?share=${encoded}`; + navigator.clipboard.writeText(url).then(() => { + setShowShareToast(true); + setTimeout(() => setShowShareToast(false), 2000); + }); + }; + const deleteSnapshot = (id: number) => { setSnapshots(prev => prev.filter(e => e.id !== id)); }; @@ -248,6 +356,22 @@ function App() { } }; + const handleGenerateShareUrl = (left: ComparisonEntry, right: ComparisonEntry) => { + const state: ShareState = { + type: 'compare', + left: left.raw, + right: right.raw, + leftName: left.name, + rightName: right.name, + }; + const encoded = encodeShareUrl(state); + const url = `${window.location.origin}${window.location.pathname}?share=${encoded}`; + navigator.clipboard.writeText(url).then(() => { + setShowShareToast(true); + setTimeout(() => setShowShareToast(false), 2000); + }); + }; + return (
@@ -364,6 +488,13 @@ function App() { onPasteStats={handlePasteStats} onFileLoad={handleCompareFileLoad} onTextLoad={handleCompareTextLoad} + onGenerateShareUrl={handleGenerateShareUrl} + initialLeftId={initialLeftId()} + initialRightId={initialRightId()} + onInitialSelectionUsed={() => { + setInitialLeftId(null); + setInitialRightId(null); + }} /> @@ -429,11 +560,18 @@ function App() { > +
+ + +
Share URL copied to clipboard!
+
); } diff --git a/packages/web/src/styles.css b/packages/web/src/styles.css index 86e387d..a5c8531 100644 --- a/packages/web/src/styles.css +++ b/packages/web/src/styles.css @@ -444,6 +444,16 @@ body { transform: scale(1.05); } +.action-btn.share { + background: var(--accent); + color: white; +} + +.action-btn.share:hover { + background: var(--accent-hover); + transform: scale(1.05); +} + .action-btn.clear { background: var(--bg-secondary); color: var(--text-secondary); @@ -1496,6 +1506,26 @@ body { color: white; } +.share-btn { + display: flex; + align-items: center; + gap: 0.375rem; + height: calc(0.75rem * 2 + 1rem + 2px); + padding: 0 0.875rem; + background: var(--accent); + border: none; + color: white; + border-radius: 0.375rem; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.share-btn:hover { + background: var(--accent-hover); +} + .compare-placeholder { text-align: center; padding: 4rem; @@ -1768,3 +1798,30 @@ body { font-size: 0.75rem; color: var(--text-muted); } + +.toast { + position: fixed; + bottom: 6rem; + left: 50%; + transform: translateX(-50%); + padding: 0.875rem 1.5rem; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + color: var(--text-primary); + font-size: 0.875rem; + box-shadow: var(--shadow); + z-index: 1000; + animation: toast-in 0.2s ease; +} + +@keyframes toast-in { + from { + opacity: 0; + transform: translateX(-50%) translateY(0.5rem); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6802e54..ef4f390 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: '@ns/ui-utils': specifier: workspace:* version: link:../ui-utils + lzutf8: + specifier: ^0.6.3 + version: 0.6.3 devDependencies: '@types/node': specifier: ^25.5.2 @@ -920,6 +923,13 @@ packages: } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + abort-controller@3.0.0: + resolution: + { + integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==, + } + engines: { node: '>=6.5' } + acorn-jsx@5.3.2: resolution: { @@ -969,6 +979,12 @@ packages: } engines: { node: 18 || 20 || >=22 } + base64-js@1.5.1: + resolution: + { + integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==, + } + baseline-browser-mapping@2.10.16: resolution: { @@ -992,6 +1008,12 @@ packages: engines: { node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7 } hasBin: true + buffer@6.0.3: + resolution: + { + integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==, + } + caniuse-lite@1.0.30001787: resolution: { @@ -1155,6 +1177,20 @@ packages: } engines: { node: '>=0.10.0' } + event-target-shim@5.0.1: + resolution: + { + integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==, + } + engines: { node: '>=6' } + + events@3.3.0: + resolution: + { + integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==, + } + engines: { node: '>=0.8.x' } + fast-deep-equal@3.1.3: resolution: { @@ -1240,6 +1276,12 @@ packages: integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==, } + ieee754@1.2.1: + resolution: + { + integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==, + } + ignore@5.3.2: resolution: { @@ -1472,6 +1514,12 @@ packages: peerDependencies: solid-js: ^1.4.7 + lzutf8@0.6.3: + resolution: + { + integrity: sha512-CAkF9HKrM+XpB0f3DepQ2to2iUEo0zrbh+XgBqgNBc1+k8HMM3u/YSfHI3Dr4GmoTIez2Pr/If1XFl3rU26AwA==, + } + merge-anything@5.1.7: resolution: { @@ -1588,6 +1636,13 @@ packages: engines: { node: '>=14' } hasBin: true + process@0.11.10: + resolution: + { + integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==, + } + engines: { node: '>= 0.6.0' } + punycode@2.3.1: resolution: { @@ -1595,6 +1650,13 @@ packages: } engines: { node: '>=6' } + readable-stream@4.7.0: + resolution: + { + integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==, + } + engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } + rolldown@1.0.0-rc.15: resolution: { @@ -1603,6 +1665,12 @@ packages: engines: { node: ^20.19.0 || >=22.12.0 } hasBin: true + safe-buffer@5.2.1: + resolution: + { + integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==, + } + semver@6.3.1: resolution: { @@ -1669,6 +1737,12 @@ packages: } engines: { node: '>=0.10.0' } + string_decoder@1.3.0: + resolution: + { + integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==, + } + tinyglobby@0.2.16: resolution: { @@ -2288,6 +2362,10 @@ snapshots: '@typescript-eslint/types': 8.58.1 eslint-visitor-keys: 5.0.1 + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -2319,6 +2397,8 @@ snapshots: balanced-match@4.0.4: {} + base64-js@1.5.1: {} + baseline-browser-mapping@2.10.16: {} brace-expansion@5.0.5: @@ -2333,6 +2413,11 @@ snapshots: node-releases: 2.0.37 update-browserslist-db: 1.2.3(browserslist@4.28.2) + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + caniuse-lite@1.0.30001787: {} convert-source-map@2.0.0: {} @@ -2459,6 +2544,10 @@ snapshots: esutils@2.0.3: {} + event-target-shim@5.0.1: {} + + events@3.3.0: {} + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -2496,6 +2585,8 @@ snapshots: html-entities@2.3.3: {} + ieee754@1.2.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -2594,6 +2685,10 @@ snapshots: dependencies: solid-js: 1.9.12 + lzutf8@0.6.3: + dependencies: + readable-stream: 4.7.0 + merge-anything@5.1.7: dependencies: is-what: 4.1.16 @@ -2649,8 +2744,18 @@ snapshots: prettier@3.8.1: {} + process@0.11.10: {} + punycode@2.3.1: {} + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + rolldown@1.0.0-rc.15: dependencies: '@oxc-project/types': 0.124.0 @@ -2672,6 +2777,8 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.15 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.15 + safe-buffer@5.2.1: {} + semver@6.3.1: {} semver@7.7.4: {} @@ -2705,6 +2812,10 @@ snapshots: source-map-js@1.2.1: {} + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4)