From a1fa86fe55f898ce4e322d98d5ee0d7e56d07b6d Mon Sep 17 00:00:00 2001 From: Marko Djordjevic Date: Thu, 12 Feb 2026 15:12:59 +0100 Subject: [PATCH] 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 --- .dockerignore | 9 ++ .env.example | 3 + Dockerfile | 37 +++++++ docker-compose.yml | 15 +++ next-env.d.ts | 2 +- next.config.js | 4 +- src/app/api/annotations/route.ts | 40 +++++++ src/app/api/health/route.ts | 41 +++++++ src/app/globals.css | 58 ++++++---- src/app/layout.tsx | 6 + src/app/page.tsx | 74 ++++++++++++- src/components/CandleChart.tsx | 43 ++++++-- src/components/Toolbox.tsx | 181 +++++++++++++++++++++++++++++-- tailwind.config.ts | 38 +++++++ 14 files changed, 509 insertions(+), 42 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 src/app/api/health/route.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b54df39 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +.next +.git +data/ +*.md +.env* +*.log +coverage/ +.DS_Store diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..15b45a5 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +NODE_ENV=production +PORT=3000 +DATABASE_PATH=/app/data/candles.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..97919e8 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..871a61e --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/next.config.js b/next.config.js index 767719f..5cd8cc3 100644 --- a/next.config.js +++ b/next.config.js @@ -1,4 +1,6 @@ /** @type {import('next').NextConfig} */ -const nextConfig = {} +const nextConfig = { + output: 'standalone', +} module.exports = nextConfig diff --git a/src/app/api/annotations/route.ts b/src/app/api/annotations/route.ts index bc1316d..6dcfeff 100644 --- a/src/app/api/annotations/route.ts +++ b/src/app/api/annotations/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { annotations } from '@/lib/db/schema'; +import { eq, inArray } from 'drizzle-orm'; // GET all annotations 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 } + ); + } +} diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..ee90023 --- /dev/null +++ b/src/app/api/health/route.ts @@ -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 } + ); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index d40575c..5f33e3a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -4,25 +4,25 @@ @layer base { :root { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; - --primary: 217.2 91.2% 59.8%; - --primary-foreground: 222.2 47.4% 11.2%; - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; - --accent: 217.2 32.6% 17.5%; - --accent-foreground: 210 40% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - --ring: 224.3 76.3% 48%; + --background: 120 100% 4%; + --foreground: 120 100% 50%; + --card: 120 100% 6%; + --card-foreground: 120 100% 50%; + --popover: 120 100% 4%; + --popover-foreground: 120 100% 50%; + --primary: 120 100% 50%; + --primary-foreground: 120 100% 4%; + --secondary: 120 100% 8%; + --secondary-foreground: 120 100% 50%; + --muted: 120 100% 10%; + --muted-foreground: 120 100% 40%; + --accent: 120 100% 50%; + --accent-foreground: 120 100% 4%; + --destructive: 348 100% 50%; + --destructive-foreground: 120 100% 4%; + --border: 120 100% 10%; + --input: 120 100% 8%; + --ring: 120 100% 50%; --radius: 0.5rem; } } @@ -33,6 +33,24 @@ } body { @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; } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 246d32b..1aa2405 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,6 +13,12 @@ export default function RootLayout({ }>) { return ( + + + {children} ); diff --git a/src/app/page.tsx b/src/app/page.tsx index 0664b40..b13635c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,13 +1,23 @@ 'use client'; -import { useState, useRef } from 'react'; +import { useState, useRef, useEffect } from 'react'; import Toolbox, { Tool } from '@/components/Toolbox'; import FileUpload from '@/components/FileUpload'; import CandleChart, { CandleChartHandle } from '@/components/CandleChart'; +interface Annotation { + id: number; + timestamp: number; + label_type: string; + geometry: any; + created_at: number; +} + export default function Home() { const [activeTool, setActiveTool] = useState(null); const [selectedColor, setSelectedColor] = useState('#3b82f6'); + const [selectedLabelId, setSelectedLabelId] = useState(null); + const [annotations, setAnnotations] = useState([]); const chartRef = useRef(null); const handleExport = () => { @@ -19,11 +29,63 @@ export default function Home() { chartRef.current?.refreshData(); }; - const handleAnnotationChange = () => { + const handleAnnotationChange = async () => { // 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 (
{/* Sidebar */} @@ -40,6 +102,10 @@ export default function Home() { onExport={handleExport} selectedColor={selectedColor} onColorChange={setSelectedColor} + annotations={annotations} + selectedLabelId={selectedLabelId} + onLabelSelect={handleLabelSelect} + onLabelDelete={handleLabelDelete} /> @@ -50,6 +116,8 @@ export default function Home() { activeTool={activeTool} onAnnotationChange={handleAnnotationChange} selectedColor={selectedColor} + selectedLabelId={selectedLabelId} + onLabelSelect={handleLabelSelect} />
diff --git a/src/components/CandleChart.tsx b/src/components/CandleChart.tsx index 63522f0..90d36d6 100644 --- a/src/components/CandleChart.tsx +++ b/src/components/CandleChart.tsx @@ -29,6 +29,8 @@ interface CandleChartProps { activeTool: string | null; onAnnotationChange?: () => void; selectedColor: string; + selectedLabelId?: number | null; + onLabelSelect?: (id: number) => void; } export interface CandleChartHandle { @@ -36,7 +38,7 @@ export interface CandleChartHandle { } const CandleChart = forwardRef( - ({ activeTool, onAnnotationChange, selectedColor }, ref) => { + ({ activeTool, onAnnotationChange, selectedColor, selectedLabelId, onLabelSelect }, ref) => { const chartContainerRef = useRef(null); const chartRef = useRef(null); const seriesRef = useRef | null>(null); @@ -159,17 +161,23 @@ const CandleChart = forwardRef( ); const markers = markerAnnotations - .map((annotation) => ({ - time: annotation.timestamp as Time, - position: annotation.label_type === 'break_up' ? ('belowBar' as const) : ('aboveBar' as const), - color: 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', - })) + .map((annotation) => { + const isSelected = annotation.id === selectedLabelId; + return { + time: annotation.timestamp as Time, + position: annotation.label_type === 'break_up' ? ('belowBar' as const) : ('aboveBar' as const), + 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)); seriesRef.current.setMarkers(markers); - }, [annotations]); + }, [annotations, selectedLabelId]); // Handle chart clicks for annotation useEffect(() => { @@ -244,6 +252,23 @@ const CandleChart = forwardRef( } } } + + // 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); diff --git a/src/components/Toolbox.tsx b/src/components/Toolbox.tsx index 845584f..69febeb 100644 --- a/src/components/Toolbox.tsx +++ b/src/components/Toolbox.tsx @@ -1,20 +1,47 @@ 'use client'; 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 { Input } from '@/components/ui/input'; 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 { activeTool: Tool; onToolChange: (tool: Tool) => void; onExport: () => void; selectedColor: string; 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) => { // Toggle: if clicking the active tool, deactivate it 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 ( -
+

Annotation Tools

- {/* Color picker - shown when line tool is available */} + {/* Color picker */}
{[ { color: '#ef4444', name: 'Red' }, @@ -90,7 +151,7 @@ export default function Toolbox({ activeTool, onToolChange, onExport, selectedCo
+ {/* Labels Section */} +
+ + + {labelsExpanded && ( +
+ {/* Count display */} +
+ Break Up: {breakUpCount} | Break Down: {breakDownCount} +
+ + {/* Search input */} + setSearchText(e.target.value)} + className="h-8 text-sm" + /> + + {/* Filter dropdown */} + + + {/* Labels list */} +
+ {filteredAnnotations.length === 0 ? ( +
+ {labelAnnotations.length === 0 + ? 'No labels yet. Click Break Up or Break Down tools to add labels.' + : 'No matching labels found.'} +
+ ) : ( + 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 ( +
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' + }`} + > +
+
+
{formattedTime}
+
+ + {annotation.label_type === 'break_up' ? 'BREAK UP' : 'BREAK DOWN'} + +
+
+ +
+
+ ); + }) + )} +
+
+ )} +
+ + {/* Export button */}