packages/web: allow sharing analysis and comparison views independently

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I16408e124ebcb36e8452d9c261f6d42f6a6a6964
This commit is contained in:
raf 2026-04-16 08:40:10 +03:00
commit 8d7bd7bb05
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
5 changed files with 339 additions and 1 deletions

View file

@ -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<string, unknown>; name: string }
| {
type: 'compare';
left: Record<string, unknown>;
right: Record<string, unknown>;
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<T extends (...args: Parameters<T>) => ReturnType<T>>(
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<number | null>(null);
const [initialRightId, setInitialRightId] = createSignal<number | null>(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 (
<div class="app">
<header class="header">
@ -364,6 +488,13 @@ function App() {
onPasteStats={handlePasteStats}
onFileLoad={handleCompareFileLoad}
onTextLoad={handleCompareTextLoad}
onGenerateShareUrl={handleGenerateShareUrl}
initialLeftId={initialLeftId()}
initialRightId={initialRightId()}
onInitialSelectionUsed={() => {
setInitialLeftId(null);
setInitialRightId(null);
}}
/>
</Show>
</Show>
@ -429,11 +560,18 @@ function App() {
>
<SaveIcon size={20} />
</button>
<button class="action-btn share" onClick={shareAnalysis} title="Share Analysis">
<ShareIcon size={20} />
</button>
<button class="action-btn clear" onClick={() => setCurrentStats(null)} title="Load New">
<UploadIcon size={20} />
</button>
</div>
</Show>
<Show when={showShareToast()}>
<div class="toast">Share URL copied to clipboard!</div>
</Show>
</div>
);
}