feat: redesign UI to match lovable compact sidebar layout

- Replace green hacker theme with professional blue-toned design
- Light theme default, manual toggle only (no system detection)
- Compact w-60 sidebar with collapsible sections
- New CSS tokens: sidebar, chart, candle, annotation colors
- Tools displayed as compact grid buttons
- Color swatches as inline bar
- Chart top bar with keyboard shortcut hints
- Inter + JetBrains Mono font pairing
- All components updated for compact styling
- Tailwind config extended with sidebar/chart tokens
This commit is contained in:
Marko Djordjevic 2026-02-16 20:50:30 +01:00
parent 2bde38d0bf
commit 4605283d2b
13 changed files with 976 additions and 740 deletions

0
candle_annotator.db Normal file
View file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,54 +1,103 @@
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap');
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base { @layer base {
:root { :root {
/* Light theme - off-white backgrounds, green-600 accents, near-black text, gray borders */ --background: 0 0% 98%;
--background: 120 25% 97%; /* #f8faf8 */ --foreground: 220 20% 10%;
--foreground: 240 15% 10%; /* #1a1a2e near-black */ --card: 0 0% 100%;
--card: 0 0% 100%; /* #ffffff */ --card-foreground: 220 20% 10%;
--card-foreground: 240 15% 10%;
--popover: 0 0% 100%; --popover: 0 0% 100%;
--popover-foreground: 240 15% 10%; --popover-foreground: 220 20% 10%;
--primary: 142 76% 36%; /* #16a34a green-600 */ --primary: 220 70% 45%;
--primary-foreground: 0 0% 100%; --primary-foreground: 0 0% 100%;
--secondary: 120 10% 95%; /* #f0f4f0 */ --secondary: 220 14% 92%;
--secondary-foreground: 240 15% 10%; --secondary-foreground: 220 20% 20%;
--muted: 120 10% 95%; --muted: 220 14% 94%;
--muted-foreground: 220 9% 46%; /* #4a5568 gray-600 */ --muted-foreground: 215 12% 46%;
--accent: 142 76% 36%; /* green-600 */ --accent: 220 14% 90%;
--accent-foreground: 0 0% 100%; --accent-foreground: 220 20% 10%;
--destructive: 348 100% 50%; /* #ff0040 neon red */ --destructive: 0 72% 51%;
--destructive-foreground: 0 0% 100%; --destructive-foreground: 0 0% 100%;
--border: 220 13% 84%; /* #d1d5db gray-300 */ --border: 220 13% 87%;
--input: 220 13% 84%; --input: 220 13% 87%;
--ring: 142 76% 36%; --ring: 220 70% 45%;
--radius: 0.5rem; --radius: 0.375rem;
--candle-bull: 0 0% 100%;
--candle-bull-stroke: 0 0% 10%;
--candle-bear: 0 0% 10%;
--candle-bear-stroke: 0 0% 10%;
--annotation-blue: 217 91% 60%;
--annotation-red: 0 72% 51%;
--annotation-green: 152 69% 45%;
--annotation-orange: 38 92% 50%;
--annotation-purple: 263 70% 58%;
--sidebar-background: 0 0% 100%;
--sidebar-foreground: 220 20% 20%;
--sidebar-primary: 220 70% 45%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 220 14% 94%;
--sidebar-accent-foreground: 220 20% 10%;
--sidebar-border: 220 13% 90%;
--sidebar-ring: 220 70% 45%;
--chart-bg: 0 0% 100%;
--chart-grid: 220 13% 92%;
--chart-crosshair: 220 10% 60%;
--chart-text: 215 12% 46%;
} }
.dark { .dark {
/* Dark hacker theme - terminal blacks, matrix green #00ff41 accents, neon text */ --background: 220 20% 7%;
--background: 120 38% 3%; /* #0a0e0a near-black */ --foreground: 210 20% 90%;
--foreground: 120 100% 63%; /* #00ff41 matrix green */ --card: 220 18% 10%;
--card: 120 29% 5%; /* #0d110d */ --card-foreground: 210 20% 90%;
--card-foreground: 120 100% 63%; --popover: 220 18% 12%;
--popover: 120 29% 5%; --popover-foreground: 210 20% 90%;
--popover-foreground: 120 100% 63%; --primary: 217 91% 60%;
--primary: 120 100% 63%; /* #00ff41 matrix green */ --primary-foreground: 220 20% 7%;
--primary-foreground: 120 38% 3%; --secondary: 220 16% 16%;
--secondary: 120 100% 50%; /* #00cc33 */ --secondary-foreground: 210 20% 80%;
--secondary-foreground: 120 100% 63%; --muted: 220 14% 14%;
--muted: 120 82% 10%; /* #003311 */ --muted-foreground: 215 12% 50%;
--muted-foreground: 120 100% 50%; --accent: 220 16% 18%;
--accent: 120 100% 63%; /* #00ff41 */ --accent-foreground: 210 20% 95%;
--accent-foreground: 120 38% 3%; --destructive: 0 72% 51%;
--destructive: 348 100% 50%; /* #ff0040 neon red */ --destructive-foreground: 210 20% 95%;
--destructive-foreground: 120 100% 63%; --border: 220 14% 18%;
--border: 120 82% 10%; /* #003311 inactive */ --input: 220 14% 20%;
--input: 120 82% 10%; --ring: 217 91% 60%;
--ring: 120 100% 63%;
--radius: 0.5rem; --candle-bull: 0 0% 100%;
--candle-bull-stroke: 0 0% 90%;
--candle-bear: 0 0% 10%;
--candle-bear-stroke: 0 0% 90%;
--annotation-blue: 217 91% 60%;
--annotation-red: 0 72% 51%;
--annotation-green: 152 69% 45%;
--annotation-orange: 38 92% 50%;
--annotation-purple: 263 70% 58%;
--sidebar-background: 220 20% 8%;
--sidebar-foreground: 210 20% 80%;
--sidebar-primary: 217 91% 60%;
--sidebar-primary-foreground: 220 20% 7%;
--sidebar-accent: 220 16% 14%;
--sidebar-accent-foreground: 210 20% 90%;
--sidebar-border: 220 14% 16%;
--sidebar-ring: 217 91% 60%;
--chart-bg: 220 20% 7%;
--chart-grid: 220 14% 14%;
--chart-crosshair: 210 20% 30%;
--chart-text: 215 12% 50%;
} }
} }
@ -60,98 +109,55 @@
@apply bg-background text-foreground antialiased; @apply bg-background text-foreground antialiased;
font-family: 'Inter', system-ui, -apple-system, sans-serif; font-family: 'Inter', system-ui, -apple-system, sans-serif;
} }
code, .font-mono {
font-family: 'JetBrains Mono', ui-monospace, monospace;
}
} }
@layer base { @layer base {
::selection { ::selection {
@apply bg-primary/20 text-foreground; @apply bg-primary/20 text-foreground;
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 6px;
} }
/* Light mode scrollbar */
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: hsl(var(--muted)); background: transparent;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: hsl(var(--border)); background: hsl(var(--border));
border-radius: 4px; border-radius: 3px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.5); background: hsl(var(--muted-foreground) / 0.5);
} }
/* Dark mode scrollbar */
.dark ::-webkit-scrollbar-track {
background: hsl(var(--muted));
}
.dark ::-webkit-scrollbar-thumb {
background: hsl(var(--primary));
border-radius: 4px;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: hsl(var(--secondary));
}
} }
/* CRT effects - dark mode only */
@layer utilities { @layer utilities {
.dark .crt-scanlines { .scrollbar-thin {
position: relative; scrollbar-width: thin;
} }
.scrollbar-thin::-webkit-scrollbar {
.dark .crt-scanlines::before { width: 4px;
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(
rgba(0, 0, 0, 0) 50%,
rgba(0, 0, 0, 0.05) 50%
);
background-size: 100% 4px;
pointer-events: none;
z-index: 1;
} }
.scrollbar-thin::-webkit-scrollbar-track {
.dark .neon-glow { background: transparent;
text-shadow: 0 0 10px hsl(var(--primary)),
0 0 20px hsl(var(--primary) / 0.5),
0 0 30px hsl(var(--primary) / 0.3);
} }
.scrollbar-thin::-webkit-scrollbar-thumb {
.dark .neon-glow-box { background: hsl(var(--border));
box-shadow: 0 0 10px hsl(var(--primary)), border-radius: 2px;
0 0 20px hsl(var(--primary) / 0.5);
} }
.dark .neon-glow-box:hover { .animate-fade-in {
box-shadow: 0 0 15px hsl(var(--primary)), animation: fadeIn 0.2s ease-in-out;
0 0 30px hsl(var(--primary) / 0.6),
0 0 45px hsl(var(--primary) / 0.4);
} }
@keyframes pulse-glow { @keyframes fadeIn {
0%, 100% { from { opacity: 0; }
box-shadow: 0 0 10px hsl(var(--primary)), to { opacity: 1; }
0 0 20px hsl(var(--primary) / 0.5);
}
50% {
box-shadow: 0 0 20px hsl(var(--primary)),
0 0 40px hsl(var(--primary) / 0.7),
0 0 60px hsl(var(--primary) / 0.5);
}
}
.dark .pulse-glow {
animation: pulse-glow 2s ease-in-out infinite;
} }
} }

