mirror of
https://github.com/NotAShelf/nix-evaluator-stats.git
synced 2026-05-18 21:17:36 +00:00
treewide: adapt for monorepo layout; initial TUI work
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Id40b5f5ccb55a8a1ea2793192a38f0256a6a6964
This commit is contained in:
parent
e36b0d89da
commit
33ec901788
35 changed files with 1699 additions and 413 deletions
320
packages/web/src/index.tsx
Normal file
320
packages/web/src/index.tsx
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
import { createSignal, Show, For, onMount, createEffect, lazy } from 'solid-js';
|
||||
import { render } from 'solid-js/web';
|
||||
import { Github, Save, Upload, Trash2, X } from 'lucide-solid';
|
||||
import FileUpload from './components/FileUpload';
|
||||
import { StatsData, ComparisonEntry, parseStats } 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 [showHelp, setShowHelp] = createSignal(false);
|
||||
const [showManageSnapshots, setShowManageSnapshots] = createSignal(false);
|
||||
const [isLoading, setIsLoading] = createSignal(true);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load saved data:', e);
|
||||
}
|
||||
setIsLoading(false);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (showSaveDialog()) setShowSaveDialog(false);
|
||||
if (showManageSnapshots()) setShowManageSnapshots(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
});
|
||||
|
||||
// Debounced save to localStorage
|
||||
const saveToStorage = debounce(
|
||||
(
|
||||
stats: StatsData | null,
|
||||
raw: Record<string, unknown> | null,
|
||||
snaps: ComparisonEntry[],
|
||||
v: 'analysis' | 'compare',
|
||||
) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
snapshots: snaps,
|
||||
currentStats: stats,
|
||||
currentRaw: raw,
|
||||
view: v,
|
||||
}),
|
||||
);
|
||||
} 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();
|
||||
|
||||
if (isLoading()) return;
|
||||
|
||||
saveToStorage(stats, raw, snaps, v);
|
||||
});
|
||||
|
||||
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 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);
|
||||
}
|
||||
};
|
||||
|
||||
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')}
|
||||
disabled={snapshots().length < 2}
|
||||
>
|
||||
Compare ({snapshots().length})
|
||||
</button>
|
||||
<Show when={snapshots().length > 0}>
|
||||
<button
|
||||
class={showManageSnapshots() ? 'active' : ''}
|
||||
onClick={() => setShowManageSnapshots(true)}
|
||||
title="Manage Snapshots"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</Show>
|
||||
</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}
|
||||
showHelp={showHelp()}
|
||||
onToggleHelp={() => setShowHelp(!showHelp())}
|
||||
snapshots={snapshots()}
|
||||
onLoadSnapshot={loadSnapshot}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={currentStats()}>
|
||||
<Analysis stats={currentStats()!} />
|
||||
<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>
|
||||
</Show>
|
||||
|
||||
<Show when={view() === 'compare'}>
|
||||
<ComparisonView
|
||||
entries={snapshots()}
|
||||
onSelect={entry => setCurrentStats(entry.data)}
|
||||
onDelete={deleteSnapshot}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<a
|
||||
href="https://github.com/notashelf/ns"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="footer-link"
|
||||
>
|
||||
<Github size={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)}>
|
||||
<X 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)}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<Show when={snapshots().length > 0}>
|
||||
<div class="modal-actions">
|
||||
<button class="danger-btn" onClick={clearAllSnapshots}>
|
||||
<Trash2 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"
|
||||
>
|
||||
<Save size={20} />
|
||||
</button>
|
||||
<button class="action-btn clear" onClick={() => setCurrentStats(null)} title="Load New">
|
||||
<Upload size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render(() => <App />, document.getElementById('root')!);
|
||||
Loading…
Add table
Add a link
Reference in a new issue