feat: implement label management with sidebar, hacker theme, and Docker support
- Add label selection on chart with visual highlight (size 2x, color change) - Implement keyboard delete handler (Delete/Backspace keys) - Add comprehensive label management sidebar with: - Collapsible label annotations section - Search by timestamp - Filter by type (Break Up, Break Down, All) - Individual delete buttons - Count display - Click to select/highlight on chart - Transform UI with hacker theme: - Matrix green (#00ff41) on dark background (#0a0e0a) - Monospace font (JetBrains Mono) - Glow effects on button hover and active states - Custom scrollbar styling - Terminal-inspired aesthetic - Add Docker deployment: - Multi-stage Dockerfile with standalone output - docker-compose.yml with volume persistence - Non-root user (nextjs) for security - Health check endpoint integration - Tailwind and CSS enhancements: - Custom colors (matrix, matrixDim, neonRed, etc.) - Glow box shadows and animations - Selection and scrollbar styling
This commit is contained in:
parent
74b84073a9
commit
a1fa86fe55
14 changed files with 509 additions and 42 deletions
9
.dockerignore
Normal file
9
.dockerignore
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
data/
|
||||||
|
*.md
|
||||||
|
.env*
|
||||||
|
*.log
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
3
.env.example
Normal file
3
.env.example
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=3000
|
||||||
|
DATABASE_PATH=/app/data/candles.db
|
||||||
37
Dockerfile
Normal file
37
Dockerfile
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# Build stage
|
||||||
|
FROM node:18-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
|
||||||
|
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||||
|
|
||||||
|
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
|
||||||
|
|
||||||
|
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
15
docker-compose.yml
Normal file
15
docker-compose.yml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
candle-annotator:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- candle-data:/app/data
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
candle-data:
|
||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
|
|
@ -1,6 +1,6 @@
|
||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/dev/types/routes.d.ts";
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {}
|
const nextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = nextConfig
|
module.exports = nextConfig
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { annotations } from '@/lib/db/schema';
|
import { annotations } from '@/lib/db/schema';
|
||||||
|
import { eq, inArray } from 'drizzle-orm';
|
||||||
|
|
||||||
// GET all annotations
|
// GET all annotations
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
|
@ -66,3 +67,42 @@ export async function POST(request: NextRequest) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DELETE annotations with bulk operations
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = request.nextUrl;
|
||||||
|
const type = searchParams.get('type');
|
||||||
|
const all = searchParams.get('all');
|
||||||
|
|
||||||
|
let result;
|
||||||
|
|
||||||
|
if (all === 'true') {
|
||||||
|
// Delete all annotations
|
||||||
|
result = await db.delete(annotations).returning();
|
||||||
|
} else if (type) {
|
||||||
|
// Delete by type(s)
|
||||||
|
const types = type.split(',').map((t) => t.trim());
|
||||||
|
result = await db
|
||||||
|
.delete(annotations)
|
||||||
|
.where(inArray(annotations.label_type, types))
|
||||||
|
.returning();
|
||||||
|
} else {
|
||||||
|
// No filter specified
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Specify type or all parameter for bulk delete' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
deleted: result.length,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || 'Failed to delete annotations' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
41
src/app/api/health/route.ts
Normal file
41
src/app/api/health/route.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = request.nextUrl;
|
||||||
|
const checkDb = searchParams.get('check');
|
||||||
|
|
||||||
|
const response: any = {
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (checkDb === 'db') {
|
||||||
|
try {
|
||||||
|
// Test database connectivity with a simple query
|
||||||
|
await db.execute('SELECT 1');
|
||||||
|
response.database = 'ok';
|
||||||
|
} catch (dbError: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
status: 'error',
|
||||||
|
database: 'failed',
|
||||||
|
message: dbError.message || 'Database check failed',
|
||||||
|
},
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(response);
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
status: 'error',
|
||||||
|
message: error.message || 'Health check failed',
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,25 +4,25 @@
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 222.2 84% 4.9%;
|
--background: 120 100% 4%;
|
||||||
--foreground: 210 40% 98%;
|
--foreground: 120 100% 50%;
|
||||||
--card: 222.2 84% 4.9%;
|
--card: 120 100% 6%;
|
||||||
--card-foreground: 210 40% 98%;
|
--card-foreground: 120 100% 50%;
|
||||||
--popover: 222.2 84% 4.9%;
|
--popover: 120 100% 4%;
|
||||||
--popover-foreground: 210 40% 98%;
|
--popover-foreground: 120 100% 50%;
|
||||||
--primary: 217.2 91.2% 59.8%;
|
--primary: 120 100% 50%;
|
||||||
--primary-foreground: 222.2 47.4% 11.2%;
|
--primary-foreground: 120 100% 4%;
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
--secondary: 120 100% 8%;
|
||||||
--secondary-foreground: 210 40% 98%;
|
--secondary-foreground: 120 100% 50%;
|
||||||
--muted: 217.2 32.6% 17.5%;
|
--muted: 120 100% 10%;
|
||||||
--muted-foreground: 215 20.2% 65.1%;
|
--muted-foreground: 120 100% 40%;
|
||||||
--accent: 217.2 32.6% 17.5%;
|
--accent: 120 100% 50%;
|
||||||
--accent-foreground: 210 40% 98%;
|
--accent-foreground: 120 100% 4%;
|
||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 348 100% 50%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 120 100% 4%;
|
||||||
--border: 217.2 32.6% 17.5%;
|
--border: 120 100% 10%;
|
||||||
--input: 217.2 32.6% 17.5%;
|
--input: 120 100% 8%;
|
||||||
--ring: 224.3 76.3% 48%;
|
--ring: 120 100% 50%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -33,6 +33,24 @@
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
::selection {
|
||||||
|
@apply bg-matrix/40 text-matrix;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
@apply bg-matrixDark;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-matrix rounded;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,12 @@ export default function RootLayout({
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
<body className="antialiased">{children}</body>
|
<body className="antialiased">{children}</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,23 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import Toolbox, { Tool } from '@/components/Toolbox';
|
import Toolbox, { Tool } from '@/components/Toolbox';
|
||||||
import FileUpload from '@/components/FileUpload';
|
import FileUpload from '@/components/FileUpload';
|
||||||
import CandleChart, { CandleChartHandle } from '@/components/CandleChart';
|
import CandleChart, { CandleChartHandle } from '@/components/CandleChart';
|
||||||
|
|
||||||
|
interface Annotation {
|
||||||
|
id: number;
|
||||||
|
timestamp: number;
|
||||||
|
label_type: string;
|
||||||
|
geometry: any;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [activeTool, setActiveTool] = useState<Tool>(null);
|
const [activeTool, setActiveTool] = useState<Tool>(null);
|
||||||
const [selectedColor, setSelectedColor] = useState('#3b82f6');
|
const [selectedColor, setSelectedColor] = useState('#3b82f6');
|
||||||
|
const [selectedLabelId, setSelectedLabelId] = useState<number | null>(null);
|
||||||
|
const [annotations, setAnnotations] = useState<Annotation[]>([]);
|
||||||
const chartRef = useRef<CandleChartHandle>(null);
|
const chartRef = useRef<CandleChartHandle>(null);
|
||||||
|
|
||||||
const handleExport = () => {
|
const handleExport = () => {
|
||||||
|
|
@ -19,11 +29,63 @@ export default function Home() {
|
||||||
chartRef.current?.refreshData();
|
chartRef.current?.refreshData();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAnnotationChange = () => {
|
const handleAnnotationChange = async () => {
|
||||||
// Refresh chart when annotations change
|
// Refresh chart when annotations change
|
||||||
chartRef.current?.refreshData();
|
await chartRef.current?.refreshData();
|
||||||
|
// Fetch annotations for sidebar
|
||||||
|
const response = await fetch('/api/annotations');
|
||||||
|
const data = await response.json();
|
||||||
|
setAnnotations(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLabelDelete = async (id: number) => {
|
||||||
|
// Remove from local state
|
||||||
|
setAnnotations(annotations.filter((a) => a.id !== id));
|
||||||
|
if (selectedLabelId === id) {
|
||||||
|
setSelectedLabelId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLabelSelect = (id: number) => {
|
||||||
|
setSelectedLabelId(id === -1 ? null : id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch annotations on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchAnnotations = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/annotations');
|
||||||
|
const data = await response.json();
|
||||||
|
setAnnotations(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch annotations:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchAnnotations();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keyboard handler for Delete/Backspace key
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = async (e: KeyboardEvent) => {
|
||||||
|
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedLabelId !== null) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/annotations/${selectedLabelId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
setSelectedLabelId(null);
|
||||||
|
chartRef.current?.refreshData();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete label:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [selectedLabelId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen">
|
<div className="flex h-screen">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
|
|
@ -40,6 +102,10 @@ export default function Home() {
|
||||||
onExport={handleExport}
|
onExport={handleExport}
|
||||||
selectedColor={selectedColor}
|
selectedColor={selectedColor}
|
||||||
onColorChange={setSelectedColor}
|
onColorChange={setSelectedColor}
|
||||||
|
annotations={annotations}
|
||||||
|
selectedLabelId={selectedLabelId}
|
||||||
|
onLabelSelect={handleLabelSelect}
|
||||||
|
onLabelDelete={handleLabelDelete}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|
@ -50,6 +116,8 @@ export default function Home() {
|
||||||
activeTool={activeTool}
|
activeTool={activeTool}
|
||||||
onAnnotationChange={handleAnnotationChange}
|
onAnnotationChange={handleAnnotationChange}
|
||||||
selectedColor={selectedColor}
|
selectedColor={selectedColor}
|
||||||
|
selectedLabelId={selectedLabelId}
|
||||||
|
onLabelSelect={handleLabelSelect}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ interface CandleChartProps {
|
||||||
activeTool: string | null;
|
activeTool: string | null;
|
||||||
onAnnotationChange?: () => void;
|
onAnnotationChange?: () => void;
|
||||||
selectedColor: string;
|
selectedColor: string;
|
||||||
|
selectedLabelId?: number | null;
|
||||||
|
onLabelSelect?: (id: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CandleChartHandle {
|
export interface CandleChartHandle {
|
||||||
|
|
@ -36,7 +38,7 @@ export interface CandleChartHandle {
|
||||||
}
|
}
|
||||||
|
|
||||||
const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
||||||
({ activeTool, onAnnotationChange, selectedColor }, ref) => {
|
({ activeTool, onAnnotationChange, selectedColor, selectedLabelId, onLabelSelect }, ref) => {
|
||||||
const chartContainerRef = useRef<HTMLDivElement>(null);
|
const chartContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const chartRef = useRef<IChartApi | null>(null);
|
const chartRef = useRef<IChartApi | null>(null);
|
||||||
const seriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null);
|
const seriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null);
|
||||||
|
|
@ -159,17 +161,23 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
||||||
);
|
);
|
||||||
|
|
||||||
const markers = markerAnnotations
|
const markers = markerAnnotations
|
||||||
.map((annotation) => ({
|
.map((annotation) => {
|
||||||
time: annotation.timestamp as Time,
|
const isSelected = annotation.id === selectedLabelId;
|
||||||
position: annotation.label_type === 'break_up' ? ('belowBar' as const) : ('aboveBar' as const),
|
return {
|
||||||
color: annotation.label_type === 'break_up' ? '#22c55e' : '#ef4444',
|
time: annotation.timestamp as Time,
|
||||||
shape: annotation.label_type === 'break_up' ? ('arrowUp' as const) : ('arrowDown' as const),
|
position: annotation.label_type === 'break_up' ? ('belowBar' as const) : ('aboveBar' as const),
|
||||||
text: annotation.label_type === 'break_up' ? 'Break Up' : 'Break Down',
|
color: isSelected
|
||||||
}))
|
? (annotation.label_type === 'break_up' ? '#00ff41' : '#ff0040')
|
||||||
|
: (annotation.label_type === 'break_up' ? '#22c55e' : '#ef4444'),
|
||||||
|
shape: annotation.label_type === 'break_up' ? ('arrowUp' as const) : ('arrowDown' as const),
|
||||||
|
text: annotation.label_type === 'break_up' ? 'Break Up' : 'Break Down',
|
||||||
|
size: isSelected ? 2 : 1,
|
||||||
|
};
|
||||||
|
})
|
||||||
.sort((a, b) => (a.time as number) - (b.time as number));
|
.sort((a, b) => (a.time as number) - (b.time as number));
|
||||||
|
|
||||||
seriesRef.current.setMarkers(markers);
|
seriesRef.current.setMarkers(markers);
|
||||||
}, [annotations]);
|
}, [annotations, selectedLabelId]);
|
||||||
|
|
||||||
// Handle chart clicks for annotation
|
// Handle chart clicks for annotation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -244,6 +252,23 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Select/deselect label markers by clicking them
|
||||||
|
if (!activeTool || activeTool === 'break_up' || activeTool === 'break_down') {
|
||||||
|
const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number);
|
||||||
|
|
||||||
|
// Find annotation at this timestamp (within tolerance)
|
||||||
|
const tolerance = 60; // 60 seconds tolerance
|
||||||
|
const annotation = annotations.find(
|
||||||
|
(a) =>
|
||||||
|
(a.label_type === 'break_up' || a.label_type === 'break_down') &&
|
||||||
|
Math.abs(a.timestamp - timestamp) < tolerance
|
||||||
|
);
|
||||||
|
|
||||||
|
if (annotation) {
|
||||||
|
onLabelSelect?.(annotation.id === selectedLabelId ? -1 : annotation.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
chartRef.current.subscribeClick(handleClick);
|
chartRef.current.subscribeClick(handleClick);
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,47 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ArrowUpCircle, ArrowDownCircle, TrendingUp, Trash2, Download } from 'lucide-react';
|
import { ArrowUpCircle, ArrowDownCircle, TrendingUp, Trash2, Download, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
||||||
export type Tool = 'break_up' | 'break_down' | 'line' | 'delete' | null;
|
export type Tool = 'break_up' | 'break_down' | 'line' | 'delete' | null;
|
||||||
|
|
||||||
|
interface Annotation {
|
||||||
|
id: number;
|
||||||
|
timestamp: number;
|
||||||
|
label_type: string;
|
||||||
|
geometry: any;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface ToolboxProps {
|
interface ToolboxProps {
|
||||||
activeTool: Tool;
|
activeTool: Tool;
|
||||||
onToolChange: (tool: Tool) => void;
|
onToolChange: (tool: Tool) => void;
|
||||||
onExport: () => void;
|
onExport: () => void;
|
||||||
selectedColor: string;
|
selectedColor: string;
|
||||||
onColorChange: (color: string) => void;
|
onColorChange: (color: string) => void;
|
||||||
|
annotations?: Annotation[];
|
||||||
|
selectedLabelId?: number | null;
|
||||||
|
onLabelSelect?: (id: number) => void;
|
||||||
|
onLabelDelete?: (id: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Toolbox({ activeTool, onToolChange, onExport, selectedColor, onColorChange }: ToolboxProps) {
|
export default function Toolbox({
|
||||||
|
activeTool,
|
||||||
|
onToolChange,
|
||||||
|
onExport,
|
||||||
|
selectedColor,
|
||||||
|
onColorChange,
|
||||||
|
annotations = [],
|
||||||
|
selectedLabelId = null,
|
||||||
|
onLabelSelect,
|
||||||
|
onLabelDelete,
|
||||||
|
}: ToolboxProps) {
|
||||||
|
const [labelsExpanded, setLabelsExpanded] = useState(true);
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [filterType, setFilterType] = useState<'all' | 'break_up' | 'break_down'>('all');
|
||||||
|
|
||||||
const handleToolClick = (tool: Tool) => {
|
const handleToolClick = (tool: Tool) => {
|
||||||
// Toggle: if clicking the active tool, deactivate it
|
// Toggle: if clicking the active tool, deactivate it
|
||||||
if (activeTool === tool) {
|
if (activeTool === tool) {
|
||||||
|
|
@ -24,14 +51,48 @@ export default function Toolbox({ activeTool, onToolChange, onExport, selectedCo
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Filter and sort annotations
|
||||||
|
const labelAnnotations = annotations
|
||||||
|
.filter((a) => a.label_type === 'break_up' || a.label_type === 'break_down')
|
||||||
|
.filter((a) => (filterType === 'all' ? true : a.label_type === filterType))
|
||||||
|
.sort((a, b) => b.timestamp - a.timestamp);
|
||||||
|
|
||||||
|
// Apply search filter
|
||||||
|
const filteredAnnotations = labelAnnotations.filter((a) => {
|
||||||
|
const formattedTime = new Date(a.timestamp * 1000).toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
return formattedTime.toLowerCase().includes(searchText.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
const breakUpCount = labelAnnotations.filter((a) => a.label_type === 'break_up').length;
|
||||||
|
const breakDownCount = labelAnnotations.filter((a) => a.label_type === 'break_down').length;
|
||||||
|
|
||||||
|
const handleLabelDelete = async (id: number, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/annotations/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
onLabelDelete?.(id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete label:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-64 bg-card border-r border-border p-4 flex flex-col gap-4">
|
<div className="flex-1 flex flex-col gap-4 overflow-y-auto">
|
||||||
<h2 className="text-lg font-semibold text-foreground">Annotation Tools</h2>
|
<h2 className="text-lg font-semibold text-foreground">Annotation Tools</h2>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant={activeTool === 'break_up' ? 'default' : 'outline'}
|
variant={activeTool === 'break_up' ? 'default' : 'outline'}
|
||||||
className="justify-start gap-2"
|
className={`justify-start gap-2 ${activeTool === 'break_up' ? 'animate-glow-pulse shadow-glow' : 'hover:shadow-glow'}`}
|
||||||
onClick={() => handleToolClick('break_up')}
|
onClick={() => handleToolClick('break_up')}
|
||||||
>
|
>
|
||||||
<ArrowUpCircle className="w-5 h-5" />
|
<ArrowUpCircle className="w-5 h-5" />
|
||||||
|
|
@ -40,7 +101,7 @@ export default function Toolbox({ activeTool, onToolChange, onExport, selectedCo
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={activeTool === 'break_down' ? 'default' : 'outline'}
|
variant={activeTool === 'break_down' ? 'default' : 'outline'}
|
||||||
className="justify-start gap-2"
|
className={`justify-start gap-2 ${activeTool === 'break_down' ? 'animate-glow-pulse shadow-glow' : 'hover:shadow-glow'}`}
|
||||||
onClick={() => handleToolClick('break_down')}
|
onClick={() => handleToolClick('break_down')}
|
||||||
>
|
>
|
||||||
<ArrowDownCircle className="w-5 h-5" />
|
<ArrowDownCircle className="w-5 h-5" />
|
||||||
|
|
@ -49,14 +110,14 @@ export default function Toolbox({ activeTool, onToolChange, onExport, selectedCo
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={activeTool === 'line' ? 'default' : 'outline'}
|
variant={activeTool === 'line' ? 'default' : 'outline'}
|
||||||
className="justify-start gap-2"
|
className={`justify-start gap-2 ${activeTool === 'line' ? 'animate-glow-pulse shadow-glow' : 'hover:shadow-glow'}`}
|
||||||
onClick={() => handleToolClick('line')}
|
onClick={() => handleToolClick('line')}
|
||||||
>
|
>
|
||||||
<TrendingUp className="w-5 h-5" />
|
<TrendingUp className="w-5 h-5" />
|
||||||
Draw Line
|
Draw Line
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Color picker - shown when line tool is available */}
|
{/* Color picker */}
|
||||||
<div className="flex gap-1 px-1">
|
<div className="flex gap-1 px-1">
|
||||||
{[
|
{[
|
||||||
{ color: '#ef4444', name: 'Red' },
|
{ color: '#ef4444', name: 'Red' },
|
||||||
|
|
@ -90,7 +151,7 @@ export default function Toolbox({ activeTool, onToolChange, onExport, selectedCo
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={activeTool === 'delete' ? 'destructive' : 'outline'}
|
variant={activeTool === 'delete' ? 'destructive' : 'outline'}
|
||||||
className="justify-start gap-2"
|
className={`justify-start gap-2 ${activeTool === 'delete' ? 'animate-glow-pulse shadow-[0_0_15px_#ff0040]' : 'hover:shadow-[0_0_15px_#ff0040]'}`}
|
||||||
onClick={() => handleToolClick('delete')}
|
onClick={() => handleToolClick('delete')}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-5 h-5" />
|
<Trash2 className="w-5 h-5" />
|
||||||
|
|
@ -98,6 +159,110 @@ export default function Toolbox({ activeTool, onToolChange, onExport, selectedCo
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Labels Section */}
|
||||||
|
<div className="border-t border-border pt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setLabelsExpanded(!labelsExpanded)}
|
||||||
|
className="w-full flex items-center justify-between px-2 py-2 hover:bg-secondary/50 rounded"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-semibold text-foreground">
|
||||||
|
Label Annotations ({labelAnnotations.length})
|
||||||
|
</span>
|
||||||
|
{labelsExpanded ? (
|
||||||
|
<ChevronUp className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{labelsExpanded && (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{/* Count display */}
|
||||||
|
<div className="text-xs text-muted-foreground px-2">
|
||||||
|
Break Up: {breakUpCount} | Break Down: {breakDownCount}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search input */}
|
||||||
|
<Input
|
||||||
|
placeholder="Search by timestamp..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Filter dropdown */}
|
||||||
|
<select
|
||||||
|
value={filterType}
|
||||||
|
onChange={(e) => setFilterType(e.target.value as 'all' | 'break_up' | 'break_down')}
|
||||||
|
className="w-full h-8 px-2 text-sm rounded border border-border bg-card"
|
||||||
|
>
|
||||||
|
<option value="all">All Types</option>
|
||||||
|
<option value="break_up">Break Up Only</option>
|
||||||
|
<option value="break_down">Break Down Only</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Labels list */}
|
||||||
|
<div className="max-h-96 overflow-y-auto space-y-2 p-2 border border-border rounded bg-card/50">
|
||||||
|
{filteredAnnotations.length === 0 ? (
|
||||||
|
<div className="text-xs text-muted-foreground text-center py-4">
|
||||||
|
{labelAnnotations.length === 0
|
||||||
|
? 'No labels yet. Click Break Up or Break Down tools to add labels.'
|
||||||
|
: 'No matching labels found.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredAnnotations.map((annotation) => {
|
||||||
|
const formattedTime = new Date(annotation.timestamp * 1000).toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
const isSelected = annotation.id === selectedLabelId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={annotation.id}
|
||||||
|
onClick={() => onLabelSelect?.(annotation.id)}
|
||||||
|
className={`p-2 rounded border text-xs cursor-pointer transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? 'border-matrix bg-matrix/10'
|
||||||
|
: 'border-border hover:bg-secondary/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-mono text-foreground">{formattedTime}</div>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-0.5 rounded text-xs font-semibold ${
|
||||||
|
annotation.label_type === 'break_up'
|
||||||
|
? 'bg-green-900/50 text-matrix'
|
||||||
|
: 'bg-red-900/50 text-neonRed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{annotation.label_type === 'break_up' ? 'BREAK UP' : 'BREAK DOWN'}
|
||||||
|
</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" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Export button */}
|
||||||
<div className="mt-auto pt-4 border-t border-border">
|
<div className="mt-auto pt-4 border-t border-border">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,44 @@ const config: Config = {
|
||||||
DEFAULT: "hsl(var(--card))",
|
DEFAULT: "hsl(var(--card))",
|
||||||
foreground: "hsl(var(--card-foreground))",
|
foreground: "hsl(var(--card-foreground))",
|
||||||
},
|
},
|
||||||
|
matrix: "#00ff41",
|
||||||
|
matrixDim: "#00cc33",
|
||||||
|
matrixDark: "#003311",
|
||||||
|
neonRed: "#ff0040",
|
||||||
|
neonCyan: "#00d4ff",
|
||||||
|
neonYellow: "#ffff00",
|
||||||
|
terminal: "#0a0e0a",
|
||||||
|
terminalLight: "#0d110d",
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
mono: ["JetBrains Mono", "Fira Code", "Courier New", "monospace"],
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
"glow-sm": "0 0 8px #00ff41",
|
||||||
|
glow: "0 0 15px #00ff41, 0 0 30px rgba(0,255,65,0.5)",
|
||||||
|
"glow-lg": "0 0 20px #00ff41, 0 0 40px rgba(0,255,65,0.5)",
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
"glow-pulse": {
|
||||||
|
"0%, 100%": {
|
||||||
|
boxShadow: "0 0 15px #00ff41",
|
||||||
|
},
|
||||||
|
"50%": {
|
||||||
|
boxShadow: "0 0 30px #00ff41, 0 0 50px rgba(0,255,65,0.5)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
flicker: {
|
||||||
|
"0%, 100%": {
|
||||||
|
opacity: "1",
|
||||||
|
},
|
||||||
|
"50%": {
|
||||||
|
opacity: "0.8",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"glow-pulse": "glow-pulse 2s ease-in-out infinite",
|
||||||
|
flicker: "flicker 0.1s ease-in-out",
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: "var(--radius)",
|
lg: "var(--radius)",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue