diff --git a/src/App.tsx b/src/App.tsx index 94722b0..f3cb1cc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { Routes, Route, Navigate } from 'react-router-dom' import { AuthProvider, ProtectedRoute } from './auth' -import { Header, FlightProvider } from './components' +import { Header, FlightProvider, SavedAnnotationsProvider } from './components' import { LoginPage } from './features/login' import { FlightsPage } from './features/flights' import { AnnotationsPage } from './features/annotations' @@ -18,19 +18,21 @@ export default function App() { element={ -
-
-
- - } /> - } /> - } /> - } /> - } /> - } /> - + +
+
+
+ + } /> + } /> + } /> + } /> + } /> + } /> + +
-
+ } diff --git a/src/auth/AuthContext.tsx b/src/auth/AuthContext.tsx index 8382bd0..12d4e0b 100644 --- a/src/auth/AuthContext.tsx +++ b/src/auth/AuthContext.tsx @@ -28,7 +28,29 @@ export function __resetBootstrapInflightForTests(): void { bootstrapInflight = null } +// Dev-only escape hatch: `VITE_DEV_AUTH_BYPASS=true` skips the backend round +// trip and injects a fake admin user so the SPA renders authenticated. Lives +// in this file so the bypass is gated by the same effect that owns auth state; +// the import.meta.env check is also tree-shaken out of production builds when +// the flag is unset at build time. +const DEV_BYPASS_USER: AuthUser = { + id: 'dev-bypass', + email: 'dev@azaion.local', + name: 'Dev Bypass', + role: 'admin', + // Permission codes are short identifiers checked via hasPermission(code) — + // currently used by the Header to gate the nav tabs (FL, ANN, DATASET, ADM). + permissions: ['FL', 'ANN', 'DATASET', 'ADM'], +} + async function runBootstrap(): Promise { + // Gated on import.meta.env.DEV so a leaked VITE_DEV_AUTH_BYPASS=true in a + // production build cannot grant admin access. Vite tree-shakes the entire + // branch when DEV is false at build time. + if (import.meta.env.DEV && import.meta.env.VITE_DEV_AUTH_BYPASS === 'true') { + setToken('dev-bypass-token') + return DEV_BYPASS_USER + } // POST refresh with credentials — the whole point of the consolidation. Goes // through fetch() directly (not api.post) because api.post does not thread // credentials:'include'; widening api.post would change CORS posture for diff --git a/src/class-colors/classColors.ts b/src/class-colors/classColors.ts index aeb50b9..56f5d29 100644 --- a/src/class-colors/classColors.ts +++ b/src/class-colors/classColors.ts @@ -22,3 +22,11 @@ export function getClassNameFallback(classNum: number): string { const base = classNum % 20 return FALLBACK_CLASS_NAMES[base % FALLBACK_CLASS_NAMES.length] ?? `#${classNum}` } + +export function hexToRgba(hex: string, alpha: number): string { + const h = hex.replace('#', '') + const r = parseInt(h.slice(0, 2), 16) + const g = parseInt(h.slice(2, 4), 16) + const b = parseInt(h.slice(4, 6), 16) + return `rgba(${r},${g},${b},${alpha})` +} diff --git a/src/class-colors/index.ts b/src/class-colors/index.ts index 50cc25c..6f443b2 100644 --- a/src/class-colors/index.ts +++ b/src/class-colors/index.ts @@ -2,5 +2,6 @@ export { getClassColor, getPhotoModeSuffix, getClassNameFallback, + hexToRgba, FALLBACK_CLASS_NAMES, } from './classColors' diff --git a/src/components/DetectionClasses.tsx b/src/components/DetectionClasses.tsx index 5ce2106..078cb4a 100644 --- a/src/components/DetectionClasses.tsx +++ b/src/components/DetectionClasses.tsx @@ -1,7 +1,5 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { MdOutlineWbSunny, MdOutlineNightlightRound } from 'react-icons/md' -import { FaRegSnowflake } from 'react-icons/fa' import { api, endpoints } from '../api' // classColors lives under 06_annotations until F3 moves it to its own home. // Importing through the 06_annotations barrel would create a cycle @@ -60,43 +58,71 @@ export default function DetectionClasses({ selectedClassNum, onSelect, photoMode } }, [classes, photoMode, selectedClassNum, onSelect]) + const modeClasses = classes.filter(c => c.photoMode === photoMode) + const modes = [ - { value: 0, label: t('annotations.regular'), icon: , activeClass: 'bg-az-orange text-white', iconColor: 'text-az-orange' }, - { value: 20, label: t('annotations.winter'), icon: , activeClass: 'bg-az-blue text-white', iconColor: 'text-az-blue' }, - { value: 40, label: t('annotations.night'), icon: , activeClass: 'bg-purple-600 text-white', iconColor: 'text-purple-400' }, + { value: 0, label: t('annotations.regular') }, + { value: 20, label: t('annotations.winter') }, + { value: 40, label: t('annotations.night') }, ] return ( -
-
{t('annotations.classes')}
-
- {classes.filter(c => c.photoMode === photoMode).map((c, i) => ( - - ))} +
+ {/* Section header */} +
+
+ {t('annotations.classes')} + {modeClasses.length.toString().padStart(2, '0')} +
-
{t('annotations.photoMode')}
-
- {modes.map(m => ( - - ))} + + {/* Column headers */} +
+ {t('annotations.colNum')} + {t('annotations.colName')} + {t('annotations.colKey')} +
+ + {/* Class rows */} +
+ {modeClasses.map((c, i) => { + const isActive = selectedClassNum === c.id + return ( +
onSelect(c.id)} + onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') onSelect(c.id) }} + className={`class-row${isActive ? ' active' : ''}`} + > + + + {c.name} + + {i + 1} +
+ ) + })} +
+ + {/* PhotoMode segmented control */} +
+
+ {t('annotations.photoMode')} +
+
+ {modes.map(m => ( + + ))} +
) diff --git a/src/components/index.ts b/src/components/index.ts index 3e7f575..7383026 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -3,3 +3,4 @@ export { default as HelpModal } from './HelpModal' export { default as ConfirmDialog } from './ConfirmDialog' export { default as DetectionClasses } from './DetectionClasses' export { FlightProvider, useFlight } from './FlightContext' +export { SavedAnnotationsProvider, useSavedAnnotations } from './SavedAnnotationsContext' diff --git a/src/features/annotations/AnnotationsPage.tsx b/src/features/annotations/AnnotationsPage.tsx index 0c054ee..e50f800 100644 --- a/src/features/annotations/AnnotationsPage.tsx +++ b/src/features/annotations/AnnotationsPage.tsx @@ -1,38 +1,108 @@ -import { useState, useCallback, useEffect, useRef } from 'react' -import { useResizablePanel } from '../../hooks' +import { useState, useCallback, useEffect, useMemo, useRef } from 'react' +import { useTranslation } from 'react-i18next' import { api, endpoints } from '../../api' import MediaList from './MediaList' import VideoPlayer, { type VideoPlayerHandle } from './VideoPlayer' import CanvasEditor, { type CanvasEditorHandle } from './CanvasEditor' import AnnotationsSidebar from './AnnotationsSidebar' +import Scrubber, { type ScrubberMark } from './Scrubber' import { DetectionClasses, useFlight } from '../../components' import { useSavedAnnotations } from '../../components/SavedAnnotationsContext' import { AnnotationSource, AnnotationStatus, MediaType } from '../../types' import { getClassColor, getClassNameFallback, getPhotoModeSuffix } from '../../class-colors' import { captureThumbnails } from './thumbnail' +import { formatTime, formatTicks, parseAnnotationTime } from './time' import type { Media, AnnotationListItem, Detection } from '../../types' +const FRAME_STEPS = [1, 5, 10, 30, 60] + +const FAKE_LOG_LINES = [ + '[tile 04/16] 2 candidates', + '[tile 05/16] 1 candidate (conf 0.94)', + '[filter] min_conf=0.25…', +] + export default function AnnotationsPage() { + const { t } = useTranslation() const [selectedMedia, setSelectedMedia] = useState(null) const [currentTime, setCurrentTime] = useState(0) + const [duration, setDuration] = useState(0) const [annotations, setAnnotations] = useState([]) const [selectedAnnotation, setSelectedAnnotation] = useState(null) const [selectedClassNum, setSelectedClassNum] = useState(0) const [photoMode, setPhotoMode] = useState(0) const [detections, setDetections] = useState([]) - const leftPanel = useResizablePanel(250, 200, 400) - const rightPanel = useResizablePanel(200, 150, 350) + const [zoom, setZoom] = useState(1) + const [cursor, setCursor] = useState<{ x: number; y: number } | null>(null) + const [isPlaying, setIsPlaying] = useState(false) + const [volume, setVolume] = useState(0.62) + const [muted, setMuted] = useState(false) + const [aiDetecting, setAiDetecting] = useState(false) + const [aiLog, setAiLog] = useState([]) + const [aiProgress, setAiProgress] = useState(0) + const aiStartRef = useRef(0) + const aiCloseTimerRef = useRef(null) + const [aiElapsed, setAiElapsed] = useState(0) const videoPlayerRef = useRef(null) const canvasRef = useRef(null) const { addMany } = useSavedAnnotations() const { selectedFlight } = useFlight() + const isVideo = selectedMedia?.mediaType === MediaType.Video + useEffect(() => { setDetections([]) setSelectedAnnotation(null) setCurrentTime(0) + setDuration(0) + setIsPlaying(false) + setMuted(false) }, [selectedMedia]) + // Push the page's initial volume into the