mirror of
https://github.com/NotAShelf/nix-evaluator-stats.git
synced 2026-05-02 22:23:16 +00:00
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I26cb6848615a5788f2196fc1675bc22d6a6a6964
551 lines
18 KiB
TypeScript
551 lines
18 KiB
TypeScript
import { createSignal, Show, For, onMount, createEffect, lazy } from 'solid-js';
|
|
import { render } from 'solid-js/web';
|
|
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,
|
|
encodeShareUrl,
|
|
decodeShareUrl,
|
|
ShareState,
|
|
} from '@ns/core';
|
|
import './styles.css';
|
|
|
|
function debounce<T extends (...args: Parameters<T>) => ReturnType<T>>(
|
|
fn: T,
|
|
delay: number,
|
|
): (...args: Parameters<T>) => void {
|
|
let timeoutId: ReturnType<typeof setTimeout>;
|
|
return (...args: Parameters<T>) => {
|
|
clearTimeout(timeoutId);
|
|
timeoutId = setTimeout(() => fn(...args), delay);
|
|
};
|
|
}
|
|
|
|
const Analysis = lazy(() => import('./components/Analysis'));
|
|
const ComparisonView = lazy(() => import('./components/ComparisonView'));
|
|
|
|
function App() {
|
|
const [currentStats, setCurrentStats] = createSignal<StatsData | null>(null);
|
|
const [currentRaw, setCurrentRaw] = createSignal<Record<string, unknown> | null>(null);
|
|
const [snapshots, setSnapshots] = createSignal<ComparisonEntry[]>([]);
|
|
const [view, setView] = createSignal<'analysis' | 'compare'>('analysis');
|
|
const [snapshotName, setSnapshotName] = createSignal('');
|
|
const [showSaveDialog, setShowSaveDialog] = createSignal(false);
|
|
const [showManageSnapshots, setShowManageSnapshots] = createSignal(false);
|
|
const [precision, setPrecision] = createSignal(2);
|
|
const [pasteMode, setPasteMode] = createSignal<'advance' | 'replace'>('advance');
|
|
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';
|
|
|
|
// Load from localStorage on mount
|
|
onMount(() => {
|
|
try {
|
|
const saved = localStorage.getItem(STORAGE_KEY);
|
|
if (saved) {
|
|
const parsed = JSON.parse(saved);
|
|
if (Array.isArray(parsed.snapshots)) {
|
|
setSnapshots(
|
|
parsed.snapshots.map((s: ComparisonEntry) => ({
|
|
...s,
|
|
raw: s.raw || {},
|
|
})),
|
|
);
|
|
}
|
|
if (parsed.currentStats) {
|
|
setCurrentStats(parsed.currentStats);
|
|
}
|
|
if (parsed.currentRaw) {
|
|
setCurrentRaw(parsed.currentRaw);
|
|
}
|
|
if (parsed.view) {
|
|
setView(parsed.view);
|
|
}
|
|
if (typeof parsed.precision === 'number' && parsed.precision >= 0) {
|
|
setPrecision(parsed.precision);
|
|
}
|
|
if (parsed.pasteMode === 'advance' || parsed.pasteMode === 'replace') {
|
|
setPasteMode(parsed.pasteMode);
|
|
}
|
|
}
|
|
} 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) => {
|
|
if (e.key === 'Escape') {
|
|
if (showSaveDialog()) setShowSaveDialog(false);
|
|
if (showManageSnapshots()) setShowManageSnapshots(false);
|
|
}
|
|
};
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
|
|
const handleAnalysisPaste = (e: ClipboardEvent) => {
|
|
const target = e.target as HTMLElement;
|
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
|
return;
|
|
}
|
|
const text = e.clipboardData?.getData('text');
|
|
if (!text) return;
|
|
try {
|
|
JSON.parse(text);
|
|
} catch {
|
|
return;
|
|
}
|
|
if (view() === 'analysis' && currentStats()) {
|
|
setPendingOverrideText(text);
|
|
setShowOverrideModal(true);
|
|
}
|
|
};
|
|
document.addEventListener('paste', handleAnalysisPaste);
|
|
|
|
return () => {
|
|
window.removeEventListener('keydown', handleKeyDown);
|
|
document.removeEventListener('paste', handleAnalysisPaste);
|
|
};
|
|
});
|
|
|
|
// Debounced save to localStorage
|
|
const saveToStorage = debounce(
|
|
(
|
|
stats: StatsData | null,
|
|
raw: Record<string, unknown> | null,
|
|
snaps: ComparisonEntry[],
|
|
v: 'analysis' | 'compare',
|
|
prec: number,
|
|
pm: 'advance' | 'replace',
|
|
) => {
|
|
try {
|
|
localStorage.setItem(
|
|
STORAGE_KEY,
|
|
JSON.stringify({
|
|
snapshots: snaps,
|
|
currentStats: stats,
|
|
currentRaw: raw,
|
|
view: v,
|
|
precision: prec,
|
|
pasteMode: pm,
|
|
}),
|
|
);
|
|
} catch (e) {
|
|
console.warn('Failed to save data:', e);
|
|
}
|
|
},
|
|
500,
|
|
);
|
|
|
|
// Save to localStorage on any change
|
|
createEffect(() => {
|
|
const stats = currentStats();
|
|
const raw = currentRaw();
|
|
const snaps = snapshots();
|
|
const v = view();
|
|
const prec = precision();
|
|
const pm = pasteMode();
|
|
|
|
if (isLoading()) return;
|
|
|
|
saveToStorage(stats, raw, snaps, v, prec, pm);
|
|
});
|
|
|
|
const saveSnapshot = () => {
|
|
const stats = currentStats();
|
|
const raw = currentRaw();
|
|
if (!stats || !raw) return;
|
|
const name = snapshotName().trim() || `Snapshot ${snapshots().length + 1}`;
|
|
const entry: ComparisonEntry = {
|
|
id: Date.now(),
|
|
name,
|
|
data: stats,
|
|
raw,
|
|
timestamp: new Date(),
|
|
};
|
|
setSnapshots(prev => [...prev, entry]);
|
|
setSnapshotName('');
|
|
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);
|
|
})
|
|
.catch(err => {
|
|
console.error('Failed to copy share URL:', err);
|
|
});
|
|
};
|
|
|
|
const deleteSnapshot = (id: number) => {
|
|
setSnapshots(prev => prev.filter(e => e.id !== id));
|
|
};
|
|
|
|
const clearAllSnapshots = () => {
|
|
if (confirm('Delete all saved snapshots?')) {
|
|
setSnapshots([]);
|
|
}
|
|
};
|
|
|
|
const loadSnapshot = (entry: ComparisonEntry) => {
|
|
setCurrentStats(entry.data);
|
|
setView('analysis');
|
|
setShowManageSnapshots(false);
|
|
};
|
|
|
|
const handleFileLoad = (data: StatsData, raw: Record<string, unknown>) => {
|
|
setCurrentStats(data);
|
|
setCurrentRaw(raw);
|
|
setView('analysis');
|
|
};
|
|
|
|
const loadFromText = (text: string) => {
|
|
try {
|
|
const raw = JSON.parse(text);
|
|
const data = parseStats(raw);
|
|
setCurrentStats(data);
|
|
setCurrentRaw(raw);
|
|
} catch (e) {
|
|
console.error('Failed to parse stats:', e);
|
|
}
|
|
};
|
|
|
|
const handlePasteStats = (text: string, name: string): ComparisonEntry | null => {
|
|
let raw: Record<string, unknown>;
|
|
let data: StatsData;
|
|
try {
|
|
raw = JSON.parse(text);
|
|
data = parseStats(raw);
|
|
} catch (e) {
|
|
console.error('Failed to parse pasted stats:', e);
|
|
return null;
|
|
}
|
|
const entry: ComparisonEntry = {
|
|
id: Date.now(),
|
|
name: name.trim() || `Snapshot ${snapshots().length + 1}`,
|
|
data,
|
|
raw,
|
|
timestamp: new Date(),
|
|
};
|
|
setSnapshots(prev => [...prev, entry]);
|
|
return entry;
|
|
};
|
|
|
|
const handleCompareFileLoad = (data: StatsData, raw: Record<string, unknown>) => {
|
|
const entry: ComparisonEntry = {
|
|
id: Date.now(),
|
|
name: `Snapshot ${snapshots().length + 1}`,
|
|
data,
|
|
raw,
|
|
timestamp: new Date(),
|
|
};
|
|
setSnapshots(prev => [...prev, entry]);
|
|
};
|
|
|
|
const handleCompareTextLoad = (text: string) => {
|
|
try {
|
|
const raw = JSON.parse(text);
|
|
const data = parseStats(raw);
|
|
const entry: ComparisonEntry = {
|
|
id: Date.now(),
|
|
name: `Snapshot ${snapshots().length + 1}`,
|
|
data,
|
|
raw,
|
|
timestamp: new Date(),
|
|
};
|
|
setSnapshots(prev => [...prev, entry]);
|
|
} catch (e) {
|
|
console.error('Failed to parse stats:', e);
|
|
}
|
|
};
|
|
|
|
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);
|
|
})
|
|
.catch(err => {
|
|
console.error('Failed to copy share URL:', err);
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div class="app">
|
|
<header class="header">
|
|
<div class="header-left">
|
|
<h1>NS</h1>
|
|
</div>
|
|
<nav class="nav">
|
|
<button class={view() === 'analysis' ? 'active' : ''} onClick={() => setView('analysis')}>
|
|
Analysis
|
|
</button>
|
|
<button class={view() === 'compare' ? 'active' : ''} onClick={() => setView('compare')}>
|
|
Compare ({snapshots().length})
|
|
</button>
|
|
<Show when={snapshots().length > 0}>
|
|
<button
|
|
class={showManageSnapshots() ? 'active' : ''}
|
|
onClick={() => setShowManageSnapshots(true)}
|
|
title="Manage Snapshots"
|
|
>
|
|
<Trash2Icon size={16} />
|
|
</button>
|
|
</Show>
|
|
<select
|
|
class="precision-select"
|
|
value={precision()}
|
|
onChange={e => setPrecision(parseInt(e.currentTarget.value, 10))}
|
|
title="Decimal Precision"
|
|
>
|
|
<For each={[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]}>
|
|
{p => <option value={p}>{p} dp</option>}
|
|
</For>
|
|
</select>
|
|
</nav>
|
|
</header>
|
|
|
|
<main class="main">
|
|
<Show when={isLoading()}>
|
|
<div class="loading">
|
|
<div class="spinner"></div>
|
|
<span>Loading saved data...</span>
|
|
</div>
|
|
</Show>
|
|
<Show when={!isLoading()}>
|
|
<Show when={view() === 'analysis'}>
|
|
<Show when={!currentStats()}>
|
|
<FileUpload
|
|
onFileLoad={handleFileLoad}
|
|
onTextLoad={loadFromText}
|
|
snapshots={snapshots()}
|
|
onLoadSnapshot={loadSnapshot}
|
|
/>
|
|
</Show>
|
|
<Show when={currentStats()}>
|
|
<Analysis stats={currentStats()!} precision={precision()} />
|
|
<Show when={showSaveDialog()}>
|
|
<div class="modal-overlay" onClick={() => setShowSaveDialog(false)}>
|
|
<div class="modal" onClick={e => e.stopPropagation()}>
|
|
<h3>Save Snapshot</h3>
|
|
<input
|
|
type="text"
|
|
class="snapshot-name-input"
|
|
placeholder="Enter snapshot name..."
|
|
value={snapshotName()}
|
|
onInput={e => setSnapshotName(e.currentTarget.value)}
|
|
onKeyDown={e => e.key === 'Enter' && saveSnapshot()}
|
|
autofocus
|
|
/>
|
|
<div class="modal-actions">
|
|
<button class="cancel-btn" onClick={() => setShowSaveDialog(false)}>
|
|
Cancel
|
|
</button>
|
|
<button class="confirm-btn" onClick={saveSnapshot}>
|
|
Save
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
<Show when={showOverrideModal()}>
|
|
<div class="modal-overlay" onClick={() => setShowOverrideModal(false)}>
|
|
<div class="modal" onClick={e => e.stopPropagation()}>
|
|
<h3>Override Analysis?</h3>
|
|
<p style={{ color: 'var(--text-secondary)', 'margin-bottom': '1rem' }}>
|
|
This will replace the current analysis with pasted data.
|
|
</p>
|
|
<div class="modal-actions">
|
|
<button class="cancel-btn" onClick={() => setShowOverrideModal(false)}>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
class="confirm-btn"
|
|
onClick={() => {
|
|
loadFromText(pendingOverrideText());
|
|
setShowOverrideModal(false);
|
|
}}
|
|
>
|
|
Override
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
</Show>
|
|
</Show>
|
|
|
|
<Show when={view() === 'compare'}>
|
|
<ComparisonView
|
|
entries={snapshots()}
|
|
onSelect={entry => setCurrentStats(entry.data)}
|
|
onDelete={deleteSnapshot}
|
|
precision={precision()}
|
|
pasteMode={pasteMode()}
|
|
onPasteModeChange={setPasteMode}
|
|
onPasteStats={handlePasteStats}
|
|
onFileLoad={handleCompareFileLoad}
|
|
onTextLoad={handleCompareTextLoad}
|
|
onGenerateShareUrl={handleGenerateShareUrl}
|
|
initialLeftId={initialLeftId()}
|
|
initialRightId={initialRightId()}
|
|
onInitialSelectionUsed={() => {
|
|
setInitialLeftId(null);
|
|
setInitialRightId(null);
|
|
}}
|
|
/>
|
|
</Show>
|
|
</Show>
|
|
</main>
|
|
|
|
<footer class="footer">
|
|
<a
|
|
href="https://github.com/notashelf/ns"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="footer-link"
|
|
>
|
|
<img src="/assets/github.svg" alt="GitHub" width={16} height={16} />
|
|
Source
|
|
</a>
|
|
</footer>
|
|
|
|
<Show when={showManageSnapshots()}>
|
|
<div class="modal-overlay" onClick={() => setShowManageSnapshots(false)}>
|
|
<div class="modal modal-large" onClick={e => e.stopPropagation()}>
|
|
<div class="modal-header">
|
|
<h3>Manage Snapshots</h3>
|
|
<button class="close-btn" onClick={() => setShowManageSnapshots(false)}>
|
|
<XIcon size={20} />
|
|
</button>
|
|
</div>
|
|
<div class="snapshot-list-manage">
|
|
<For each={snapshots()} fallback={<div class="empty-state">No snapshots saved</div>}>
|
|
{entry => (
|
|
<div class="snapshot-manage-item">
|
|
<div class="snapshot-info" onClick={() => loadSnapshot(entry)}>
|
|
<span class="snapshot-name">{entry.name}</span>
|
|
<span class="snapshot-date">
|
|
{new Date(entry.timestamp).toLocaleDateString()}{' '}
|
|
{new Date(entry.timestamp).toLocaleTimeString()}
|
|
</span>
|
|
</div>
|
|
<button class="delete-btn" onClick={() => deleteSnapshot(entry.id)}>
|
|
<XIcon size={16} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</For>
|
|
</div>
|
|
<Show when={snapshots().length > 0}>
|
|
<div class="modal-actions">
|
|
<button class="danger-btn" onClick={clearAllSnapshots}>
|
|
<Trash2Icon size={16} />
|
|
Clear All
|
|
</button>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={currentStats() && view() === 'analysis'}>
|
|
<div class="floating-actions">
|
|
<button
|
|
class="action-btn save"
|
|
onClick={() => setShowSaveDialog(true)}
|
|
title="Save Snapshot"
|
|
>
|
|
<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>
|
|
);
|
|
}
|
|
|
|
render(() => <App />, document.getElementById('root')!);
|