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:
parent
2bde38d0bf
commit
4605283d2b
13 changed files with 976 additions and 740 deletions
0
candle_annotator.db
Normal file
0
candle_annotator.db
Normal file
254
lovable_design_html/candles_lovable_design.html
Normal file
254
lovable_design_html/candles_lovable_design.html
Normal file
File diff suppressed because one or more lines are too long
1
lovable_design_html/index-DRgIPKRu.css
Normal file
1
lovable_design_html/index-DRgIPKRu.css
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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,6 +109,9 @@
|
||||||
@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 {
|
||||||
|
|
@ -68,90 +120,44 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
::-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 {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(var(--border));
|
||||||
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .crt-scanlines::before {
|
.animate-fade-in {
|
||||||
content: "";
|
animation: fadeIn 0.2s ease-in-out;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .neon-glow {
|
@keyframes fadeIn {
|
||||||
text-shadow: 0 0 10px hsl(var(--primary)),
|
from { opacity: 0; }
|
||||||
0 0 20px hsl(var(--primary) / 0.5),
|
to { opacity: 1; }
|
||||||
0 0 30px hsl(var(--primary) / 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .neon-glow-box {
|
|
||||||
box-shadow: 0 0 10px hsl(var(--primary)),
|
|
||||||
0 0 20px hsl(var(--primary) / 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .neon-glow-box:hover {
|
|
||||||
box-shadow: 0 0 15px hsl(var(--primary)),
|
|
||||||
0 0 30px hsl(var(--primary) / 0.6),
|
|
||||||
0 0 45px hsl(var(--primary) / 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse-glow {
|
|
||||||
0%, 100% {
|
|
||||||
box-shadow: 0 0 10px hsl(var(--primary)),
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,6 +746,20 @@ export default function Home() {
|
||||||
onDeleteSpan={handleDeleteSpan}
|
onDeleteSpan={handleDeleteSpan}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Annotations List - scrollable */}
|
||||||
|
<div className="flex-1 overflow-y-auto scrollbar-thin px-3 py-2 min-h-0">
|
||||||
|
<SpanAnnotationList
|
||||||
|
spanAnnotations={spanAnnotations}
|
||||||
|
spanLabelTypes={spanLabelTypes}
|
||||||
|
selectedSpanId={selectedSpanId}
|
||||||
|
onSelectSpan={handleSelectedSpanChange}
|
||||||
|
onDeleteSpan={handleDeleteSpan}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Predictions */}
|
||||||
|
<div className="px-3 py-2 border-t border-sidebar-border">
|
||||||
<PredictionPanel
|
<PredictionPanel
|
||||||
predictionState={predictionState}
|
predictionState={predictionState}
|
||||||
onToggleVisibility={togglePredictionVisibility}
|
onToggleVisibility={togglePredictionVisibility}
|
||||||
|
|
@ -756,10 +772,40 @@ export default function Home() {
|
||||||
showOnlyDisagreements={showOnlyDisagreements}
|
showOnlyDisagreements={showOnlyDisagreements}
|
||||||
onToggleShowOnlyDisagreements={toggleShowOnlyDisagreements}
|
onToggleShowOnlyDisagreements={toggleShowOnlyDisagreements}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</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">
|
||||||
|
{/* Chart top bar */}
|
||||||
|
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-card/50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="font-mono text-sm font-semibold text-foreground">
|
||||||
|
{activeChart?.name || 'No chart'}
|
||||||
|
</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 */}
|
{/* Loading overlay for predictions */}
|
||||||
{predictionState.isLoading && (
|
{predictionState.isLoading && (
|
||||||
<div className="absolute inset-0 bg-background/50 backdrop-blur-sm z-50 flex items-center justify-center">
|
<div className="absolute inset-0 bg-background/50 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||||
|
|
@ -795,7 +841,8 @@ export default function Home() {
|
||||||
onPredictionClick={handlePredictionClick}
|
onPredictionClick={handlePredictionClick}
|
||||||
onPredictionDismiss={handlePredictionDismiss}
|
onPredictionDismiss={handlePredictionDismiss}
|
||||||
/>
|
/>
|
||||||
</main>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 “{charts.find((c) => c.id === confirmDeleteId)?.name}” and all its candles and annotations? This cannot be undone.
|
Delete “{charts.find((c) => c.id === confirmDeleteId)?.name}” 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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,43 +41,36 @@ export default function PredictionPanel({
|
||||||
spans,
|
spans,
|
||||||
} = predictionState;
|
} = predictionState;
|
||||||
|
|
||||||
if (!isModelOnline) {
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 border-t border-border bg-card">
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-3">
|
{/* Collapsible header */}
|
||||||
<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 (
|
|
||||||
<div className="p-4 border-t border-border bg-card">
|
|
||||||
{/* Header with master toggle */}
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className={`w-2 h-2 rounded-full ${isModelOnline ? 'bg-green-500' : 'bg-red-500'}`} />
|
|
||||||
<h3 className="text-sm font-semibold text-foreground">Predictions</h3>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={onToggleVisibility}
|
onClick={() => setExpanded(!expanded)}
|
||||||
className={`px-3 py-1 text-xs rounded ${
|
className="w-full flex items-center justify-between py-1"
|
||||||
visible
|
|
||||||
? 'bg-primary text-primary-foreground'
|
|
||||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{visible ? 'Hide' : 'Show'}
|
<div className="flex items-center gap-1.5">
|
||||||
</button>
|
<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>
|
||||||
|
<ChevronDown className={`w-3 h-3 text-muted-foreground transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="mt-1 space-y-2">
|
||||||
|
{!isModelOnline ? (
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
Prediction service unavailable.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{/* Model Info */}
|
{/* Model Info */}
|
||||||
{modelInfo && (
|
{modelInfo && (
|
||||||
<div className="mb-3 p-2 bg-muted/50 rounded text-xs">
|
<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">Model:</span>
|
<span className="text-muted-foreground">Model:</span>
|
||||||
<span className="font-mono text-foreground">{modelInfo.model_name}</span>
|
<span className="font-mono text-foreground">{modelInfo.model_name}</span>
|
||||||
|
|
@ -91,35 +87,33 @@ export default function PredictionPanel({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 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'}
|
{isLoading ? 'Loading...' : 'Run on Visible'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onFetchBatchPredictions}
|
onClick={onFetchBatchPredictions}
|
||||||
disabled={isLoading || !isModelOnline}
|
disabled={isLoading}
|
||||||
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"
|
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
|
Predict All
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error Display */}
|
{/* Error */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-3 p-2 bg-destructive/10 border border-destructive/20 rounded text-xs text-destructive">
|
<p className="text-[10px] text-destructive">{error}</p>
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Confidence Slider */}
|
{/* Confidence Slider */}
|
||||||
<div className="mb-3">
|
<div>
|
||||||
<div className="flex justify-between items-center mb-1">
|
<div className="flex justify-between items-center mb-1">
|
||||||
<label className="text-xs text-muted-foreground">Confidence Threshold</label>
|
<label className="text-[10px] text-muted-foreground">Confidence</label>
|
||||||
<span className="text-xs font-mono text-foreground">{(confidenceThreshold * 100).toFixed(0)}%</span>
|
<span className="text-[10px] font-mono text-foreground">{(confidenceThreshold * 100).toFixed(0)}%</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
|
|
@ -127,34 +121,47 @@ export default function PredictionPanel({
|
||||||
max="100"
|
max="100"
|
||||||
value={confidenceThreshold * 100}
|
value={confidenceThreshold * 100}
|
||||||
onChange={(e) => onConfidenceChange(Number(e.target.value) / 100)}
|
onChange={(e) => onConfidenceChange(Number(e.target.value) / 100)}
|
||||||
className="w-full h-1 bg-muted rounded-lg appearance-none cursor-pointer"
|
className="w-full h-1 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Label Filter Checkboxes */}
|
{/* Show on chart toggle */}
|
||||||
{modelInfo && (
|
<div className="flex items-center justify-between">
|
||||||
<div className="mb-3">
|
<label className="text-[10px] text-muted-foreground">Show on chart</label>
|
||||||
<label className="text-xs text-muted-foreground mb-2 block">Filter by Label</label>
|
<button
|
||||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
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) => {
|
{modelInfo.labels.map((label) => {
|
||||||
const metrics = modelInfo.per_class_metrics.find((m) => m.label === label);
|
const metrics = modelInfo.per_class_metrics.find((m) => m.label === label);
|
||||||
const isSelected = selectedLabels.has(label);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
key={label}
|
key={label}
|
||||||
className="flex items-center gap-2 p-1 rounded hover:bg-muted/50 cursor-pointer"
|
className="flex items-center gap-1.5 p-0.5 rounded hover:bg-secondary/50 cursor-pointer"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isSelected}
|
checked={selectedLabels.has(label)}
|
||||||
onChange={() => onToggleLabelSelection(label)}
|
onChange={() => onToggleLabelSelection(label)}
|
||||||
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 flex-1">{label}</span>
|
||||||
{metrics && (
|
{metrics && (
|
||||||
<span className="text-xs text-muted-foreground font-mono">
|
<span className="text-[10px] text-muted-foreground font-mono">
|
||||||
F1: {(metrics.f1_score * 100).toFixed(0)}%
|
F1:{(metrics.f1_score * 100).toFixed(0)}%
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -166,29 +173,23 @@ export default function PredictionPanel({
|
||||||
|
|
||||||
{/* Disagreement Filter */}
|
{/* Disagreement Filter */}
|
||||||
{predictionSummary && predictionSummary.disagreements.length > 0 && onToggleShowOnlyDisagreements && (
|
{predictionSummary && predictionSummary.disagreements.length > 0 && onToggleShowOnlyDisagreements && (
|
||||||
<div className="mb-3">
|
<label className="flex items-center gap-1.5 p-1 bg-secondary/30 rounded cursor-pointer">
|
||||||
<label className="flex items-center gap-2 p-2 bg-muted/50 rounded cursor-pointer hover:bg-muted">
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={showOnlyDisagreements}
|
checked={showOnlyDisagreements}
|
||||||
onChange={onToggleShowOnlyDisagreements}
|
onChange={onToggleShowOnlyDisagreements}
|
||||||
className="w-3 h-3"
|
className="w-3 h-3"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-foreground">Show only disagreements</span>
|
<span className="text-[10px] text-foreground">Show only disagreements</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Prediction Summary */}
|
{/* Summary */}
|
||||||
{visible && spans.length > 0 && predictionSummary && (
|
{visible && spans.length > 0 && predictionSummary ? (
|
||||||
<div className="p-2 bg-muted/30 rounded text-xs space-y-1">
|
<div className="p-2 bg-secondary/30 rounded text-[10px] space-y-0.5">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Predictions:</span>
|
<span className="text-muted-foreground">Predictions:</span>
|
||||||
<span className="text-foreground font-mono">{predictionSummary.total_predictions}</span>
|
<span className="font-mono text-foreground">{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>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Agreements:</span>
|
<span className="text-muted-foreground">Agreements:</span>
|
||||||
|
|
@ -196,9 +197,17 @@ export default function PredictionPanel({
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Disagreements:</span>
|
<span className="text-muted-foreground">Disagreements:</span>
|
||||||
<span className="text-orange-600 font-mono">{predictionSummary.disagreements.length}</span>
|
<span className="text-orange-500 font-mono">{predictionSummary.disagreements.length}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-[10px] text-muted-foreground text-center py-2">
|
||||||
|
No predictions loaded.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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">
|
|
||||||
{sortedSpans.map((span) => {
|
|
||||||
const { displayName, color } = getLabelInfo(span.label);
|
const { displayName, color } = getLabelInfo(span.label);
|
||||||
const isSelected = span.id === selectedSpanId;
|
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
|
isSelected
|
||||||
? 'bg-primary/10 border border-primary'
|
? 'bg-primary/10 border border-primary'
|
||||||
: 'hover:bg-secondary/50 border border-transparent'
|
: 'hover:bg-secondary/50 border border-transparent'
|
||||||
}
|
}`}
|
||||||
`}
|
|
||||||
onClick={() => onSelectSpan(span.id)}
|
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{/* Time range */}
|
<div className="text-[10px] text-muted-foreground font-mono">
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{formatTime(span.start_time)} → {formatTime(span.end_time)}
|
{formatTime(span.start_time)} → {formatTime(span.end_time)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Label badge */}
|
|
||||||
<div className="mt-1">
|
|
||||||
<span
|
<span
|
||||||
className="px-2 py-0.5 rounded text-xs font-medium text-white"
|
className="px-1.5 py-0.5 rounded text-[10px] font-medium text-white mt-0.5 inline-block"
|
||||||
style={{ backgroundColor: color }}
|
style={{ backgroundColor: color }}
|
||||||
>
|
>
|
||||||
{displayName}
|
{displayName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Additional info */}
|
|
||||||
{(span.confidence || span.outcome) && (
|
{(span.confidence || span.outcome) && (
|
||||||
<div className="mt-1 text-xs text-muted-foreground flex gap-2">
|
<div className="mt-0.5 text-[10px] text-muted-foreground flex gap-2">
|
||||||
{span.confidence && <span>Confidence: {span.confidence}</span>}
|
{span.confidence && <span>C:{span.confidence}</span>}
|
||||||
{span.outcome && <span>Outcome: {span.outcome}</span>}
|
{span.outcome && <span>{span.outcome}</span>}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Notes preview */}
|
|
||||||
{span.notes && (
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground truncate">
|
|
||||||
{span.notes}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
{/* Delete button */}
|
className="p-0.5 text-muted-foreground hover:text-destructive flex-shrink-0"
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 w-6 p-0 hover:bg-destructive/20 hover:text-destructive"
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDeleteSpan(span.id);
|
onDeleteSpan(span.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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="h-5 w-5 text-foreground" />
|
<Icon className="w-3.5 h-3.5" />
|
||||||
</TooltipTrigger>
|
</button>
|
||||||
<TooltipContent>
|
|
||||||
<p>Theme: {themeName}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
<RectangleHorizontal className="w-3.5 h-3.5" /> Rect
|
||||||
{type.display_name}
|
</button>
|
||||||
</Button>
|
<button
|
||||||
))}
|
onClick={() => handleToolClick('span')}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 text-xs rounded transition-colors ${
|
||||||
{/* Line type buttons */}
|
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 && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{markerTypes.map((type) => (
|
||||||
|
<button
|
||||||
|
key={type.id}
|
||||||
|
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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{type.icon === 'arrowUp' ? <ArrowUpCircle className="w-3.5 h-3.5" /> :
|
||||||
|
type.icon === 'arrowDown' ? <ArrowDownCircle className="w-3.5 h-3.5" /> :
|
||||||
|
<ArrowUpCircle className="w-3.5 h-3.5" />}
|
||||||
|
{type.display_name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Color swatches */}
|
||||||
|
<div className="flex gap-1 pt-1">
|
||||||
|
{colors.map((preset) => {
|
||||||
|
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
|
<button
|
||||||
onClick={() => setLabelsExpanded(!labelsExpanded)}
|
onClick={() => setLabelsExpanded(!labelsExpanded)}
|
||||||
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 mt-1"
|
||||||
>
|
>
|
||||||
<span className="text-sm font-semibold text-foreground">
|
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
Label Annotations ({labelAnnotations.length})
|
Annotations ({labelAnnotations.length})
|
||||||
</span>
|
</span>
|
||||||
{labelsExpanded ? (
|
<ChevronDown className={`w-3 h-3 text-muted-foreground transition-transform ${labelsExpanded ? 'rotate-180' : ''}`} />
|
||||||
<ChevronUp className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{labelsExpanded && (
|
{labelsExpanded && (
|
||||||
<div className="mt-3 space-y-2">
|
<div className="space-y-1 mt-1">
|
||||||
{/* Count display */}
|
{/* Count display */}
|
||||||
<div className="text-xs text-muted-foreground px-2">
|
{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,37 +280,35 @@ export default function Toolbox({
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Search input */}
|
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search by timestamp..."
|
placeholder="Search..."
|
||||||
value={searchText}
|
value={searchText}
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
className="h-8 text-sm"
|
className="h-7 text-xs"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 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 ? (
|
||||||
<div className="text-xs text-muted-foreground text-center py-4">
|
<p className="text-[10px] text-muted-foreground text-center py-3">
|
||||||
{labelAnnotations.length === 0
|
{labelAnnotations.length === 0 ? 'No annotations yet.' : 'No matching labels.'}
|
||||||
? 'No labels yet. Click Break Up or Break Down tools to add labels.'
|
</p>
|
||||||
: 'No matching labels found.'}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
filteredAnnotations.map((annotation) => {
|
filteredAnnotations.map((annotation) => {
|
||||||
const formattedTime = new Date(annotation.timestamp * 1000).toLocaleString('en-US', {
|
const formattedTime = new Date(annotation.timestamp * 1000).toLocaleString('en-US', {
|
||||||
|
|
@ -339,18 +324,17 @@ export default function Toolbox({
|
||||||
<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-2 py-0.5 rounded text-xs font-medium"
|
className="px-1.5 py-0.5 rounded text-[10px] font-medium mt-0.5 inline-block"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: annotationType ? `${annotationType.color}20` : '#e5e7eb',
|
backgroundColor: annotationType ? `${annotationType.color}20` : '#e5e7eb',
|
||||||
color: annotationType?.color || '#374151',
|
color: annotationType?.color || '#374151',
|
||||||
|
|
@ -359,15 +343,12 @@ export default function Toolbox({
|
||||||
{annotationType?.display_name || annotation.label_type}
|
{annotationType?.display_name || annotation.label_type}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<button
|
||||||
<Button
|
className="p-0.5 text-muted-foreground hover:text-destructive"
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
onClick={(e) => handleLabelDelete(annotation.id, e)}
|
onClick={(e) => handleLabelDelete(annotation.id, e)}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -377,34 +358,5 @@ export default function Toolbox({
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
73
tailwind.config.js
Normal file
73
tailwind.config.js
Normal 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: [],
|
||||||
|
};
|
||||||
Loading…
Add table
Add a link
Reference in a new issue