View file

@ -14,17 +14,11 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<head>
<link
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap"
rel="stylesheet"
/>
</head>
<body className="antialiased"> <body className="antialiased">
<ThemeProvider <ThemeProvider
attribute="class" attribute="class"
defaultTheme="system" defaultTheme="light"
enableSystem enableSystem={false}
> >
{children} {children}
</ThemeProvider> </ThemeProvider>

View file

@ -6,6 +6,8 @@ import FileUpload from '@/components/FileUpload';
import CandleChart, { CandleChartHandle } from '@/components/CandleChart'; import CandleChart, { CandleChartHandle } from '@/components/CandleChart';
import ChartSelector from '@/components/ChartSelector'; import ChartSelector from '@/components/ChartSelector';
import PredictionPanel from '@/components/PredictionPanel'; import PredictionPanel from '@/components/PredictionPanel';
import SpanAnnotationList from '@/components/SpanAnnotationList';
import { ThemeToggle } from '@/components/ThemeToggle';
import type { PredictionState, PredictionSpan, ModelInfoResponse, Disagreement, DisagreementType, PredictionSummary } from '@/types/predictions'; import type { PredictionState, PredictionSpan, ModelInfoResponse, Disagreement, DisagreementType, PredictionSummary } from '@/types/predictions';
/** /**
@ -692,29 +694,25 @@ export default function Home() {
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedLabelId]); }, [selectedLabelId]);
const activeChart = charts.find((c) => c.id === activeChartId);
return ( return (
<div className="flex h-screen bg-background"> <div className="flex h-screen w-full overflow-hidden bg-background">
{/* Sidebar */} {/* Sidebar */}
<aside className="w-72 flex-shrink-0 flex flex-col border-r border-border bg-card"> <div className="flex flex-col bg-sidebar border-r border-sidebar-border w-60 flex-shrink-0 animate-fade-in">
<div className="p-6 border-b border-border"> {/* Sidebar Header */}
<h1 className="text-2xl font-semibold text-foreground">Candle Annotator</h1> <div className="flex items-center justify-between px-4 py-3 border-b border-sidebar-border">
<p className="text-sm text-muted-foreground mt-1">Chart annotation tool</p> <div>
<div className="mt-3 flex flex-col gap-1"> <h1 className="text-sm font-semibold text-foreground tracking-tight">Candle Annotator</h1>
<a <p className="text-[10px] text-muted-foreground">Chart annotation tool</p>
href="/annotation-types" </div>
className="text-sm text-muted-foreground hover:text-foreground" <div className="flex items-center gap-1">
> <ThemeToggle />
Manage Annotation Types
</a>
<a
href="/span-label-types"
className="text-sm text-muted-foreground hover:text-foreground"
>
Manage Span Label Types
</a>
</div> </div>
</div> </div>
<div className="p-6 pb-3">
{/* Chart Selector */}
<div className="px-3 py-2 border-b border-sidebar-border">
<ChartSelector <ChartSelector
charts={charts} charts={charts}
activeChartId={activeChartId} activeChartId={activeChartId}
@ -722,10 +720,14 @@ export default function Home() {
onDeleteChart={handleDeleteChart} onDeleteChart={handleDeleteChart}
/> />
</div> </div>
<div className="px-6 pb-3">
{/* File Upload */}
<div className="px-3 py-2 border-b border-sidebar-border">
<FileUpload onUploadSuccess={handleUploadSuccess} /> <FileUpload onUploadSuccess={handleUploadSuccess} />
</div> </div>
<div className="px-6 pb-6">
{/* Toolbox */}
<div className="px-3 py-2 border-b border-sidebar-border">
<Toolbox <Toolbox
activeTool={activeTool} activeTool={activeTool}
onToolChange={setActiveTool} onToolChange={setActiveTool}
@ -744,58 +746,103 @@ export default function Home() {
onDeleteSpan={handleDeleteSpan} onDeleteSpan={handleDeleteSpan}
/> />
</div> </div>
<PredictionPanel
predictionState={predictionState} {/* Annotations List - scrollable */}
onToggleVisibility={togglePredictionVisibility} <div className="flex-1 overflow-y-auto scrollbar-thin px-3 py-2 min-h-0">
onFetchPredictions={handleFetchVisiblePredictions} <SpanAnnotationList
onFetchBatchPredictions={handleFetchBatchPredictions} spanAnnotations={spanAnnotations}
onConfidenceChange={setConfidenceThreshold} spanLabelTypes={spanLabelTypes}
onToggleLabelSelection={toggleLabelSelection} selectedSpanId={selectedSpanId}
predictionSummary={predictionSummary} onSelectSpan={handleSelectedSpanChange}
isModelOnline={isModelOnline} onDeleteSpan={handleDeleteSpan}
showOnlyDisagreements={showOnlyDisagreements} />
onToggleShowOnlyDisagreements={toggleShowOnlyDisagreements} </div>
/>
</aside> {/* Predictions */}
<div className="px-3 py-2 border-t border-sidebar-border">
<PredictionPanel
predictionState={predictionState}
onToggleVisibility={togglePredictionVisibility}
onFetchPredictions={handleFetchVisiblePredictions}
onFetchBatchPredictions={handleFetchBatchPredictions}
onConfidenceChange={setConfidenceThreshold}
onToggleLabelSelection={toggleLabelSelection}
predictionSummary={predictionSummary}
isModelOnline={isModelOnline}
showOnlyDisagreements={showOnlyDisagreements}
onToggleShowOnlyDisagreements={toggleShowOnlyDisagreements}
/>
</div>
{/* Export */}
<div className="px-3 py-2 border-t border-sidebar-border">
<button
onClick={handleExport}
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs rounded bg-primary/10 hover:bg-primary/20 text-primary transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
Export JSON
</button>
</div>
</div>
{/* Main chart area */} {/* Main chart area */}
<main className="flex-1 relative bg-background"> <div className="flex-1 flex flex-col min-w-0">
{/* Loading overlay for predictions */} {/* Chart top bar */}
{predictionState.isLoading && ( <div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-card/50">
<div className="absolute inset-0 bg-background/50 backdrop-blur-sm z-50 flex items-center justify-center"> <div className="flex items-center gap-3">
<div className="bg-card border border-border rounded-lg p-6 shadow-lg"> <span className="font-mono text-sm font-semibold text-foreground">
<div className="flex items-center gap-3"> {activeChart?.name || 'No chart'}
<div className="animate-spin rounded-full h-5 w-5 border-2 border-primary border-t-transparent" /> </span>
<span className="text-sm text-foreground">Loading predictions...</span> </div>
<div className="flex items-center gap-2 text-[10px] text-muted-foreground font-mono">
<span className="px-1.5 py-0.5 rounded bg-secondary/50">R Rect</span>
<span className="px-1.5 py-0.5 rounded bg-secondary/50">S Span</span>
<span className="px-1.5 py-0.5 rounded bg-secondary/50">L Line</span>
<span className="px-1.5 py-0.5 rounded bg-secondary/50">D Del</span>
<span className="px-1.5 py-0.5 rounded bg-secondary/50">T Theme</span>
</div>
</div>
{/* Chart */}
<div className="flex-1 min-h-0 relative">
{/* Loading overlay for predictions */}
{predictionState.isLoading && (
<div className="absolute inset-0 bg-background/50 backdrop-blur-sm z-50 flex items-center justify-center">
<div className="bg-card border border-border rounded-lg p-6 shadow-lg">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-5 w-5 border-2 border-primary border-t-transparent" />
<span className="text-sm text-foreground">Loading predictions...</span>
</div>
</div> </div>
</div> </div>
</div> )}
)} <CandleChart
<CandleChart ref={chartRef}
ref={chartRef} activeTool={activeTool}
activeTool={activeTool} onAnnotationChange={handleAnnotationChange}
onAnnotationChange={handleAnnotationChange} selectedColor={selectedColor}
selectedColor={selectedColor} selectedLabelId={selectedLabelId}
selectedLabelId={selectedLabelId} onLabelSelect={handleLabelSelect}
onLabelSelect={handleLabelSelect} activeChartId={activeChartId}
activeChartId={activeChartId} spanAnnotations={spanAnnotations}
spanAnnotations={spanAnnotations} spanLabelTypes={spanLabelTypes}
spanLabelTypes={spanLabelTypes} selectedSpanId={selectedSpanId}
selectedSpanId={selectedSpanId} onSpanAnnotationsChange={handleSpanAnnotationsChange}
onSpanAnnotationsChange={handleSpanAnnotationsChange} onSelectedSpanChange={handleSelectedSpanChange}
onSelectedSpanChange={handleSelectedSpanChange} predictionVisible={predictionState.visible}
predictionVisible={predictionState.visible} perCandlePredictions={predictionState.perCandlePredictions}
perCandlePredictions={predictionState.perCandlePredictions} predictionSpans={predictionState.spans}
predictionSpans={predictionState.spans} confidenceThreshold={predictionState.confidenceThreshold}
confidenceThreshold={predictionState.confidenceThreshold} selectedLabels={predictionState.selectedLabels}
selectedLabels={predictionState.selectedLabels} modelInfo={predictionState.modelInfo}
modelInfo={predictionState.modelInfo} predictionSummary={predictionSummary}
predictionSummary={predictionSummary} showOnlyDisagreements={showOnlyDisagreements}
showOnlyDisagreements={showOnlyDisagreements} onPredictionClick={handlePredictionClick}
onPredictionClick={handlePredictionClick} onPredictionDismiss={handlePredictionDismiss}
onPredictionDismiss={handlePredictionDismiss} />
/> </div>
</main> </div>
</div> </div>
); );
} }

View file

@ -29,36 +29,28 @@ export default function ChartSelector({
if (charts.length === 0) { if (charts.length === 0) {
return ( return (
<div className="text-sm text-muted-foreground italic"> <p className="text-[10px] text-muted-foreground italic">
No charts upload a CSV to get started No charts upload a CSV to get started
</div> </p>
); );
} }
const formatDate = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
return ( return (
<div className="relative"> <div className="relative">
<button <button
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between px-3 py-2 text-sm rounded-md border border-border bg-background hover:bg-accent text-foreground" className="w-full flex items-center justify-between px-2 py-1.5 text-xs rounded bg-secondary/50 hover:bg-secondary text-foreground transition-colors"
> >
<span className="truncate">{activeChart?.name || 'Select chart'}</span> <span className="font-mono font-medium truncate">{activeChart?.name || 'Select chart'}</span>
<ChevronDown className={`h-4 w-4 ml-2 flex-shrink-0 transition-transform ${isOpen ? 'rotate-180' : ''}`} /> <ChevronDown className={`w-3 h-3 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button> </button>
{isOpen && ( {isOpen && (
<div className="absolute z-50 mt-1 w-full rounded-md border border-border bg-popover shadow-md max-h-64 overflow-y-auto"> <div className="absolute z-50 mt-1 w-full rounded-md border border-border bg-popover shadow-md max-h-48 overflow-y-auto">
{charts.map((chart) => ( {charts.map((chart) => (
<div <div
key={chart.id} key={chart.id}
className={`flex items-center justify-between px-3 py-2 text-sm hover:bg-accent cursor-pointer ${ className={`flex items-center justify-between px-2 py-1.5 text-xs hover:bg-accent cursor-pointer ${
chart.id === activeChartId ? 'bg-accent/50' : '' chart.id === activeChartId ? 'bg-accent/50' : ''
}`} }`}
> >
@ -69,35 +61,33 @@ export default function ChartSelector({
setIsOpen(false); setIsOpen(false);
}} }}
> >
<div className="truncate text-foreground">{chart.name}</div> <div className="truncate font-mono text-foreground">{chart.name}</div>
<div className="text-xs text-muted-foreground">{formatDate(chart.created_at)}</div>
</div> </div>
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setConfirmDeleteId(chart.id); setConfirmDeleteId(chart.id);
}} }}
className="ml-2 p-1 text-muted-foreground hover:text-destructive flex-shrink-0" className="ml-2 p-0.5 text-muted-foreground hover:text-destructive flex-shrink-0"
title="Delete chart" title="Delete chart"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3 w-3" />
</button> </button>
</div> </div>
))} ))}
</div> </div>
)} )}
{/* Confirmation dialog */}
{confirmDeleteId !== null && ( {confirmDeleteId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-popover border border-border rounded-lg p-6 max-w-sm mx-4 shadow-lg"> <div className="bg-popover border border-border rounded-lg p-4 max-w-sm mx-4 shadow-lg">
<p className="text-sm text-foreground"> <p className="text-xs text-foreground">
Delete chart &ldquo;{charts.find((c) => c.id === confirmDeleteId)?.name}&rdquo; and all its candles and annotations? This cannot be undone. Delete &ldquo;{charts.find((c) => c.id === confirmDeleteId)?.name}&rdquo; and all its data? This cannot be undone.
</p> </p>
<div className="flex justify-end gap-2 mt-4"> <div className="flex justify-end gap-2 mt-3">
<button <button
onClick={() => setConfirmDeleteId(null)} onClick={() => setConfirmDeleteId(null)}
className="px-3 py-1.5 text-sm rounded-md border border-border hover:bg-accent text-foreground" className="px-3 py-1 text-xs rounded border border-border hover:bg-accent text-foreground"
> >
Cancel Cancel
</button> </button>
@ -107,7 +97,7 @@ export default function ChartSelector({
setConfirmDeleteId(null); setConfirmDeleteId(null);
setIsOpen(false); setIsOpen(false);
}} }}
className="px-3 py-1.5 text-sm rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90" className="px-3 py-1 text-xs rounded bg-destructive text-destructive-foreground hover:bg-destructive/90"
> >
Delete Delete
</button> </button>

View file

@ -1,7 +1,6 @@
'use client'; 'use client';
import { useState, useRef } from 'react'; import { useState, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Upload } from 'lucide-react'; import { Upload } from 'lucide-react';
interface FileUploadProps { interface FileUploadProps {
@ -34,7 +33,7 @@ export default function FileUpload({ onUploadSuccess }: FileUploadProps) {
if (response.ok) { if (response.ok) {
setMessage({ setMessage({
type: 'success', type: 'success',
text: `Successfully uploaded ${data.count} candle records`, text: `Uploaded ${data.count} candles`,
}); });
onUploadSuccess(data.chart); onUploadSuccess(data.chart);
} else { } else {
@ -50,7 +49,6 @@ export default function FileUpload({ onUploadSuccess }: FileUploadProps) {
}); });
} finally { } finally {
setIsUploading(false); setIsUploading(false);
// Reset file input
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ''; fileInputRef.current.value = '';
} }
@ -58,9 +56,7 @@ export default function FileUpload({ onUploadSuccess }: FileUploadProps) {
}; };
return ( return (
<div className="p-4 bg-muted rounded-lg border border-border"> <div>
<h3 className="text-sm font-medium mb-3 text-foreground">Upload CSV Data</h3>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
@ -68,29 +64,20 @@ export default function FileUpload({ onUploadSuccess }: FileUploadProps) {
onChange={handleFileChange} onChange={handleFileChange}
disabled={isUploading} disabled={isUploading}
className="hidden" className="hidden"
id="csv-upload"
/> />
<button
<Button
variant="outline"
className="w-full"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
disabled={isUploading} disabled={isUploading}
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs rounded border border-border bg-secondary/30 hover:bg-secondary text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
> >
<Upload className="w-4 h-4 mr-2" /> <Upload className="w-3 h-3" />
{isUploading ? 'Uploading...' : 'Choose CSV File'} {isUploading ? 'Uploading...' : 'Upload CSV'}
</Button> </button>
{message && ( {message && (
<div <p className={`mt-1 text-[10px] ${message.type === 'success' ? 'text-green-600' : 'text-destructive'}`}>
className={`mt-3 text-xs p-3 rounded-md ${
message.type === 'success'
? 'bg-emerald-50 text-emerald-700 border border-emerald-200'
: 'bg-red-50 text-red-700 border border-red-200'
}`}
>
{message.text} {message.text}
</div> </p>
)} )}
</div> </div>
); );

View file

@ -1,5 +1,7 @@
'use client'; 'use client';
import { useState } from 'react';
import { ChevronDown } from 'lucide-react';
import type { PredictionState, ModelInfoResponse, PredictionSummary } from '@/types/predictions'; import type { PredictionState, ModelInfoResponse, PredictionSummary } from '@/types/predictions';
interface PredictionPanelProps { interface PredictionPanelProps {
@ -27,6 +29,7 @@ export default function PredictionPanel({
showOnlyDisagreements = false, showOnlyDisagreements = false,
onToggleShowOnlyDisagreements, onToggleShowOnlyDisagreements,
}: PredictionPanelProps) { }: PredictionPanelProps) {
const [expanded, setExpanded] = useState(true);
const { const {
visible, visible,
@ -38,166 +41,172 @@ export default function PredictionPanel({
spans, spans,
} = predictionState; } = predictionState;
if (!isModelOnline) {
return (
<div className="p-4 border-t border-border bg-card">
<div className="flex items-center gap-2 mb-3">
<div className="w-2 h-2 rounded-full bg-red-500" />
<h3 className="text-sm font-semibold text-foreground">Model Server Offline</h3>
</div>
<p className="text-xs text-muted-foreground">
Prediction service is unavailable. Annotation tools continue to work normally.
</p>
</div>
);
}
return ( return (
<div className="p-4 border-t border-border bg-card"> <div>
{/* Header with master toggle */} {/* Collapsible header */}
<div className="flex items-center justify-between mb-3"> <button
<div className="flex items-center gap-2"> onClick={() => setExpanded(!expanded)}
<div className={`w-2 h-2 rounded-full ${isModelOnline ? 'bg-green-500' : 'bg-red-500'}`} /> className="w-full flex items-center justify-between py-1"
<h3 className="text-sm font-semibold text-foreground">Predictions</h3> >
<div className="flex items-center gap-1.5">
<div
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: isModelOnline ? '#22c55e' : '#ef4444' }}
/>
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
Predictions
</span>
</div> </div>
<button <ChevronDown className={`w-3 h-3 text-muted-foreground transition-transform ${expanded ? 'rotate-180' : ''}`} />
onClick={onToggleVisibility} </button>
className={`px-3 py-1 text-xs rounded ${
visible
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}`}
>
{visible ? 'Hide' : 'Show'}
</button>
</div>
{/* Model Info */} {expanded && (
{modelInfo && ( <div className="mt-1 space-y-2">
<div className="mb-3 p-2 bg-muted/50 rounded text-xs"> {!isModelOnline ? (
<div className="flex justify-between"> <p className="text-[10px] text-muted-foreground">
<span className="text-muted-foreground">Model:</span> Prediction service unavailable.
<span className="font-mono text-foreground">{modelInfo.model_name}</span> </p>
</div> ) : (
<div className="flex justify-between"> <>
<span className="text-muted-foreground">Version:</span> {/* Model Info */}
<span className="font-mono text-foreground">{modelInfo.model_version || 'N/A'}</span> {modelInfo && (
</div> <div className="p-2 bg-secondary/30 rounded text-[10px] space-y-1">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Type:</span> <span className="text-muted-foreground">Model:</span>
<span className="text-foreground">{modelInfo.model_type}</span> <span className="font-mono text-foreground">{modelInfo.model_name}</span>
</div> </div>
</div> <div className="flex justify-between">
)} <span className="text-muted-foreground">Version:</span>
<span className="font-mono text-foreground">{modelInfo.model_version || 'N/A'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Type:</span>
<span className="text-foreground">{modelInfo.model_type}</span>
</div>
</div>
)}
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex gap-2 mb-3"> <div className="flex gap-1">
<button <button
onClick={onFetchPredictions} onClick={onFetchPredictions}
disabled={isLoading || !isModelOnline} disabled={isLoading}
className="flex-1 px-3 py-2 text-xs bg-primary text-primary-foreground rounded hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed" className="flex-1 px-2 py-1.5 text-[10px] bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity disabled:opacity-50"
>
{isLoading ? 'Loading...' : 'Run on Visible'}
</button>
<button
onClick={onFetchBatchPredictions}
disabled={isLoading || !isModelOnline}
className="flex-1 px-3 py-2 text-xs bg-secondary text-secondary-foreground rounded hover:bg-secondary/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
Predict All
</button>
</div>
{/* Error Display */}
{error && (
<div className="mb-3 p-2 bg-destructive/10 border border-destructive/20 rounded text-xs text-destructive">
{error}
</div>
)}
{/* Confidence Slider */}
<div className="mb-3">
<div className="flex justify-between items-center mb-1">
<label className="text-xs text-muted-foreground">Confidence Threshold</label>
<span className="text-xs font-mono text-foreground">{(confidenceThreshold * 100).toFixed(0)}%</span>
</div>
<input
type="range"
min="0"
max="100"
value={confidenceThreshold * 100}
onChange={(e) => onConfidenceChange(Number(e.target.value) / 100)}
className="w-full h-1 bg-muted rounded-lg appearance-none cursor-pointer"
/>
</div>
{/* Label Filter Checkboxes */}
{modelInfo && (
<div className="mb-3">
<label className="text-xs text-muted-foreground mb-2 block">Filter by Label</label>
<div className="space-y-1 max-h-32 overflow-y-auto">
{modelInfo.labels.map((label) => {
const metrics = modelInfo.per_class_metrics.find((m) => m.label === label);
const isSelected = selectedLabels.has(label);
return (
<label
key={label}
className="flex items-center gap-2 p-1 rounded hover:bg-muted/50 cursor-pointer"
> >
{isLoading ? 'Loading...' : 'Run on Visible'}
</button>
<button
onClick={onFetchBatchPredictions}
disabled={isLoading}
className="flex-1 px-2 py-1.5 text-[10px] bg-secondary text-secondary-foreground rounded hover:opacity-90 transition-opacity disabled:opacity-50"
>
Predict All
</button>
</div>
{/* Error */}
{error && (
<p className="text-[10px] text-destructive">{error}</p>
)}
{/* Confidence Slider */}
<div>
<div className="flex justify-between items-center mb-1">
<label className="text-[10px] text-muted-foreground">Confidence</label>
<span className="text-[10px] font-mono text-foreground">{(confidenceThreshold * 100).toFixed(0)}%</span>
</div>
<input
type="range"
min="0"
max="100"
value={confidenceThreshold * 100}
onChange={(e) => onConfidenceChange(Number(e.target.value) / 100)}
className="w-full h-1 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
{/* Show on chart toggle */}
<div className="flex items-center justify-between">
<label className="text-[10px] text-muted-foreground">Show on chart</label>
<button
onClick={onToggleVisibility}
className={`px-2 py-0.5 text-[10px] rounded transition-colors ${
visible
? 'bg-primary/20 text-primary'
: 'bg-secondary text-muted-foreground'
}`}
>
{visible ? 'On' : 'Off'}
</button>
</div>
{/* Label Filter */}
{modelInfo && modelInfo.labels.length > 0 && (
<div>
<label className="text-[10px] text-muted-foreground mb-1 block">Labels</label>
<div className="space-y-0.5 max-h-24 overflow-y-auto scrollbar-thin">
{modelInfo.labels.map((label) => {
const metrics = modelInfo.per_class_metrics.find((m) => m.label === label);
return (
<label
key={label}
className="flex items-center gap-1.5 p-0.5 rounded hover:bg-secondary/50 cursor-pointer"
>
<input
type="checkbox"
checked={selectedLabels.has(label)}
onChange={() => onToggleLabelSelection(label)}
className="w-3 h-3"
/>
<span className="text-[10px] text-foreground flex-1">{label}</span>
{metrics && (
<span className="text-[10px] text-muted-foreground font-mono">
F1:{(metrics.f1_score * 100).toFixed(0)}%
</span>
)}
</label>
);
})}
</div>
</div>
)}
{/* Disagreement Filter */}
{predictionSummary && predictionSummary.disagreements.length > 0 && onToggleShowOnlyDisagreements && (
<label className="flex items-center gap-1.5 p-1 bg-secondary/30 rounded cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={isSelected} checked={showOnlyDisagreements}
onChange={() => onToggleLabelSelection(label)} onChange={onToggleShowOnlyDisagreements}
className="w-3 h-3" className="w-3 h-3"
/> />
<span className="text-xs text-foreground flex-1">{label}</span> <span className="text-[10px] text-foreground">Show only disagreements</span>
{metrics && (
<span className="text-xs text-muted-foreground font-mono">
F1: {(metrics.f1_score * 100).toFixed(0)}%
</span>
)}
</label> </label>
); )}
})}
</div>
</div>
)}
{/* Disagreement Filter */} {/* Summary */}
{predictionSummary && predictionSummary.disagreements.length > 0 && onToggleShowOnlyDisagreements && ( {visible && spans.length > 0 && predictionSummary ? (
<div className="mb-3"> <div className="p-2 bg-secondary/30 rounded text-[10px] space-y-0.5">
<label className="flex items-center gap-2 p-2 bg-muted/50 rounded cursor-pointer hover:bg-muted"> <div className="flex justify-between">
<input <span className="text-muted-foreground">Predictions:</span>
type="checkbox" <span className="font-mono text-foreground">{predictionSummary.total_predictions}</span>
checked={showOnlyDisagreements} </div>
onChange={onToggleShowOnlyDisagreements} <div className="flex justify-between">
className="w-3 h-3" <span className="text-muted-foreground">Agreements:</span>
/> <span className="text-green-600 font-mono">{predictionSummary.agreements}</span>
<span className="text-xs text-foreground">Show only disagreements</span> </div>
</label> <div className="flex justify-between">
</div> <span className="text-muted-foreground">Disagreements:</span>
)} <span className="text-orange-500 font-mono">{predictionSummary.disagreements.length}</span>
</div>
{/* Prediction Summary */} </div>
{visible && spans.length > 0 && predictionSummary && ( ) : (
<div className="p-2 bg-muted/30 rounded text-xs space-y-1"> <p className="text-[10px] text-muted-foreground text-center py-2">
<div className="flex justify-between"> No predictions loaded.
<span className="text-muted-foreground">Predictions:</span> </p>
<span className="text-foreground font-mono">{predictionSummary.total_predictions}</span> )}
</div> </>
<div className="flex justify-between"> )}
<span className="text-muted-foreground">Human annotations:</span>
<span className="text-foreground font-mono">{predictionSummary.total_human_annotations}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Agreements:</span>
<span className="text-green-600 font-mono">{predictionSummary.agreements}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Disagreements:</span>
<span className="text-orange-600 font-mono">{predictionSummary.disagreements.length}</span>
</div>
</div> </div>
)} )}
</div> </div>

View file

@ -1,8 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { Trash2, ChevronDown, ChevronUp } from 'lucide-react'; import { Trash2, ChevronDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface SpanAnnotation { interface SpanAnnotation {
id: number; id: number;
@ -46,7 +45,6 @@ export default function SpanAnnotationList({
}: SpanAnnotationListProps) { }: SpanAnnotationListProps) {
const [expanded, setExpanded] = useState(true); const [expanded, setExpanded] = useState(true);
// Format timestamp to readable date/time
const formatTime = (timestamp: number) => { const formatTime = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleString('en-US', { return new Date(timestamp * 1000).toLocaleString('en-US', {
month: 'short', month: 'short',
@ -56,7 +54,6 @@ export default function SpanAnnotationList({
}); });
}; };
// Get display name and color for label
const getLabelInfo = (labelName: string) => { const getLabelInfo = (labelName: string) => {
const labelType = spanLabelTypes.find((t) => t.name === labelName); const labelType = spanLabelTypes.find((t) => t.name === labelName);
return { return {
@ -65,125 +62,72 @@ export default function SpanAnnotationList({
}; };
}; };
// Calculate count per label type
const labelCounts = spanAnnotations.reduce((acc, span) => {
acc[span.label] = (acc[span.label] || 0) + 1;
return acc;
}, {} as Record<string, number>);
// Sort by start_time descending (most recent first)
const sortedSpans = [...spanAnnotations].sort((a, b) => b.start_time - a.start_time); const sortedSpans = [...spanAnnotations].sort((a, b) => b.start_time - a.start_time);
return ( return (
<div className="flex flex-col"> <div>
{/* Header with collapse toggle */}
<button <button
onClick={() => setExpanded(!expanded)} onClick={() => setExpanded(!expanded)}
className="w-full flex items-center justify-between px-2 py-2 hover:bg-secondary/50 rounded" className="w-full flex items-center justify-between py-1"
> >
<span className="text-sm font-semibold text-foreground"> <span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
Span Annotations ({spanAnnotations.length}) Annotations ({spanAnnotations.length})
</span> </span>
{expanded ? ( <ChevronDown className={`w-3 h-3 text-muted-foreground transition-transform ${expanded ? 'rotate-180' : ''}`} />
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button> </button>
{expanded && ( {expanded && (
<> <div className="space-y-1 mt-1">
{/* Count summary */}
{spanAnnotations.length > 0 && (
<div className="text-xs text-muted-foreground px-2 mb-2">
{Object.entries(labelCounts).map(([labelName, count], idx) => {
const { displayName } = getLabelInfo(labelName);
return (
<span key={labelName}>
{idx > 0 && ' | '}
{displayName}: {count}
</span>
);
})}
</div>
)}
{/* Empty state */}
{spanAnnotations.length === 0 ? ( {spanAnnotations.length === 0 ? (
<div className="text-xs text-muted-foreground px-2 py-4 text-center"> <p className="text-[10px] text-muted-foreground text-center py-3">
No span annotations yet. Use the Span tool to select candle ranges. No annotations yet.
</div> </p>
) : ( ) : (
/* Span list */ sortedSpans.map((span) => {
<div className="flex flex-col gap-1 max-h-64 overflow-y-auto"> const { displayName, color } = getLabelInfo(span.label);
{sortedSpans.map((span) => { const isSelected = span.id === selectedSpanId;
const { displayName, color } = getLabelInfo(span.label);
const isSelected = span.id === selectedSpanId;
return ( return (
<div <div
key={span.id} key={span.id}
className={` onClick={() => onSelectSpan(span.id)}
flex items-start gap-2 p-2 rounded cursor-pointer className={`flex items-start gap-1.5 p-1.5 rounded cursor-pointer transition-colors ${
transition-colors isSelected
${ ? 'bg-primary/10 border border-primary'
isSelected : 'hover:bg-secondary/50 border border-transparent'
? 'bg-primary/10 border border-primary' }`}
: 'hover:bg-secondary/50 border border-transparent' >
} <div className="flex-1 min-w-0">
`} <div className="text-[10px] text-muted-foreground font-mono">
onClick={() => onSelectSpan(span.id)} {formatTime(span.start_time)} {formatTime(span.end_time)}
>
<div className="flex-1 min-w-0">
{/* Time range */}
<div className="text-xs text-muted-foreground">
{formatTime(span.start_time)} {formatTime(span.end_time)}
</div>
{/* Label badge */}
<div className="mt-1">
<span
className="px-2 py-0.5 rounded text-xs font-medium text-white"
style={{ backgroundColor: color }}
>
{displayName}
</span>
</div>
{/* Additional info */}
{(span.confidence || span.outcome) && (
<div className="mt-1 text-xs text-muted-foreground flex gap-2">
{span.confidence && <span>Confidence: {span.confidence}</span>}
{span.outcome && <span>Outcome: {span.outcome}</span>}
</div>
)}
{/* Notes preview */}
{span.notes && (
<div className="mt-1 text-xs text-muted-foreground truncate">
{span.notes}
</div>
)}
</div> </div>
<span
{/* Delete button */} className="px-1.5 py-0.5 rounded text-[10px] font-medium text-white mt-0.5 inline-block"
<Button style={{ backgroundColor: color }}
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-destructive/20 hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onDeleteSpan(span.id);
}}
> >
<Trash2 className="w-3 h-3" /> {displayName}
</Button> </span>
{(span.confidence || span.outcome) && (
<div className="mt-0.5 text-[10px] text-muted-foreground flex gap-2">
{span.confidence && <span>C:{span.confidence}</span>}
{span.outcome && <span>{span.outcome}</span>}
</div>
)}
</div> </div>
); <button
})} className="p-0.5 text-muted-foreground hover:text-destructive flex-shrink-0"
</div> onClick={(e) => {
e.stopPropagation();
onDeleteSpan(span.id);
}}
>
<Trash2 className="w-3 h-3" />
</button>
</div>
);
})
)} )}
</> </div>
)} )}
</div> </div>
); );

View file

@ -2,19 +2,12 @@
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Monitor, Sun, Moon } from "lucide-react"; import { Sun, Moon } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
export function ThemeToggle() { export function ThemeToggle() {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
// Avoid hydration mismatch by only rendering after mount
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
}, []); }, []);
@ -22,41 +15,27 @@ export function ThemeToggle() {
if (!mounted) { if (!mounted) {
return ( return (
<button <button
className="p-3 rounded-lg border border-border bg-card hover:bg-muted transition-colors" className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
disabled disabled
> >
<Monitor className="h-5 w-5 text-muted-foreground" /> <Sun className="w-3.5 h-3.5" />
</button> </button>
); );
} }
const cycleTheme = () => { const toggleTheme = () => {
if (theme === "system") { setTheme(theme === "dark" ? "light" : "dark");
setTheme("light");
} else if (theme === "light") {
setTheme("dark");
} else {
setTheme("system");
}
}; };
const Icon = theme === "light" ? Sun : theme === "dark" ? Moon : Monitor; const Icon = theme === "dark" ? Sun : Moon;
const themeName = theme === "light" ? "Light" : theme === "dark" ? "Dark" : "System";
return ( return (
<TooltipProvider> <button
<Tooltip> onClick={toggleTheme}
<TooltipTrigger className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
onClick={cycleTheme} title={`Switch to ${theme === "dark" ? "light" : "dark"} theme`}
className="p-3 rounded-lg border border-border bg-card hover:bg-muted transition-colors" >
aria-label={`Current theme: ${themeName}. Click to cycle.`} <Icon className="w-3.5 h-3.5" />
> </button>
<Icon className="h-5 w-5 text-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>Theme: {themeName}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
); );
} }

View file

@ -1,11 +1,8 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { ArrowUpCircle, ArrowDownCircle, TrendingUp, Trash2, Download, ChevronDown, ChevronUp, RectangleHorizontal } from 'lucide-react'; import { ArrowUpCircle, ArrowDownCircle, TrendingUp, Trash2, ChevronDown, ChevronUp, RectangleHorizontal, Layers, Minus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { ThemeToggle } from '@/components/ThemeToggle';
import SpanAnnotationList from '@/components/SpanAnnotationList';
export type Tool = string | 'delete' | null; export type Tool = string | 'delete' | null;
@ -87,12 +84,11 @@ export default function Toolbox({
onSelectSpan, onSelectSpan,
onDeleteSpan, onDeleteSpan,
}: ToolboxProps) { }: ToolboxProps) {
const [labelsExpanded, setLabelsExpanded] = useState(true); const [labelsExpanded, setLabelsExpanded] = useState(false);
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
const [filterType, setFilterType] = useState<string>('all'); const [filterType, setFilterType] = useState<string>('all');
const [annotationTypes, setAnnotationTypes] = useState<AnnotationType[]>([]); const [annotationTypes, setAnnotationTypes] = useState<AnnotationType[]>([]);
// Fetch annotation types on mount
useEffect(() => { useEffect(() => {
const fetchTypes = async () => { const fetchTypes = async () => {
try { try {
@ -109,7 +105,6 @@ export default function Toolbox({
}, []); }, []);
const handleToolClick = (tool: Tool) => { const handleToolClick = (tool: Tool) => {
// Toggle: if clicking the active tool, deactivate it
if (activeTool === tool) { if (activeTool === tool) {
onToolChange(null); onToolChange(null);
} else { } else {
@ -117,17 +112,14 @@ export default function Toolbox({
} }
}; };
// Get marker types (exclude line types)
const markerTypes = annotationTypes.filter((t) => t.category === 'marker'); const markerTypes = annotationTypes.filter((t) => t.category === 'marker');
const markerTypeNames = markerTypes.map((t) => t.name); const markerTypeNames = markerTypes.map((t) => t.name);
// Filter and sort annotations
const labelAnnotations = annotations const labelAnnotations = annotations
.filter((a) => markerTypeNames.includes(a.label_type)) .filter((a) => markerTypeNames.includes(a.label_type))
.filter((a) => (filterType === 'all' ? true : a.label_type === filterType)) .filter((a) => (filterType === 'all' ? true : a.label_type === filterType))
.sort((a, b) => b.timestamp - a.timestamp); .sort((a, b) => b.timestamp - a.timestamp);
// Apply search filter
const filteredAnnotations = labelAnnotations.filter((a) => { const filteredAnnotations = labelAnnotations.filter((a) => {
const formattedTime = new Date(a.timestamp * 1000).toLocaleString('en-US', { const formattedTime = new Date(a.timestamp * 1000).toLocaleString('en-US', {
month: 'short', month: 'short',
@ -138,7 +130,6 @@ export default function Toolbox({
return formattedTime.toLowerCase().includes(searchText.toLowerCase()); return formattedTime.toLowerCase().includes(searchText.toLowerCase());
}); });
// Count annotations per type
const typeCounts = markerTypes.reduce((acc, type) => { const typeCounts = markerTypes.reduce((acc, type) => {
acc[type.name] = labelAnnotations.filter((a) => a.label_type === type.name).length; acc[type.name] = labelAnnotations.filter((a) => a.label_type === type.name).length;
return acc; return acc;
@ -158,134 +149,130 @@ export default function Toolbox({
} }
}; };
const getIconComponent = (iconName: string | null) => { const colors = [
switch (iconName) { { color: '#ef4444', name: 'red' },
case 'arrowUp': { color: '#22c55e', name: 'green' },
return <ArrowUpCircle className="w-5 h-5" />; { color: '#3b82f6', name: 'blue' },
case 'arrowDown': { color: '#f59e0b', name: 'orange' },
return <ArrowDownCircle className="w-5 h-5" />; { color: '#8b5cf6', name: 'purple' },
case 'line': ];
return <TrendingUp className="w-5 h-5" />;
default:
return <ArrowUpCircle className="w-5 h-5" />;
}
};
return ( return (
<div className="flex-1 flex flex-col gap-4 overflow-y-auto"> <div className="space-y-1">
<h2 className="text-lg font-semibold text-foreground">Annotation Tools</h2> <p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1">Tools</p>
<div className="flex flex-col gap-2"> {/* Tool buttons grid */}
{/* Marker type buttons */} <div className="flex gap-1">
{markerTypes.map((type) => ( <button
<Button onClick={() => handleToolClick('rectangle')}
key={type.id} className={`flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 text-xs rounded transition-colors ${
variant={activeTool === type.name ? 'default' : 'outline'} activeTool === 'rectangle'
className="justify-start gap-2" ? 'bg-primary text-primary-foreground'
onClick={() => handleToolClick(type.name)} : 'bg-secondary/50 hover:bg-secondary text-muted-foreground hover:text-foreground'
> }`}
{getIconComponent(type.icon)} >
{type.display_name} <RectangleHorizontal className="w-3.5 h-3.5" /> Rect
</Button> </button>
))} <button
onClick={() => handleToolClick('span')}
{/* Line type buttons */} className={`flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 text-xs rounded transition-colors ${
activeTool === 'span'
? 'bg-primary text-primary-foreground'
: 'bg-secondary/50 hover:bg-secondary text-muted-foreground hover:text-foreground'
}`}
>
<Layers className="w-3.5 h-3.5" /> Span
</button>
{annotationTypes {annotationTypes
.filter((t) => t.category === 'line') .filter((t) => t.category === 'line')
.map((type) => ( .map((type) => (
<Button <button
key={type.id} key={type.id}
variant={activeTool === type.name ? 'default' : 'outline'}
className="justify-start gap-2"
onClick={() => handleToolClick(type.name)} onClick={() => handleToolClick(type.name)}
className={`flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 text-xs rounded transition-colors ${
activeTool === type.name
? 'bg-primary text-primary-foreground'
: 'bg-secondary/50 hover:bg-secondary text-muted-foreground hover:text-foreground'
}`}
> >
{getIconComponent(type.icon)} <Minus className="w-3.5 h-3.5" /> Line
{type.display_name} </button>
</Button>
))} ))}
<button
{/* Rectangle tool button */}
<Button
variant={activeTool === 'rectangle' ? 'default' : 'outline'}
className="justify-start gap-2"
onClick={() => handleToolClick('rectangle')}
>
<RectangleHorizontal className="w-5 h-5" />
Rectangle
</Button>
{/* Span tool button */}
<Button
variant={activeTool === 'span' ? 'default' : 'outline'}
className="justify-start gap-2"
onClick={() => handleToolClick('span')}
>
<RectangleHorizontal className="w-5 h-5" />
Span
</Button>
{/* Color picker */}
<div className="flex gap-1 px-1">
{[
{ color: '#ef4444', name: 'Red' },
{ color: '#10b981', name: 'Green' },
{ color: '#3b82f6', name: 'Blue' },
{ color: '#f59e0b', name: 'Orange' },
{ color: '#8b5cf6', name: 'Purple' },
].map((preset) => (
<Button
key={preset.color}
variant={selectedColor === preset.color ? 'default' : 'outline'}
size="sm"
className="w-8 h-8 p-0"
style={{
backgroundColor: selectedColor === preset.color ? preset.color : 'transparent',
borderColor: preset.color,
borderWidth: '2px',
}}
onClick={() => onColorChange(preset.color)}
title={preset.name}
>
{selectedColor === preset.color ? '' : (
<div
className="w-4 h-4 rounded-sm"
style={{ backgroundColor: preset.color }}
/>
)}
</Button>
))}
</div>
<Button
variant={activeTool === 'delete' ? 'destructive' : 'outline'}
className="justify-start gap-2"
onClick={() => handleToolClick('delete')} onClick={() => handleToolClick('delete')}
className={`flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 text-xs rounded transition-colors ${
activeTool === 'delete'
? 'bg-destructive text-destructive-foreground'
: 'bg-secondary/50 hover:bg-secondary text-muted-foreground hover:text-foreground'
}`}
> >
<Trash2 className="w-5 h-5" /> <Trash2 className="w-3.5 h-3.5" /> Del
Delete </button>
</Button>
</div> </div>
{/* Labels Section */} {/* Marker type buttons (if any) */}
<div className="border-t border-border pt-4"> {markerTypes.length > 0 && (
<button <div className="flex gap-1">
onClick={() => setLabelsExpanded(!labelsExpanded)} {markerTypes.map((type) => (
className="w-full flex items-center justify-between px-2 py-2 hover:bg-secondary/50 rounded" <button
> key={type.id}
<span className="text-sm font-semibold text-foreground"> onClick={() => handleToolClick(type.name)}
Label Annotations ({labelAnnotations.length}) className={`flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 text-xs rounded transition-colors ${
</span> activeTool === type.name
{labelsExpanded ? ( ? 'bg-primary text-primary-foreground'
<ChevronUp className="w-4 h-4" /> : 'bg-secondary/50 hover:bg-secondary text-muted-foreground hover:text-foreground'
) : ( }`}
<ChevronDown className="w-4 h-4" /> >
)} {type.icon === 'arrowUp' ? <ArrowUpCircle className="w-3.5 h-3.5" /> :
</button> type.icon === 'arrowDown' ? <ArrowDownCircle className="w-3.5 h-3.5" /> :
<ArrowUpCircle className="w-3.5 h-3.5" />}
{type.display_name}
</button>
))}
</div>
)}
{labelsExpanded && ( {/* Color swatches */}
<div className="mt-3 space-y-2"> <div className="flex gap-1 pt-1">
{/* Count display */} {colors.map((preset) => {
<div className="text-xs text-muted-foreground px-2"> const isSelected = selectedColor === preset.color;
return (
<button
key={preset.color}
onClick={() => onColorChange(preset.color)}
className={`flex-1 h-6 rounded-sm border-2 transition-all ${
isSelected
? 'scale-105 ring-1 ring-offset-1 ring-offset-sidebar'
: 'opacity-60 hover:opacity-100'
}`}
style={{
backgroundColor: preset.color,
borderColor: isSelected ? preset.color : 'transparent',
// @ts-ignore
'--tw-ring-color': preset.color,
}}
title={preset.name}
/>
);
})}
</div>
{/* Labels Section - collapsible */}
<button
onClick={() => setLabelsExpanded(!labelsExpanded)}
className="w-full flex items-center justify-between py-1 mt-1"
>
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
Annotations ({labelAnnotations.length})
</span>
<ChevronDown className={`w-3 h-3 text-muted-foreground transition-transform ${labelsExpanded ? 'rotate-180' : ''}`} />
</button>
{labelsExpanded && (
<div className="space-y-1 mt-1">
{/* Count display */}
{markerTypes.length > 0 && (
<div className="text-[10px] text-muted-foreground">
{markerTypes.map((type, idx) => ( {markerTypes.map((type, idx) => (
<span key={type.id}> <span key={type.id}>
{idx > 0 && ' | '} {idx > 0 && ' | '}
@ -293,118 +280,83 @@ export default function Toolbox({
</span> </span>
))} ))}
</div> </div>
)}
{/* Search input */} <Input
<Input placeholder="Search..."
placeholder="Search by timestamp..." value={searchText}
value={searchText} onChange={(e) => setSearchText(e.target.value)}
onChange={(e) => setSearchText(e.target.value)} className="h-7 text-xs"
className="h-8 text-sm" />
/>
{/* Filter dropdown */} {filterType !== 'all' || markerTypes.length > 1 ? (
<select <select
value={filterType} value={filterType}
onChange={(e) => setFilterType(e.target.value)} onChange={(e) => setFilterType(e.target.value)}
className="w-full h-8 px-2 text-sm rounded border border-border bg-card" className="w-full h-7 px-2 text-xs rounded border border-border bg-card"
> >
<option value="all">All Types</option> <option value="all">All Types</option>
{markerTypes.map((type) => ( {markerTypes.map((type) => (
<option key={type.id} value={type.name}> <option key={type.id} value={type.name}>
{type.display_name} Only {type.display_name}
</option> </option>
))} ))}
</select> </select>
) : null}
{/* Labels list */} <div className="max-h-40 overflow-y-auto scrollbar-thin space-y-1">
<div className="max-h-96 overflow-y-auto space-y-2 p-2 border border-border rounded bg-card/50"> {filteredAnnotations.length === 0 ? (
{filteredAnnotations.length === 0 ? ( <p className="text-[10px] text-muted-foreground text-center py-3">
<div className="text-xs text-muted-foreground text-center py-4"> {labelAnnotations.length === 0 ? 'No annotations yet.' : 'No matching labels.'}
{labelAnnotations.length === 0 </p>
? 'No labels yet. Click Break Up or Break Down tools to add labels.' ) : (
: 'No matching labels found.'} filteredAnnotations.map((annotation) => {
</div> const formattedTime = new Date(annotation.timestamp * 1000).toLocaleString('en-US', {
) : ( month: 'short',
filteredAnnotations.map((annotation) => { day: 'numeric',
const formattedTime = new Date(annotation.timestamp * 1000).toLocaleString('en-US', { hour: '2-digit',
month: 'short', minute: '2-digit',
day: 'numeric', });
hour: '2-digit', const isSelected = annotation.id === selectedLabelId;
minute: '2-digit', const annotationType = annotationTypes.find((t) => t.name === annotation.label_type);
});
const isSelected = annotation.id === selectedLabelId;
const annotationType = annotationTypes.find((t) => t.name === annotation.label_type);
return ( return (
<div <div
key={annotation.id} key={annotation.id}
onClick={() => onLabelSelect?.(annotation.id)} onClick={() => onLabelSelect?.(annotation.id)}
className={`p-2 rounded border text-xs cursor-pointer transition-colors ${ className={`p-1.5 rounded text-[10px] cursor-pointer transition-colors ${
isSelected isSelected
? 'border-primary bg-primary/10' ? 'bg-primary/10 border border-primary'
: 'border-border hover:bg-secondary' : 'hover:bg-secondary/50 border border-transparent'
}`} }`}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex-1"> <div className="flex-1 min-w-0">
<div className="font-medium text-foreground">{formattedTime}</div> <div className="font-mono text-foreground">{formattedTime}</div>
<div className="mt-1 flex items-center gap-2"> <span
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium mt-0.5 inline-block"
className="px-2 py-0.5 rounded text-xs font-medium" style={{
style={{ backgroundColor: annotationType ? `${annotationType.color}20` : '#e5e7eb',
backgroundColor: annotationType ? `${annotationType.color}20` : '#e5e7eb', color: annotationType?.color || '#374151',
color: annotationType?.color || '#374151', }}
}}
>
{annotationType?.display_name || annotation.label_type}
</span>
</div>
</div>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={(e) => handleLabelDelete(annotation.id, e)}
> >
<Trash2 className="w-3 h-3" /> {annotationType?.display_name || annotation.label_type}
</Button> </span>
</div> </div>
<button
className="p-0.5 text-muted-foreground hover:text-destructive"
onClick={(e) => handleLabelDelete(annotation.id, e)}
>
<Trash2 className="w-3 h-3" />
</button>
</div> </div>
); </div>
}) );
)} })
</div> )}
</div> </div>
)} </div>
</div> )}
{/* Span Annotations List */}
<div className="pt-4">
<SpanAnnotationList
spanAnnotations={spanAnnotations}
spanLabelTypes={spanLabelTypes}
selectedSpanId={selectedSpanId}
onSelectSpan={onSelectSpan || (() => {})}
onDeleteSpan={onDeleteSpan || (() => {})}
/>
</div>
{/* Export button */}
<div className="mt-auto pt-4 border-t border-border">
<Button
variant="secondary"
className="w-full justify-start gap-2"
onClick={onExport}
>
<Download className="w-5 h-5" />
Export CSV
</Button>
</div>
{/* Theme toggle */}
<div className="pt-2">
<ThemeToggle />
</div>
</div> </div>
); );
} }

73
tailwind.config.js Normal file
View file

@ -0,0 +1,73 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: [
"./src/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
sidebar: {
DEFAULT: "hsl(var(--sidebar-background))",
foreground: "hsl(var(--sidebar-foreground))",
primary: "hsl(var(--sidebar-primary))",
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
accent: "hsl(var(--sidebar-accent))",
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
border: "hsl(var(--sidebar-border))",
ring: "hsl(var(--sidebar-ring))",
},
"candle-bull": "hsl(var(--candle-bull))",
"candle-bear": "hsl(var(--candle-bear))",
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"fade-in": {
from: { opacity: "0" },
to: { opacity: "1" },
},
},
animation: {
"fade-in": "fade-in 0.2s ease-in-out",
},
},
},
plugins: [],
};