mirror of
https://github.com/azaion/ui.git
synced 2026-06-23 07:51:11 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f754afff46 | |||
| cfffb4bdd7 |
+15
-13
@@ -1,6 +1,6 @@
|
|||||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { AuthProvider, ProtectedRoute } from './auth'
|
import { AuthProvider, ProtectedRoute } from './auth'
|
||||||
import { Header, FlightProvider } from './components'
|
import { Header, FlightProvider, SavedAnnotationsProvider } from './components'
|
||||||
import { LoginPage } from './features/login'
|
import { LoginPage } from './features/login'
|
||||||
import { FlightsPage } from './features/flights'
|
import { FlightsPage } from './features/flights'
|
||||||
import { AnnotationsPage } from './features/annotations'
|
import { AnnotationsPage } from './features/annotations'
|
||||||
@@ -18,19 +18,21 @@ export default function App() {
|
|||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<FlightProvider>
|
<FlightProvider>
|
||||||
<div className="flex flex-col h-screen">
|
<SavedAnnotationsProvider>
|
||||||
<Header />
|
<div className="flex flex-col h-screen">
|
||||||
<div className="flex-1 overflow-hidden">
|
<Header />
|
||||||
<Routes>
|
<div className="flex-1 overflow-hidden">
|
||||||
<Route path="/flights" element={<FlightsPage />} />
|
<Routes>
|
||||||
<Route path="/annotations" element={<AnnotationsPage />} />
|
<Route path="/flights" element={<FlightsPage />} />
|
||||||
<Route path="/dataset" element={<DatasetPage />} />
|
<Route path="/annotations" element={<AnnotationsPage />} />
|
||||||
<Route path="/admin" element={<AdminPage />} />
|
<Route path="/dataset" element={<DatasetPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/admin" element={<AdminPage />} />
|
||||||
<Route path="*" element={<Navigate to="/flights" replace />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
</Routes>
|
<Route path="*" element={<Navigate to="/flights" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SavedAnnotationsProvider>
|
||||||
</FlightProvider>
|
</FlightProvider>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,41 @@ export function useAuth() {
|
|||||||
return useContext(AuthContext)
|
return useContext(AuthContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// React 18+ StrictMode double-invokes effects in dev (mount → cleanup → mount),
|
||||||
|
// and the backend rotates the refresh cookie on every successful POST. Two
|
||||||
|
// concurrent bootstraps would race the rotation and leave the second one with
|
||||||
|
// a stale cookie. The module-scoped in-flight promise lets the second mount
|
||||||
|
// await the first's network round-trip instead of duplicating it. Risk 4 in
|
||||||
|
// AZ-510 spec.
|
||||||
|
let bootstrapInflight: Promise<AuthUser | null> | null = null
|
||||||
|
|
||||||
export function __resetBootstrapInflightForTests(): void {
|
export function __resetBootstrapInflightForTests(): void {
|
||||||
bootstrapInflight = null
|
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<AuthUser | null> {
|
async function runBootstrap(): Promise<AuthUser | null> {
|
||||||
|
// 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
|
// POST refresh with credentials — the whole point of the consolidation. Goes
|
||||||
// through fetch() directly (not api.post) because api.post does not thread
|
// through fetch() directly (not api.post) because api.post does not thread
|
||||||
// credentials:'include'; widening api.post would change CORS posture for
|
// credentials:'include'; widening api.post would change CORS posture for
|
||||||
|
|||||||
@@ -22,3 +22,11 @@ export function getClassNameFallback(classNum: number): string {
|
|||||||
const base = classNum % 20
|
const base = classNum % 20
|
||||||
return FALLBACK_CLASS_NAMES[base % FALLBACK_CLASS_NAMES.length] ?? `#${classNum}`
|
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})`
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ export {
|
|||||||
getClassColor,
|
getClassColor,
|
||||||
getPhotoModeSuffix,
|
getPhotoModeSuffix,
|
||||||
getClassNameFallback,
|
getClassNameFallback,
|
||||||
|
hexToRgba,
|
||||||
FALLBACK_CLASS_NAMES,
|
FALLBACK_CLASS_NAMES,
|
||||||
} from './classColors'
|
} from './classColors'
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { MdOutlineWbSunny, MdOutlineNightlightRound } from 'react-icons/md'
|
|
||||||
import { FaRegSnowflake } from 'react-icons/fa'
|
|
||||||
import { api, endpoints } from '../api'
|
import { api, endpoints } from '../api'
|
||||||
// classColors lives under 06_annotations until F3 moves it to its own home.
|
// classColors lives under 06_annotations until F3 moves it to its own home.
|
||||||
// Importing through the 06_annotations barrel would create a cycle
|
// 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])
|
}, [classes, photoMode, selectedClassNum, onSelect])
|
||||||
|
|
||||||
|
const modeClasses = classes.filter(c => c.photoMode === photoMode)
|
||||||
|
|
||||||
const modes = [
|
const modes = [
|
||||||
{ value: 0, label: t('annotations.regular'), icon: <MdOutlineWbSunny />, activeClass: 'bg-az-orange text-white', iconColor: 'text-az-orange' },
|
{ value: 0, label: t('annotations.regular') },
|
||||||
{ value: 20, label: t('annotations.winter'), icon: <FaRegSnowflake />, activeClass: 'bg-az-blue text-white', iconColor: 'text-az-blue' },
|
{ value: 20, label: t('annotations.winter') },
|
||||||
{ value: 40, label: t('annotations.night'), icon: <MdOutlineNightlightRound />, activeClass: 'bg-purple-600 text-white', iconColor: 'text-purple-400' },
|
{ value: 40, label: t('annotations.night') },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-az-border p-2">
|
<div className="border-t border-border-hair">
|
||||||
<div className="text-xs text-az-muted mb-1 font-semibold">{t('annotations.classes')}</div>
|
{/* Section header */}
|
||||||
<div className="space-y-0.5 max-h-48 overflow-y-auto mb-2">
|
<div className="flex items-center justify-between px-3 h-9 border-b border-border-hair">
|
||||||
{classes.filter(c => c.photoMode === photoMode).map((c, i) => (
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<span className="sect-head">{t('annotations.classes')}</span>
|
||||||
key={c.id}
|
<span className="mono text-[10px] text-text-muted">{modeClasses.length.toString().padStart(2, '0')}</span>
|
||||||
onClick={() => onSelect(c.id)}
|
</div>
|
||||||
className={`w-full flex items-center gap-1.5 px-1.5 py-0.5 rounded text-xs text-left ${
|
|
||||||
selectedClassNum === c.id ? 'bg-az-border text-white' : 'text-az-text hover:bg-az-bg'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: getClassColor(c.id) }} />
|
|
||||||
<span className="text-az-muted">{i + 1}.</span>
|
|
||||||
<span className="truncate">{c.name}</span>
|
|
||||||
<span className="text-az-muted ml-auto">{c.shortName}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-az-muted mb-1 font-semibold">{t('annotations.photoMode')}</div>
|
|
||||||
<div className="flex gap-1">
|
{/* Column headers */}
|
||||||
{modes.map(m => (
|
<div className="grid grid-cols-[28px_1fr_auto] px-3 h-6 items-center border-b border-border-hair gap-2">
|
||||||
<button
|
<span className="micro">{t('annotations.colNum')}</span>
|
||||||
key={m.value}
|
<span className="micro">{t('annotations.colName')}</span>
|
||||||
onClick={() => onPhotoModeChange(m.value)}
|
<span className="micro">{t('annotations.colKey')}</span>
|
||||||
title={m.label}
|
</div>
|
||||||
className={`flex-1 flex items-center justify-center px-2 py-1 rounded text-base ${photoMode === m.value ? m.activeClass : `bg-az-bg ${m.iconColor} hover:brightness-125`}`}
|
|
||||||
>
|
{/* Class rows */}
|
||||||
{m.icon}
|
<div>
|
||||||
</button>
|
{modeClasses.map((c, i) => {
|
||||||
))}
|
const isActive = selectedClassNum === c.id
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => onSelect(c.id)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') onSelect(c.id) }}
|
||||||
|
className={`class-row${isActive ? ' active' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="swatch" style={{ background: getClassColor(c.id) }} />
|
||||||
|
<span className={`truncate${isActive ? ' text-text-primary font-medium' : ' text-text-primary'}`}>
|
||||||
|
{c.name}
|
||||||
|
</span>
|
||||||
|
<span className="kbd">{i + 1}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PhotoMode segmented control */}
|
||||||
|
<div className="p-3 border-t border-border-hair">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="micro">{t('annotations.photoMode')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="seg" style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', width: '100%' }}>
|
||||||
|
{modes.map(m => (
|
||||||
|
<button
|
||||||
|
key={m.value}
|
||||||
|
type="button"
|
||||||
|
className={`seg-btn${photoMode === m.value ? ' active' : ''}`}
|
||||||
|
onClick={() => onPhotoModeChange(m.value)}
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ export { default as HelpModal } from './HelpModal'
|
|||||||
export { default as ConfirmDialog } from './ConfirmDialog'
|
export { default as ConfirmDialog } from './ConfirmDialog'
|
||||||
export { default as DetectionClasses } from './DetectionClasses'
|
export { default as DetectionClasses } from './DetectionClasses'
|
||||||
export { FlightProvider, useFlight } from './FlightContext'
|
export { FlightProvider, useFlight } from './FlightContext'
|
||||||
|
export { SavedAnnotationsProvider, useSavedAnnotations } from './SavedAnnotationsContext'
|
||||||
|
|||||||
@@ -1,38 +1,108 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
import { useResizablePanel } from '../../hooks'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { api, endpoints } from '../../api'
|
import { api, endpoints } from '../../api'
|
||||||
import MediaList from './MediaList'
|
import MediaList from './MediaList'
|
||||||
import VideoPlayer, { type VideoPlayerHandle } from './VideoPlayer'
|
import VideoPlayer, { type VideoPlayerHandle } from './VideoPlayer'
|
||||||
import CanvasEditor, { type CanvasEditorHandle } from './CanvasEditor'
|
import CanvasEditor, { type CanvasEditorHandle } from './CanvasEditor'
|
||||||
import AnnotationsSidebar from './AnnotationsSidebar'
|
import AnnotationsSidebar from './AnnotationsSidebar'
|
||||||
|
import Scrubber, { type ScrubberMark } from './Scrubber'
|
||||||
import { DetectionClasses, useFlight } from '../../components'
|
import { DetectionClasses, useFlight } from '../../components'
|
||||||
import { useSavedAnnotations } from '../../components/SavedAnnotationsContext'
|
import { useSavedAnnotations } from '../../components/SavedAnnotationsContext'
|
||||||
import { AnnotationSource, AnnotationStatus, MediaType } from '../../types'
|
import { AnnotationSource, AnnotationStatus, MediaType } from '../../types'
|
||||||
import { getClassColor, getClassNameFallback, getPhotoModeSuffix } from '../../class-colors'
|
import { getClassColor, getClassNameFallback, getPhotoModeSuffix } from '../../class-colors'
|
||||||
import { captureThumbnails } from './thumbnail'
|
import { captureThumbnails } from './thumbnail'
|
||||||
|
import { formatTime, formatTicks, parseAnnotationTime } from './time'
|
||||||
import type { Media, AnnotationListItem, Detection } from '../../types'
|
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() {
|
export default function AnnotationsPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null)
|
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null)
|
||||||
const [currentTime, setCurrentTime] = useState(0)
|
const [currentTime, setCurrentTime] = useState(0)
|
||||||
|
const [duration, setDuration] = useState(0)
|
||||||
const [annotations, setAnnotations] = useState<AnnotationListItem[]>([])
|
const [annotations, setAnnotations] = useState<AnnotationListItem[]>([])
|
||||||
const [selectedAnnotation, setSelectedAnnotation] = useState<AnnotationListItem | null>(null)
|
const [selectedAnnotation, setSelectedAnnotation] = useState<AnnotationListItem | null>(null)
|
||||||
const [selectedClassNum, setSelectedClassNum] = useState(0)
|
const [selectedClassNum, setSelectedClassNum] = useState(0)
|
||||||
const [photoMode, setPhotoMode] = useState(0)
|
const [photoMode, setPhotoMode] = useState(0)
|
||||||
const [detections, setDetections] = useState<Detection[]>([])
|
const [detections, setDetections] = useState<Detection[]>([])
|
||||||
const leftPanel = useResizablePanel(250, 200, 400)
|
const [zoom, setZoom] = useState(1)
|
||||||
const rightPanel = useResizablePanel(200, 150, 350)
|
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<string[]>([])
|
||||||
|
const [aiProgress, setAiProgress] = useState(0)
|
||||||
|
const aiStartRef = useRef<number>(0)
|
||||||
|
const aiCloseTimerRef = useRef<number | null>(null)
|
||||||
|
const [aiElapsed, setAiElapsed] = useState(0)
|
||||||
const videoPlayerRef = useRef<VideoPlayerHandle>(null)
|
const videoPlayerRef = useRef<VideoPlayerHandle>(null)
|
||||||
const canvasRef = useRef<CanvasEditorHandle>(null)
|
const canvasRef = useRef<CanvasEditorHandle>(null)
|
||||||
const { addMany } = useSavedAnnotations()
|
const { addMany } = useSavedAnnotations()
|
||||||
const { selectedFlight } = useFlight()
|
const { selectedFlight } = useFlight()
|
||||||
|
|
||||||
|
const isVideo = selectedMedia?.mediaType === MediaType.Video
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDetections([])
|
setDetections([])
|
||||||
setSelectedAnnotation(null)
|
setSelectedAnnotation(null)
|
||||||
setCurrentTime(0)
|
setCurrentTime(0)
|
||||||
|
setDuration(0)
|
||||||
|
setIsPlaying(false)
|
||||||
|
setMuted(false)
|
||||||
}, [selectedMedia])
|
}, [selectedMedia])
|
||||||
|
|
||||||
|
// Push the page's initial volume into the <video> element once the player
|
||||||
|
// is mounted — otherwise the slider shows 62% while audio plays at 100%.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedMedia || !isVideo) return
|
||||||
|
videoPlayerRef.current?.setVolume(volume)
|
||||||
|
// Only on media change — subsequent slider drags push via onVolumeChange.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selectedMedia, isVideo])
|
||||||
|
|
||||||
|
// AI detection fake-log progress
|
||||||
|
useEffect(() => {
|
||||||
|
if (!aiDetecting) return
|
||||||
|
aiStartRef.current = performance.now()
|
||||||
|
setAiElapsed(0)
|
||||||
|
setAiLog([])
|
||||||
|
setAiProgress(0)
|
||||||
|
let i = 0
|
||||||
|
const logTimer = window.setInterval(() => {
|
||||||
|
if (i < FAKE_LOG_LINES.length) {
|
||||||
|
setAiLog(prev => [...prev, FAKE_LOG_LINES[i]])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}, 700)
|
||||||
|
const tickTimer = window.setInterval(() => {
|
||||||
|
setAiElapsed((performance.now() - aiStartRef.current) / 1000)
|
||||||
|
setAiProgress(p => Math.min(0.95, p + 0.04))
|
||||||
|
}, 100)
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(logTimer)
|
||||||
|
window.clearInterval(tickTimer)
|
||||||
|
}
|
||||||
|
}, [aiDetecting])
|
||||||
|
|
||||||
|
const scrubberMarks = useMemo<ScrubberMark[]>(() => {
|
||||||
|
return annotations
|
||||||
|
.map(a => {
|
||||||
|
const sec = parseAnnotationTime(a.time)
|
||||||
|
if (sec == null) return null
|
||||||
|
const first = a.detections[0]
|
||||||
|
return { time: sec, color: first ? getClassColor(first.classNum) : '#9AA4B2' }
|
||||||
|
})
|
||||||
|
.filter((m): m is ScrubberMark => m !== null)
|
||||||
|
}, [annotations])
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
if (!selectedMedia || !detections.length) return
|
if (!selectedMedia || !detections.length) return
|
||||||
const time = selectedMedia.mediaType === MediaType.Video ? formatTicks(currentTime) : null
|
const time = selectedMedia.mediaType === MediaType.Video ? formatTicks(currentTime) : null
|
||||||
@@ -108,7 +178,6 @@ export default function AnnotationsPage() {
|
|||||||
txtA.click()
|
txtA.click()
|
||||||
URL.revokeObjectURL(txtUrl)
|
URL.revokeObjectURL(txtUrl)
|
||||||
|
|
||||||
// Build the image: video frame or image with rectangles drawn
|
|
||||||
const videoEl = videoPlayerRef.current?.getVideoElement() ?? null
|
const videoEl = videoPlayerRef.current?.getVideoElement() ?? null
|
||||||
let w = 0, h = 0
|
let w = 0, h = 0
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
@@ -181,11 +250,10 @@ export default function AnnotationsPage() {
|
|||||||
const handleAnnotationSelect = useCallback((ann: AnnotationListItem) => {
|
const handleAnnotationSelect = useCallback((ann: AnnotationListItem) => {
|
||||||
setSelectedAnnotation(ann)
|
setSelectedAnnotation(ann)
|
||||||
setDetections(ann.detections)
|
setDetections(ann.detections)
|
||||||
if (ann.time) {
|
const sec = parseAnnotationTime(ann.time)
|
||||||
const parts = ann.time.split(':').map(Number)
|
if (sec != null) {
|
||||||
const seconds = (parts[0] || 0) * 3600 + (parts[1] || 0) * 60 + (parts[2] || 0)
|
videoPlayerRef.current?.seek(sec)
|
||||||
videoPlayerRef.current?.seek(seconds)
|
setCurrentTime(sec)
|
||||||
setCurrentTime(seconds)
|
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -193,20 +261,68 @@ export default function AnnotationsPage() {
|
|||||||
setDetections(dets)
|
setDetections(dets)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const isVideo = selectedMedia?.mediaType === MediaType.Video
|
const handleAiDetect = useCallback(async () => {
|
||||||
|
if (!selectedMedia || aiDetecting) return
|
||||||
|
if (aiCloseTimerRef.current != null) {
|
||||||
|
window.clearTimeout(aiCloseTimerRef.current)
|
||||||
|
aiCloseTimerRef.current = null
|
||||||
|
}
|
||||||
|
setAiDetecting(true)
|
||||||
|
try {
|
||||||
|
await api.post(endpoints.detect.media(selectedMedia.id))
|
||||||
|
} catch {
|
||||||
|
// banner stays visible briefly; sidebar SSE refresh will pick up results
|
||||||
|
} finally {
|
||||||
|
setAiProgress(1)
|
||||||
|
aiCloseTimerRef.current = window.setTimeout(() => {
|
||||||
|
aiCloseTimerRef.current = null
|
||||||
|
setAiDetecting(false)
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
}, [selectedMedia, aiDetecting])
|
||||||
|
|
||||||
function formatTicks(seconds: number): string {
|
// Clear any pending AI-banner close timer on unmount.
|
||||||
const h = Math.floor(seconds / 3600)
|
useEffect(() => () => {
|
||||||
const m = Math.floor((seconds % 3600) / 60)
|
if (aiCloseTimerRef.current != null) {
|
||||||
const s = Math.floor(seconds % 60)
|
window.clearTimeout(aiCloseTimerRef.current)
|
||||||
const ms = Math.floor((seconds - Math.floor(seconds)) * 1000)
|
aiCloseTimerRef.current = null
|
||||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(ms).padStart(3, '0')}`
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const togglePlay = () => { videoPlayerRef.current?.toggle() }
|
||||||
|
const stepFrames = (n: number) => { videoPlayerRef.current?.frameStep(n) }
|
||||||
|
const seekRel = (sec: number) => {
|
||||||
|
const p = videoPlayerRef.current
|
||||||
|
if (!p) return
|
||||||
|
p.seek(Math.max(0, Math.min(p.getDuration(), p.getCurrentTime() + sec)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onVolumeChange = (v: number) => {
|
||||||
|
setVolume(v)
|
||||||
|
videoPlayerRef.current?.setVolume(v)
|
||||||
|
}
|
||||||
|
const toggleMute = () => {
|
||||||
|
// VideoPlayer.toggleMute() fires onMutedChange, which updates `muted` —
|
||||||
|
// don't flip parent state independently or the two desync (e.g. M-key
|
||||||
|
// shortcut already routed via onMutedChange).
|
||||||
|
videoPlayerRef.current?.toggleMute()
|
||||||
|
}
|
||||||
|
|
||||||
|
const dims = (() => {
|
||||||
|
const v = videoPlayerRef.current?.getVideoElement()
|
||||||
|
if (!v || !v.videoWidth) return null
|
||||||
|
return { w: v.videoWidth, h: v.videoHeight }
|
||||||
|
})()
|
||||||
|
const fps = videoPlayerRef.current?.getFrameRate() ?? 30
|
||||||
|
const currentFrame = isVideo ? Math.floor(currentTime * fps) : 0
|
||||||
|
const totalFrames = isVideo ? Math.floor(duration * fps) : 0
|
||||||
|
|
||||||
|
const detectionsLabel = `${detections.length} det${detections.length !== 1 ? 's' : ''}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
{/* Left panel */}
|
{/* LEFT SIDEBAR */}
|
||||||
<div style={{ width: leftPanel.width }} className="bg-az-panel border-r border-az-border flex flex-col shrink-0">
|
<div style={{ width: 232 }} className="bg-surface-1 flex flex-col shrink-0 border-r border-border-hair">
|
||||||
<MediaList
|
<MediaList
|
||||||
selectedMedia={selectedMedia}
|
selectedMedia={selectedMedia}
|
||||||
onSelect={setSelectedMedia}
|
onSelect={setSelectedMedia}
|
||||||
@@ -219,42 +335,62 @@ export default function AnnotationsPage() {
|
|||||||
onPhotoModeChange={setPhotoMode}
|
onPhotoModeChange={setPhotoMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div onMouseDown={leftPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" />
|
{/* CENTER */}
|
||||||
|
<div className="flex-1 flex flex-col min-w-0 bg-surface-0">
|
||||||
{/* Center - video/canvas */}
|
{/* Canvas top bar */}
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<div className="h-9 flex items-center gap-3 px-4 border-b border-border-hair bg-surface-1 shrink-0">
|
||||||
{selectedMedia && (
|
<div className="flex items-center gap-2">
|
||||||
<div className="bg-az-panel border-b border-az-border px-2 py-1 flex gap-2 items-center shrink-0">
|
<span className="sect-head">{t('annotations.canvas')}</span>
|
||||||
<button
|
{selectedMedia && (
|
||||||
onClick={handleSave}
|
<>
|
||||||
disabled={!detections.length}
|
<span className="mono text-[11px] text-text-muted">{selectedMedia.name}</span>
|
||||||
className="px-2.5 py-1 rounded border border-az-green text-az-green text-[11px] hover:bg-az-green/10 disabled:opacity-40 disabled:cursor-not-allowed"
|
{dims && (
|
||||||
>
|
<span className="mono text-[10px] px-1.5 py-0.5 border border-border-hair text-text-secondary">
|
||||||
Save
|
{dims.w}×{dims.h} · {fps} FPS
|
||||||
</button>
|
</span>
|
||||||
<button
|
)}
|
||||||
onClick={() => canvasRef.current?.deleteSelected()}
|
</>
|
||||||
disabled={!detections.length}
|
)}
|
||||||
className="px-2.5 py-1 rounded border border-az-red text-az-red text-[11px] hover:bg-az-red/10 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => canvasRef.current?.deleteAll()}
|
|
||||||
disabled={!detections.length}
|
|
||||||
className="px-2.5 py-1 rounded border border-az-red text-az-red text-[11px] hover:bg-az-red/10 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Remove All
|
|
||||||
</button>
|
|
||||||
<span className="text-az-muted text-[10px]">{detections.length} detection{detections.length !== 1 ? 's' : ''}</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="ml-auto flex items-center gap-2">
|
||||||
{selectedMedia && isVideo && (
|
<span className="micro">{t('annotations.zoom')}</span>
|
||||||
<VideoPlayer
|
<span className="mono text-[11px] text-text-primary">{Math.round(zoom * 100)}%</span>
|
||||||
ref={videoPlayerRef}
|
<span className="mx-2 h-4 w-px bg-border-hair" />
|
||||||
media={selectedMedia}
|
<span className="micro">{t('annotations.cursor')}</span>
|
||||||
onTimeUpdate={setCurrentTime}
|
<span className="mono text-[11px] text-text-primary">
|
||||||
>
|
{cursor ? `${cursor.x.toFixed(3)}, ${cursor.y.toFixed(3)}` : '—'}
|
||||||
|
</span>
|
||||||
|
<span className="mx-2 h-4 w-px bg-border-hair" />
|
||||||
|
<span className="mono text-[11px] text-text-secondary">{detectionsLabel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Canvas area */}
|
||||||
|
<div className="flex-1 relative overflow-hidden">
|
||||||
|
{selectedMedia && isVideo && (
|
||||||
|
<VideoPlayer
|
||||||
|
ref={videoPlayerRef}
|
||||||
|
media={selectedMedia}
|
||||||
|
onTimeUpdate={setCurrentTime}
|
||||||
|
onPlayingChange={setIsPlaying}
|
||||||
|
onDurationChange={setDuration}
|
||||||
|
onMutedChange={setMuted}
|
||||||
|
>
|
||||||
|
<CanvasEditor
|
||||||
|
ref={canvasRef}
|
||||||
|
media={selectedMedia}
|
||||||
|
annotation={selectedAnnotation}
|
||||||
|
detections={detections}
|
||||||
|
onDetectionsChange={handleDetectionsChange}
|
||||||
|
selectedClassNum={selectedClassNum}
|
||||||
|
currentTime={currentTime}
|
||||||
|
annotations={annotations}
|
||||||
|
onZoomChange={setZoom}
|
||||||
|
onCursorChange={(x, y) => setCursor({ x, y })}
|
||||||
|
/>
|
||||||
|
</VideoPlayer>
|
||||||
|
)}
|
||||||
|
{selectedMedia && !isVideo && (
|
||||||
<CanvasEditor
|
<CanvasEditor
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
media={selectedMedia}
|
media={selectedMedia}
|
||||||
@@ -264,31 +400,178 @@ export default function AnnotationsPage() {
|
|||||||
selectedClassNum={selectedClassNum}
|
selectedClassNum={selectedClassNum}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
annotations={annotations}
|
annotations={annotations}
|
||||||
|
onZoomChange={setZoom}
|
||||||
|
onCursorChange={(x, y) => setCursor({ x, y })}
|
||||||
/>
|
/>
|
||||||
</VideoPlayer>
|
)}
|
||||||
|
{!selectedMedia && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-text-muted text-sm">
|
||||||
|
{t('annotations.selectMedia')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI Detection floating banner */}
|
||||||
|
{aiDetecting && (
|
||||||
|
<div className="absolute top-6 right-6 ai-banner px-3 py-2 w-72">
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<span className="live-dot" />
|
||||||
|
<span className="micro text-accent-cyan">{t('annotations.detectInProgress')}</span>
|
||||||
|
<span className="ml-auto mono text-[10px] text-text-muted">{aiElapsed.toFixed(1)}s</span>
|
||||||
|
</div>
|
||||||
|
<div className="mono text-[10px] space-y-0.5 text-text-secondary">
|
||||||
|
{aiLog.map((line, i) => <div key={i}>{line}</div>)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-[2px] bg-black/40 overflow-hidden">
|
||||||
|
<div style={{ height: '100%', width: `${aiProgress * 100}%`, background: 'var(--accent-cyan)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrubber + Controls */}
|
||||||
|
{selectedMedia && isVideo && (
|
||||||
|
<div className="border-t border-border-hair bg-surface-1 shrink-0">
|
||||||
|
<div className="px-4 pt-3 pb-2">
|
||||||
|
<Scrubber
|
||||||
|
current={currentTime}
|
||||||
|
duration={duration}
|
||||||
|
marks={scrubberMarks}
|
||||||
|
onSeek={t => { videoPlayerRef.current?.seek(t); setCurrentTime(t) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 pb-3 flex items-center gap-1.5 min-w-0 whitespace-nowrap overflow-hidden">
|
||||||
|
<div className="flex items-center gap-1 p-1 border border-border-hair rounded-[2px]">
|
||||||
|
<button className="ibtn" style={{ width: 28, height: 28, border: 0, background: 'transparent' }} title={t('annotations.previousMedia')}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button className="ibtn" style={{ width: 28, height: 28, border: 0, background: 'transparent' }} title={t('annotations.back5s')} onClick={() => seekRel(-5)}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M11 18V6l-8.5 6zM22 18V6l-8.5 6z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ibtn"
|
||||||
|
title={isPlaying ? t('annotations.pause') : t('annotations.play')}
|
||||||
|
onClick={togglePlay}
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
background: isPlaying ? 'rgba(255,157,61,0.12)' : 'transparent',
|
||||||
|
color: isPlaying ? 'var(--accent-amber)' : undefined,
|
||||||
|
borderColor: isPlaying ? 'var(--accent-amber)' : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPlaying
|
||||||
|
? <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M6 5h4v14H6zM14 5h4v14h-4z"/></svg>
|
||||||
|
: <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>}
|
||||||
|
</button>
|
||||||
|
<button className="ibtn" style={{ width: 28, height: 28, border: 0, background: 'transparent' }} title={t('annotations.forward5s')} onClick={() => seekRel(5)}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M13 6v12l8.5-6zM2 6v12l8.5-6z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button className="ibtn" style={{ width: 28, height: 28, border: 0, background: 'transparent' }} title={t('annotations.nextMedia')}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M16 6h2v12h-2zM6 18l8.5-6L6 6z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="micro">{t('annotations.frameStep')}</span>
|
||||||
|
<div className="flex items-center gap-1 p-1 border border-border-hair rounded-[2px]">
|
||||||
|
{FRAME_STEPS.map(n => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
onClick={() => stepFrames(n)}
|
||||||
|
className="ibtn mono"
|
||||||
|
style={{ width: 30, height: 28, fontSize: 10, border: 0, background: 'transparent', letterSpacing: 0 }}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="mx-1 h-5 w-px bg-border-hair" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!detections.length}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><path d="M17 21v-8H7v8M7 3v5h8"/></svg>
|
||||||
|
{t('annotations.save')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => canvasRef.current?.deleteSelected()}
|
||||||
|
disabled={!detections.length}
|
||||||
|
className="btn btn-danger-ghost"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
|
||||||
|
{t('annotations.delete')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => canvasRef.current?.deleteAll()}
|
||||||
|
disabled={!detections.length}
|
||||||
|
className="btn btn-danger-ghost"
|
||||||
|
title={t('annotations.deleteAllTitle')}
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11l4 6M14 11l-4 6"/></svg>
|
||||||
|
{t('annotations.deleteAll')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="mx-1 h-5 w-px bg-border-hair" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleAiDetect}
|
||||||
|
disabled={!selectedMedia || aiDetecting}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7V3h4"/><path d="M17 3h4v4"/><path d="M21 17v4h-4"/><path d="M7 21H3v-4"/><circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none"/></svg>
|
||||||
|
{t('annotations.detect')}
|
||||||
|
<span className="ml-1 mono opacity-70" style={{ fontSize: 9 }}>[R]</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="mx-1 h-5 w-px bg-border-hair" />
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<button className="ibtn" style={{ width: 28, height: 28 }} title={t('annotations.mute')} onClick={toggleMute}>
|
||||||
|
{muted
|
||||||
|
? <svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.21.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51A8.95 8.95 0 0 0 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.17v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>
|
||||||
|
: <svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3a4.5 4.5 0 0 0-2.5-4v8a4.5 4.5 0 0 0 2.5-4z"/></svg>}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="vol"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={Math.round(volume * 100)}
|
||||||
|
onChange={e => onVolumeChange(Number(e.target.value) / 100)}
|
||||||
|
/>
|
||||||
|
<span className="mono text-[10px] text-text-muted" style={{ width: 24 }}>{Math.round(volume * 100)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status bar */}
|
||||||
|
<div className="px-4 h-7 flex items-center border-t border-border-hair bg-surface-0">
|
||||||
|
<span className="mono text-[11px] text-text-primary">{formatTime(currentTime, true)}</span>
|
||||||
|
<span className="mono text-[11px] mx-1.5 text-text-muted">/</span>
|
||||||
|
<span className="mono text-[11px] text-text-secondary">{formatTime(duration, true)}</span>
|
||||||
|
<span className="mx-3 h-4 w-px bg-border-hair" />
|
||||||
|
<span className="micro">{t('annotations.frame')}</span>
|
||||||
|
<span className="mono text-[11px] ml-1.5 text-text-primary">{currentFrame} / {totalFrames}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Photo-only controls row (save/delete/AI detect) */}
|
||||||
{selectedMedia && !isVideo && (
|
{selectedMedia && !isVideo && (
|
||||||
<CanvasEditor
|
<div className="border-t border-border-hair bg-surface-1 shrink-0 px-4 py-2 flex items-center gap-3">
|
||||||
ref={canvasRef}
|
<button onClick={handleSave} disabled={!detections.length} className="btn btn-secondary">{t('annotations.save')}</button>
|
||||||
media={selectedMedia}
|
<button onClick={() => canvasRef.current?.deleteSelected()} disabled={!detections.length} className="btn btn-danger-ghost">{t('annotations.delete')}</button>
|
||||||
annotation={selectedAnnotation}
|
<button onClick={() => canvasRef.current?.deleteAll()} disabled={!detections.length} className="btn btn-danger-ghost">{t('annotations.deleteAll')}</button>
|
||||||
detections={detections}
|
<span className="mx-1 h-5 w-px bg-border-hair" />
|
||||||
onDetectionsChange={handleDetectionsChange}
|
<button onClick={handleAiDetect} disabled={!selectedMedia || aiDetecting} className="btn btn-primary">{t('annotations.detect')}</button>
|
||||||
selectedClassNum={selectedClassNum}
|
<span className="ml-auto mono text-[11px] text-text-muted">{detectionsLabel}</span>
|
||||||
currentTime={currentTime}
|
|
||||||
annotations={annotations}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!selectedMedia && (
|
|
||||||
<div className="flex-1 flex items-center justify-center text-az-muted text-sm">
|
|
||||||
Select a media file to start
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right panel */}
|
{/* RIGHT SIDEBAR */}
|
||||||
<div onMouseDown={rightPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" />
|
<div style={{ width: 208 }} className="bg-surface-1 flex flex-col shrink-0 border-l border-border-hair">
|
||||||
<div style={{ width: rightPanel.width }} className="bg-az-panel border-l border-az-border flex flex-col shrink-0">
|
|
||||||
<AnnotationsSidebar
|
<AnnotationsSidebar
|
||||||
media={selectedMedia}
|
media={selectedMedia}
|
||||||
annotations={annotations}
|
annotations={annotations}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { FaDownload } from 'react-icons/fa'
|
import { FaDownload } from 'react-icons/fa'
|
||||||
import { api, createSSE, endpoints } from '../../api'
|
import { api, createSSE, endpoints } from '../../api'
|
||||||
import { getClassColor } from '../../class-colors'
|
import { getClassColor, getClassNameFallback, hexToRgba } from '../../class-colors'
|
||||||
import type { Media, AnnotationListItem, PaginatedResponse } from '../../types'
|
import type { Media, AnnotationListItem, PaginatedResponse } from '../../types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -14,10 +14,46 @@ interface Props {
|
|||||||
onDownload?: (ann: AnnotationListItem) => void
|
onDownload?: (ann: AnnotationListItem) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRowGradient(ann: AnnotationListItem): string {
|
||||||
|
if (ann.detections.length === 0) {
|
||||||
|
return 'linear-gradient(90deg, rgba(221,221,221,0.10), rgba(221,221,221,0.04))'
|
||||||
|
}
|
||||||
|
if (ann.detections.length === 1) {
|
||||||
|
const c = getClassColor(ann.detections[0].classNum)
|
||||||
|
return `linear-gradient(90deg, ${hexToRgba(c, 0.55)} 0%, ${hexToRgba(c, 0.10)} 60%, transparent 100%)`
|
||||||
|
}
|
||||||
|
const n = ann.detections.length
|
||||||
|
const bandWidth = 100 / n
|
||||||
|
const stops: string[] = []
|
||||||
|
ann.detections.forEach((d, i) => {
|
||||||
|
const c = getClassColor(d.classNum)
|
||||||
|
const start = i * bandWidth
|
||||||
|
const mid = start + bandWidth * 0.6
|
||||||
|
const end = (i + 1) * bandWidth
|
||||||
|
stops.push(`${hexToRgba(c, 0.50)} ${start}%`)
|
||||||
|
stops.push(`${hexToRgba(c, 0.10)} ${mid}%`)
|
||||||
|
if (i < n - 1) stops.push(`${hexToRgba(c, 0.10)} ${end - 0.01}%`)
|
||||||
|
})
|
||||||
|
return `linear-gradient(90deg, ${stops.join(', ')})`
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClassAgg { classNum: number; color: string; count: number }
|
||||||
|
|
||||||
|
function aggregateClasses(annotations: AnnotationListItem[]): ClassAgg[] {
|
||||||
|
const counts = new Map<number, number>()
|
||||||
|
for (const ann of annotations) {
|
||||||
|
for (const d of ann.detections) {
|
||||||
|
counts.set(d.classNum, (counts.get(d.classNum) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...counts.entries()]
|
||||||
|
.map(([classNum, count]) => ({ classNum, color: getClassColor(classNum), count }))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 6)
|
||||||
|
}
|
||||||
|
|
||||||
export default function AnnotationsSidebar({ media, annotations, selectedAnnotation, onSelect, onAnnotationsUpdate, onDownload }: Props) {
|
export default function AnnotationsSidebar({ media, annotations, selectedAnnotation, onSelect, onAnnotationsUpdate, onDownload }: Props) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [detecting, setDetecting] = useState(false)
|
|
||||||
const [detectLog, setDetectLog] = useState<string[]>([])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!media) return
|
if (!media) return
|
||||||
@@ -30,85 +66,105 @@ export default function AnnotationsSidebar({ media, annotations, selectedAnnotat
|
|||||||
})
|
})
|
||||||
}, [media, onAnnotationsUpdate])
|
}, [media, onAnnotationsUpdate])
|
||||||
|
|
||||||
const handleDetect = async () => {
|
const totals = useMemo(() => ({
|
||||||
if (!media) return
|
total: annotations.length,
|
||||||
setDetecting(true)
|
empty: annotations.filter(a => a.detections.length === 0).length,
|
||||||
setDetectLog(['Starting AI detection...'])
|
}), [annotations])
|
||||||
try {
|
|
||||||
await api.post(endpoints.detect.media(media.id))
|
|
||||||
setDetectLog(prev => [...prev, 'Detection complete.'])
|
|
||||||
} catch (e: any) {
|
|
||||||
setDetectLog(prev => [...prev, `Error: ${e.message}`])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getRowGradient = (ann: AnnotationListItem) => {
|
const classDist = useMemo(() => aggregateClasses(annotations), [annotations])
|
||||||
if (ann.detections.length === 0) return 'rgba(221,221,221,0.25)'
|
|
||||||
const stops = ann.detections.map((d, i) => {
|
|
||||||
const pct = (i / Math.max(ann.detections.length - 1, 1)) * 100
|
|
||||||
const alpha = Math.min(1, d.confidence)
|
|
||||||
return `${getClassColor(d.classNum)}${Math.round(alpha * 40).toString(16).padStart(2, '0')} ${pct}%`
|
|
||||||
})
|
|
||||||
return `linear-gradient(to right, ${stops.join(', ')})`
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full bg-surface-1">
|
||||||
<div className="p-2 border-b border-az-border flex items-center justify-between gap-1">
|
<div className="flex items-center justify-between px-3 h-9 border-b border-border-hair">
|
||||||
<span className="text-xs font-semibold text-az-muted">{t('annotations.title')}</span>
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="sect-head">{t('annotations.title')}</span>
|
||||||
|
<span className="mono text-[10px] text-text-muted">{String(annotations.length).padStart(2, '0')}</span>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button className="ibtn" style={{ width: 22, height: 22 }} title={t('annotations.filter')}>
|
||||||
onClick={handleDetect}
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polygon points="22 3 2 3 10 12.5 10 19 14 21 14 12.5"/></svg>
|
||||||
disabled={!media}
|
|
||||||
className="text-xs bg-az-blue text-white px-2 py-0.5 rounded disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{t('annotations.detect')}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button className="ibtn" style={{ width: 22, height: 22 }} title={t('annotations.sort')}>
|
||||||
onClick={() => selectedAnnotation && onDownload?.(selectedAnnotation)}
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 6h13M3 12h9M3 18h5M17 8l4-4 4 4M21 4v16"/></svg>
|
||||||
disabled={!selectedAnnotation}
|
|
||||||
title="Download annotation"
|
|
||||||
className="text-xs bg-az-orange text-white p-1 rounded disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<FaDownload size={12} />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="grid grid-cols-[44px_1fr_auto] gap-2 px-3 h-6 items-center border-b border-border-hair">
|
||||||
{annotations.map(ann => (
|
<span className="micro">{t('annotations.colTime')}</span>
|
||||||
<div
|
<span className="micro">{t('annotations.colClass')}</span>
|
||||||
key={ann.id}
|
<span className="micro">{t('annotations.colConf')}</span>
|
||||||
onClick={() => onSelect(ann)}
|
</div>
|
||||||
className={`px-2 py-1 cursor-pointer border-b border-az-border text-xs ${
|
|
||||||
selectedAnnotation?.id === ann.id ? 'ring-1 ring-az-orange ring-inset' : ''
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
}`}
|
{annotations.map(ann => {
|
||||||
style={{ background: getRowGradient(ann) }}
|
const isSelected = selectedAnnotation?.id === ann.id
|
||||||
>
|
const isEmpty = ann.detections.length === 0
|
||||||
<div className="flex items-center justify-between">
|
const first = ann.detections[0]
|
||||||
<span className="text-az-text font-mono">{ann.time || '—'}</span>
|
const extra = ann.detections.length > 1 ? ` +${ann.detections.length - 1}` : ''
|
||||||
<span className="text-az-muted">{ann.detections.length > 0 ? ann.detections[0].label : '—'}</span>
|
const maxConf = ann.detections.reduce((m, d) => Math.max(m, d.confidence ?? 0), 0)
|
||||||
|
const className = first ? (first.label || getClassNameFallback(first.classNum)) : ''
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ann.id}
|
||||||
|
onClick={() => onSelect(ann)}
|
||||||
|
className={`ann-row${isSelected ? ' active' : ''}`}
|
||||||
|
style={{ ['--row-grad' as string]: getRowGradient(ann) }}
|
||||||
|
>
|
||||||
|
<span className={`mono text-[11px] ${isSelected ? 'text-accent-amber font-semibold' : isEmpty ? 'text-text-muted' : 'text-text-secondary'}`}>
|
||||||
|
{ann.time || '—'}
|
||||||
|
</span>
|
||||||
|
{isEmpty
|
||||||
|
? <span className="text-text-muted italic">{t('annotations.emptyFrame')}</span>
|
||||||
|
: <span className={`truncate ${isSelected ? 'text-text-primary font-semibold' : 'text-text-primary'}`}>{className}{extra}</span>
|
||||||
|
}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{isSelected && !isEmpty && onDownload && (
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); onDownload(ann) }}
|
||||||
|
className="ibtn"
|
||||||
|
style={{ width: 18, height: 18 }}
|
||||||
|
title="Download annotation"
|
||||||
|
>
|
||||||
|
<FaDownload size={9} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className={`mono text-[10px] ${isEmpty ? 'text-text-muted' : isSelected ? 'text-accent-amber' : 'text-text-secondary'}`}>
|
||||||
|
{isEmpty ? '—' : `${Math.round(maxConf * 100)}%`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
))}
|
})}
|
||||||
{annotations.length === 0 && (
|
{annotations.length === 0 && (
|
||||||
<div className="p-2 text-az-muted text-xs text-center">{t('common.noData')}</div>
|
<div className="p-3 text-text-muted text-xs text-center">{t('common.noData')}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{detecting && (
|
<div className="border-t border-border-hair px-3 py-2.5 bg-surface-0">
|
||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[100]">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="bg-az-panel border border-az-border rounded-lg p-4 w-96 max-h-80 flex flex-col">
|
<span className="micro">{t('annotations.summary')}</span>
|
||||||
<h3 className="text-white font-semibold mb-2">{t('annotations.detect')}</h3>
|
<span className="mono text-[10px] text-text-muted">
|
||||||
<div className="flex-1 overflow-y-auto bg-az-bg rounded p-2 text-xs text-az-text font-mono space-y-0.5 mb-2">
|
{t('annotations.annCount', { count: totals.total })} · {t('annotations.emptyCount', { count: totals.empty })}
|
||||||
{detectLog.map((line, i) => <div key={i}>{line}</div>)}
|
</span>
|
||||||
</div>
|
|
||||||
<button onClick={() => setDetecting(false)} className="self-end text-xs bg-az-border text-az-text px-3 py-1 rounded">
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{classDist.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-1 h-2">
|
||||||
|
{classDist.map(c => (
|
||||||
|
<span key={c.classNum} style={{ flex: c.count, background: c.color, height: '100%' }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between mt-2 mono text-[10px] text-text-muted">
|
||||||
|
{classDist.map(c => (
|
||||||
|
<span key={c.classNum} className="flex items-center gap-1">
|
||||||
|
<span style={{ color: c.color }}>■</span> {c.count}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useRef, useEffect, useState, useCallback, forwardRef, useImperativeHand
|
|||||||
import { endpoints } from '../../api'
|
import { endpoints } from '../../api'
|
||||||
import { MediaType } from '../../types'
|
import { MediaType } from '../../types'
|
||||||
import type { Media, AnnotationListItem, Detection, Affiliation, CombatReadiness } from '../../types'
|
import type { Media, AnnotationListItem, Detection, Affiliation, CombatReadiness } from '../../types'
|
||||||
import { getClassColor, getPhotoModeSuffix, getClassNameFallback } from '../../class-colors'
|
import { getClassColor, getClassNameFallback, hexToRgba } from '../../class-colors'
|
||||||
|
import { parseAnnotationTime } from './time'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
media: Media
|
media: Media
|
||||||
@@ -12,6 +13,8 @@ interface Props {
|
|||||||
selectedClassNum: number
|
selectedClassNum: number
|
||||||
currentTime: number
|
currentTime: number
|
||||||
annotations: AnnotationListItem[]
|
annotations: AnnotationListItem[]
|
||||||
|
onZoomChange?: (zoom: number) => void
|
||||||
|
onCursorChange?: (nx: number, ny: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CanvasEditorHandle {
|
export interface CanvasEditorHandle {
|
||||||
@@ -28,28 +31,60 @@ interface DragState {
|
|||||||
handle?: string
|
handle?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LabelChip {
|
||||||
|
leftPct: number
|
||||||
|
topPct: number
|
||||||
|
color: string
|
||||||
|
name: string
|
||||||
|
conf: number
|
||||||
|
combatReady: boolean
|
||||||
|
}
|
||||||
|
|
||||||
const HANDLE_SIZE = 6
|
const HANDLE_SIZE = 6
|
||||||
const MIN_BOX_SIZE = 12
|
const MIN_BOX_SIZE = 12
|
||||||
|
|
||||||
const AFFILIATION_COLORS: Record<number, string> = {
|
const HOSTILE_HEXES = new Set(['#FF0000', '#FFFF00', '#FF00FF', '#800000', '#808000', '#800080'])
|
||||||
0: '#FFD700',
|
const FRIENDLY_HEXES = new Set(['#00FF00', '#0000FF', '#00FFFF', '#008000', '#000080', '#008080'])
|
||||||
1: '#228be6',
|
|
||||||
2: '#fa5252',
|
function affiliationIcon(hex: string) {
|
||||||
|
const up = hex.toUpperCase()
|
||||||
|
if (HOSTILE_HEXES.has(up)) {
|
||||||
|
return (
|
||||||
|
<svg width="11" height="11" viewBox="0 0 11 11" aria-hidden="true">
|
||||||
|
<polygon points="5.5,0.7 10.3,5.5 5.5,10.3 0.7,5.5" fill="#FF0000" stroke="#0A0D10" strokeWidth="1"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (FRIENDLY_HEXES.has(up)) {
|
||||||
|
return (
|
||||||
|
<svg width="11" height="9" viewBox="0 0 11 9" aria-hidden="true">
|
||||||
|
<rect x="0.5" y="0.5" width="10" height="8" fill="#87CEEB" stroke="#0A0D10" strokeWidth="1"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
|
||||||
|
<circle cx="5" cy="5" r="3.5" fill="none" stroke="currentColor" strokeWidth="1.2"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor(
|
const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor(
|
||||||
{ media, annotation, detections, onDetectionsChange, selectedClassNum, currentTime, annotations },
|
{ media, annotation, detections, onDetectionsChange, selectedClassNum, currentTime, annotations, onZoomChange, onCursorChange },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const imgRef = useRef<HTMLImageElement | null>(null)
|
const imgRef = useRef<HTMLImageElement | null>(null)
|
||||||
|
const cursorRafRef = useRef<number | null>(null)
|
||||||
|
const cursorLatestRef = useRef<{ x: number; y: number } | null>(null)
|
||||||
const [zoom, setZoom] = useState(1)
|
const [zoom, setZoom] = useState(1)
|
||||||
const [pan, setPan] = useState({ x: 0, y: 0 })
|
const [pan, setPan] = useState({ x: 0, y: 0 })
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||||
const [dragState, setDragState] = useState<DragState | null>(null)
|
const [dragState, setDragState] = useState<DragState | null>(null)
|
||||||
const [drawRect, setDrawRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null)
|
const [drawRect, setDrawRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null)
|
||||||
const [imgSize, setImgSize] = useState({ w: 0, h: 0 })
|
const [imgSize, setImgSize] = useState({ w: 0, h: 0 })
|
||||||
|
const [labelChips, setLabelChips] = useState<LabelChip[]>([])
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
deleteSelected() {
|
deleteSelected() {
|
||||||
@@ -70,7 +105,6 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
|||||||
|
|
||||||
const loadImage = useCallback(() => {
|
const loadImage = useCallback(() => {
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
// Use natural size based on container; no image load
|
|
||||||
imgRef.current = null
|
imgRef.current = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -116,16 +150,45 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
|||||||
return () => ro.disconnect()
|
return () => ro.disconnect()
|
||||||
}, [isVideo])
|
}, [isVideo])
|
||||||
|
|
||||||
const toCanvas = useCallback((nx: number, ny: number) => ({
|
useEffect(() => { onZoomChange?.(zoom) }, [zoom, onZoomChange])
|
||||||
x: nx * imgSize.w * zoom + pan.x,
|
|
||||||
y: ny * imgSize.h * zoom + pan.y,
|
// Cancel any pending cursor RAF on unmount so the callback can't fire after.
|
||||||
}), [imgSize, zoom, pan])
|
useEffect(() => () => {
|
||||||
|
if (cursorRafRef.current != null) {
|
||||||
|
cancelAnimationFrame(cursorRafRef.current)
|
||||||
|
cursorRafRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const fromCanvas = useCallback((cx: number, cy: number) => ({
|
const fromCanvas = useCallback((cx: number, cy: number) => ({
|
||||||
x: Math.max(0, Math.min(1, (cx - pan.x) / (imgSize.w * zoom))),
|
x: Math.max(0, Math.min(1, (cx - pan.x) / (imgSize.w * zoom))),
|
||||||
y: Math.max(0, Math.min(1, (cy - pan.y) / (imgSize.h * zoom))),
|
y: Math.max(0, Math.min(1, (cy - pan.y) / (imgSize.h * zoom))),
|
||||||
}), [imgSize, zoom, pan])
|
}), [imgSize, zoom, pan])
|
||||||
|
|
||||||
|
const getTimeWindowDetections = useCallback((): Detection[] => {
|
||||||
|
if (media.mediaType !== MediaType.Video) return []
|
||||||
|
if (annotation) return []
|
||||||
|
const timeTicks = currentTime * 10_000_000
|
||||||
|
return annotations
|
||||||
|
.filter(a => {
|
||||||
|
const sec = parseAnnotationTime(a.time)
|
||||||
|
if (sec == null) return false
|
||||||
|
return Math.abs(sec * 10_000_000 - timeTicks) < 2_000_000
|
||||||
|
})
|
||||||
|
.flatMap(a => a.detections)
|
||||||
|
}, [media.mediaType, annotation, annotations, currentTime])
|
||||||
|
|
||||||
|
const getHandles = (x: number, y: number, w: number, h: number) => [
|
||||||
|
{ x, y, cursor: 'nw-resize', name: 'tl' },
|
||||||
|
{ x: x + w / 2, y, cursor: 'n-resize', name: 'tc' },
|
||||||
|
{ x: x + w, y, cursor: 'ne-resize', name: 'tr' },
|
||||||
|
{ x: x + w, y: y + h / 2, cursor: 'e-resize', name: 'mr' },
|
||||||
|
{ x: x + w, y: y + h, cursor: 'se-resize', name: 'br' },
|
||||||
|
{ x: x + w / 2, y: y + h, cursor: 's-resize', name: 'bc' },
|
||||||
|
{ x, y: y + h, cursor: 'sw-resize', name: 'bl' },
|
||||||
|
{ x, y: y + h / 2, cursor: 'w-resize', name: 'ml' },
|
||||||
|
]
|
||||||
|
|
||||||
const draw = useCallback(() => {
|
const draw = useCallback(() => {
|
||||||
const canvas = canvasRef.current
|
const canvas = canvasRef.current
|
||||||
const ctx = canvas?.getContext('2d')
|
const ctx = canvas?.getContext('2d')
|
||||||
@@ -146,9 +209,11 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
|||||||
|
|
||||||
const timeWindowDets = getTimeWindowDetections()
|
const timeWindowDets = getTimeWindowDetections()
|
||||||
const allDets = [...detections, ...timeWindowDets]
|
const allDets = [...detections, ...timeWindowDets]
|
||||||
|
const chips: LabelChip[] = []
|
||||||
|
|
||||||
allDets.forEach((det, i) => {
|
allDets.forEach((det, i) => {
|
||||||
const isSelected = selected.has(i) && i < detections.length
|
const isOwn = i < detections.length
|
||||||
|
const isSelected = selected.has(i) && isOwn
|
||||||
const cx = (det.centerX - det.width / 2) * imgSize.w * zoom + pan.x
|
const cx = (det.centerX - det.width / 2) * imgSize.w * zoom + pan.x
|
||||||
const cy = (det.centerY - det.height / 2) * imgSize.h * zoom + pan.y
|
const cy = (det.centerY - det.height / 2) * imgSize.h * zoom + pan.y
|
||||||
const w = det.width * imgSize.w * zoom
|
const w = det.width * imgSize.w * zoom
|
||||||
@@ -160,45 +225,51 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
|||||||
ctx.strokeRect(cx, cy, w, h)
|
ctx.strokeRect(cx, cy, w, h)
|
||||||
|
|
||||||
ctx.fillStyle = color
|
ctx.fillStyle = color
|
||||||
ctx.globalAlpha = 0.1
|
ctx.globalAlpha = 0.06
|
||||||
ctx.fillRect(cx, cy, w, h)
|
ctx.fillRect(cx, cy, w, h)
|
||||||
ctx.globalAlpha = 1
|
ctx.globalAlpha = 1
|
||||||
|
|
||||||
const name = det.label || getClassNameFallback(det.classNum)
|
// Corner brackets — 8px legs (skipped in environments lacking path API, e.g. JSDOM)
|
||||||
const modeSuffix = getPhotoModeSuffix(det.classNum)
|
if (typeof ctx.moveTo === 'function' && typeof ctx.beginPath === 'function') {
|
||||||
const confSuffix = det.confidence < 0.995 ? ` ${(det.confidence * 100).toFixed(0)}%` : ''
|
const legLen = 8
|
||||||
const label = `${name}${modeSuffix}${confSuffix}`
|
ctx.lineWidth = 2
|
||||||
|
|
||||||
ctx.font = '11px sans-serif'
|
|
||||||
const metrics = ctx.measureText(label)
|
|
||||||
const padX = 3
|
|
||||||
const labelH = 14
|
|
||||||
const labelW = metrics.width + padX * 2
|
|
||||||
ctx.fillStyle = color
|
|
||||||
ctx.fillRect(cx, cy - labelH, labelW, labelH)
|
|
||||||
ctx.fillStyle = '#000'
|
|
||||||
ctx.fillText(label, cx + padX, cy - 3)
|
|
||||||
|
|
||||||
if (det.combatReadiness === 1) {
|
|
||||||
ctx.fillStyle = '#40c057'
|
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.arc(cx + w - 6, cy + 6, 3, 0, Math.PI * 2)
|
ctx.moveTo(cx, cy + legLen); ctx.lineTo(cx, cy); ctx.lineTo(cx + legLen, cy)
|
||||||
ctx.fill()
|
ctx.moveTo(cx + w - legLen, cy); ctx.lineTo(cx + w, cy); ctx.lineTo(cx + w, cy + legLen)
|
||||||
|
ctx.moveTo(cx + w, cy + h - legLen); ctx.lineTo(cx + w, cy + h); ctx.lineTo(cx + w - legLen, cy + h)
|
||||||
|
ctx.moveTo(cx + legLen, cy + h); ctx.lineTo(cx, cy + h); ctx.lineTo(cx, cy + h - legLen)
|
||||||
|
ctx.strokeStyle = color
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOwn) {
|
||||||
|
const container = containerRef.current
|
||||||
|
if (container && container.clientWidth && container.clientHeight) {
|
||||||
|
chips.push({
|
||||||
|
leftPct: (cx / container.clientWidth) * 100,
|
||||||
|
topPct: (cy / container.clientHeight) * 100,
|
||||||
|
color,
|
||||||
|
name: det.label || getClassNameFallback(det.classNum),
|
||||||
|
conf: det.confidence,
|
||||||
|
combatReady: det.combatReadiness === 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
const handles = getHandles(cx, cy, w, h)
|
const handles = getHandles(cx, cy, w, h)
|
||||||
handles.forEach(hp => {
|
handles.forEach(hp => {
|
||||||
ctx.fillStyle = '#fff'
|
ctx.fillStyle = '#FF9D3D'
|
||||||
ctx.fillRect(hp.x - HANDLE_SIZE / 2, hp.y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE)
|
ctx.fillRect(hp.x - HANDLE_SIZE / 2, hp.y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE)
|
||||||
ctx.strokeStyle = color
|
ctx.strokeStyle = '#0A0D10'
|
||||||
ctx.strokeRect(hp.x - HANDLE_SIZE / 2, hp.y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE)
|
ctx.strokeRect(hp.x - HANDLE_SIZE / 2, hp.y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (drawRect) {
|
if (drawRect) {
|
||||||
ctx.strokeStyle = '#fd7e14'
|
ctx.strokeStyle = '#FF9D3D'
|
||||||
ctx.lineWidth = 1
|
ctx.lineWidth = 1
|
||||||
ctx.setLineDash([4, 4])
|
ctx.setLineDash([4, 4])
|
||||||
ctx.strokeRect(drawRect.x, drawRect.y, drawRect.w, drawRect.h)
|
ctx.strokeRect(drawRect.x, drawRect.y, drawRect.w, drawRect.h)
|
||||||
@@ -206,7 +277,23 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx.restore()
|
ctx.restore()
|
||||||
}, [detections, selected, zoom, pan, imgSize, drawRect, currentTime, annotations])
|
|
||||||
|
// Only setState when chips actually changed — prevents a render storm
|
||||||
|
// during video playback (draw runs on every time-update; without this
|
||||||
|
// guard React would commit a new array reference on every paint).
|
||||||
|
setLabelChips(prev => {
|
||||||
|
if (prev.length !== chips.length) return chips
|
||||||
|
for (let i = 0; i < chips.length; i++) {
|
||||||
|
const a = prev[i], b = chips[i]
|
||||||
|
if (
|
||||||
|
a.leftPct !== b.leftPct || a.topPct !== b.topPct ||
|
||||||
|
a.color !== b.color || a.name !== b.name ||
|
||||||
|
a.conf !== b.conf || a.combatReady !== b.combatReady
|
||||||
|
) return chips
|
||||||
|
}
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
}, [detections, selected, zoom, pan, imgSize, drawRect, isVideo, getTimeWindowDetections])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = requestAnimationFrame(draw)
|
const id = requestAnimationFrame(draw)
|
||||||
@@ -221,31 +308,6 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
|||||||
return () => obs.disconnect()
|
return () => obs.disconnect()
|
||||||
}, [draw])
|
}, [draw])
|
||||||
|
|
||||||
const getTimeWindowDetections = (): Detection[] => {
|
|
||||||
if (media.mediaType !== MediaType.Video) return []
|
|
||||||
if (annotation) return []
|
|
||||||
const timeTicks = currentTime * 10_000_000
|
|
||||||
return annotations
|
|
||||||
.filter(a => {
|
|
||||||
if (!a.time) return false
|
|
||||||
const parts = a.time.split(':').map(Number)
|
|
||||||
const annTime = (parts[0] * 3600 + parts[1] * 60 + parts[2]) * 10_000_000
|
|
||||||
return Math.abs(annTime - timeTicks) < 2_000_000
|
|
||||||
})
|
|
||||||
.flatMap(a => a.detections)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getHandles = (x: number, y: number, w: number, h: number) => [
|
|
||||||
{ x, y, cursor: 'nw-resize', name: 'tl' },
|
|
||||||
{ x: x + w / 2, y, cursor: 'n-resize', name: 'tc' },
|
|
||||||
{ x: x + w, y, cursor: 'ne-resize', name: 'tr' },
|
|
||||||
{ x: x + w, y: y + h / 2, cursor: 'e-resize', name: 'mr' },
|
|
||||||
{ x: x + w, y: y + h, cursor: 'se-resize', name: 'br' },
|
|
||||||
{ x: x + w / 2, y: y + h, cursor: 's-resize', name: 'bc' },
|
|
||||||
{ x, y: y + h, cursor: 'sw-resize', name: 'bl' },
|
|
||||||
{ x, y: y + h / 2, cursor: 'w-resize', name: 'ml' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const hitTest = (cx: number, cy: number) => {
|
const hitTest = (cx: number, cy: number) => {
|
||||||
for (let i = detections.length - 1; i >= 0; i--) {
|
for (let i = detections.length - 1; i >= 0; i--) {
|
||||||
const d = detections[i]
|
const d = detections[i]
|
||||||
@@ -298,12 +360,28 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleMouseMove = (e: React.MouseEvent) => {
|
const handleMouseMove = (e: React.MouseEvent) => {
|
||||||
if (!dragState) return
|
|
||||||
const rect = canvasRef.current?.getBoundingClientRect()
|
const rect = canvasRef.current?.getBoundingClientRect()
|
||||||
if (!rect) return
|
if (!rect) return
|
||||||
const mx = e.clientX - rect.left
|
const mx = e.clientX - rect.left
|
||||||
const my = e.clientY - rect.top
|
const my = e.clientY - rect.top
|
||||||
|
|
||||||
|
if (onCursorChange && imgSize.w && imgSize.h) {
|
||||||
|
const nx = (mx - pan.x) / (imgSize.w * zoom)
|
||||||
|
const ny = (my - pan.y) / (imgSize.h * zoom)
|
||||||
|
if (nx >= 0 && nx <= 1 && ny >= 0 && ny <= 1) {
|
||||||
|
cursorLatestRef.current = { x: nx, y: ny }
|
||||||
|
if (cursorRafRef.current == null) {
|
||||||
|
cursorRafRef.current = requestAnimationFrame(() => {
|
||||||
|
const v = cursorLatestRef.current
|
||||||
|
cursorRafRef.current = null
|
||||||
|
if (v) onCursorChange(v.x, v.y)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dragState) return
|
||||||
|
|
||||||
if (dragState.type === 'draw') {
|
if (dragState.type === 'draw') {
|
||||||
setDrawRect({
|
setDrawRect({
|
||||||
x: Math.min(dragState.startX, mx),
|
x: Math.min(dragState.startX, mx),
|
||||||
@@ -415,6 +493,25 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
|||||||
onMouseLeave={handleMouseUp}
|
onMouseLeave={handleMouseUp}
|
||||||
onWheel={handleWheel}
|
onWheel={handleWheel}
|
||||||
/>
|
/>
|
||||||
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
|
{labelChips.map((chip, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="bbox-label"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${chip.leftPct}%`,
|
||||||
|
top: `calc(${chip.topPct}% - 26px)`,
|
||||||
|
borderColor: hexToRgba(chip.color, 0.6),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: chip.color, display: 'inline-flex' }}>{affiliationIcon(chip.color)}</span>
|
||||||
|
{chip.combatReady && <span style={{ width: 6, height: 6, borderRadius: 999, background: 'var(--accent-green)', display: 'inline-block' }} />}
|
||||||
|
<span style={{ color: chip.color }}>{chip.name}</span>
|
||||||
|
{chip.conf < 0.995 && <span className="conf">{(chip.conf * 100).toFixed(1)}%</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
|||||||
const debouncedFilter = useDebounce(filter, 300)
|
const debouncedFilter = useDebounce(filter, 300)
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||||
const folderInputRef = useRef<HTMLInputElement>(null)
|
const folderInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const fetchMedia = useCallback(async () => {
|
const fetchMedia = useCallback(async () => {
|
||||||
const params = new URLSearchParams({ pageSize: '1000' })
|
const params = new URLSearchParams({ pageSize: '1000' })
|
||||||
@@ -139,70 +140,126 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
|||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filtered = media.filter(m => m.name.toLowerCase().includes(filter.toLowerCase()))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...getRootProps({
|
{...getRootProps({
|
||||||
className: `flex-1 flex flex-col overflow-hidden ${isDragActive ? 'ring-2 ring-az-orange ring-inset' : ''}`,
|
className: `flex flex-col flex-1 min-h-0 bg-surface-1${isDragActive ? ' ring-2 ring-accent-amber ring-inset' : ''}`,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
{/* Dropzone hidden input */}
|
||||||
<input {...getInputProps()} />
|
<input {...getInputProps()} />
|
||||||
<div className="p-2 border-b border-az-border flex gap-1">
|
|
||||||
<input
|
{/* Hidden file inputs */}
|
||||||
value={filter}
|
<input
|
||||||
onChange={e => setFilter(e.target.value)}
|
ref={fileInputRef}
|
||||||
placeholder={t('annotations.mediaList')}
|
type="file"
|
||||||
className="flex-1 bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text outline-none"
|
multiple
|
||||||
/>
|
className="hidden"
|
||||||
</div>
|
onChange={e => {
|
||||||
<div className="px-2 pt-2 pb-2 flex gap-1">
|
if (e.target.files?.length) uploadFiles(e.target.files)
|
||||||
<label className="flex-1 bg-az-orange text-white text-[10px] py-1 rounded text-center cursor-pointer hover:brightness-110">
|
e.target.value = ''
|
||||||
Open File
|
}}
|
||||||
<input
|
/>
|
||||||
type="file"
|
<input
|
||||||
multiple
|
ref={folderInputRef}
|
||||||
className="hidden"
|
type="file"
|
||||||
onChange={e => {
|
multiple
|
||||||
if (e.target.files?.length) uploadFiles(e.target.files)
|
className="hidden"
|
||||||
e.target.value = ''
|
// @ts-expect-error webkitdirectory is non-standard but widely supported
|
||||||
}}
|
webkitdirectory=""
|
||||||
/>
|
directory=""
|
||||||
</label>
|
onChange={handleFolderInput}
|
||||||
<button
|
/>
|
||||||
type="button"
|
|
||||||
onClick={() => folderInputRef.current?.click()}
|
{/* Header row */}
|
||||||
className="flex-1 bg-az-orange text-white text-[10px] py-1 rounded hover:brightness-110"
|
<div className="flex items-center justify-between px-3 h-9 border-b border-border-hair shrink-0">
|
||||||
>
|
<div className="flex items-center gap-2">
|
||||||
Open Folder
|
<span className="sect-head">{t('annotations.mediaList')}</span>
|
||||||
</button>
|
<span className="mono text-[10px] text-text-muted">{filtered.length}</span>
|
||||||
<input
|
</div>
|
||||||
ref={folderInputRef}
|
<div className="flex items-center gap-1">
|
||||||
type="file"
|
{/* Upload file button */}
|
||||||
multiple
|
<button
|
||||||
className="hidden"
|
type="button"
|
||||||
// @ts-expect-error webkitdirectory is non-standard but widely supported
|
className="ibtn"
|
||||||
webkitdirectory=""
|
style={{ width: 22, height: 22 }}
|
||||||
directory=""
|
title={t('annotations.upload')}
|
||||||
onChange={handleFolderInput}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
{media.filter(m => m.name.toLowerCase().includes(filter.toLowerCase())).map(m => (
|
|
||||||
<div
|
|
||||||
key={m.id}
|
|
||||||
onClick={() => handleSelect(m)}
|
|
||||||
onContextMenu={e => { e.preventDefault(); setDeleteId(m.id) }}
|
|
||||||
className={`px-2 py-1 cursor-pointer border-b border-az-border text-xs flex items-center gap-1.5 ${
|
|
||||||
selectedMedia?.id === m.id ? 'bg-az-bg text-white' : ''
|
|
||||||
} ${m.annotationCount > 0 ? 'bg-az-bg/50' : ''} text-az-text hover:bg-az-bg`}
|
|
||||||
>
|
>
|
||||||
<span className={`font-mono text-[10px] px-1 rounded ${m.mediaType === MediaType.Video ? 'bg-az-blue/20 text-az-blue' : 'bg-az-green/20 text-az-green'}`}>
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
{m.mediaType === MediaType.Video ? 'V' : 'P'}
|
<path d="M12 5v14M5 12h14"/>
|
||||||
</span>
|
</svg>
|
||||||
<span className="truncate flex-1">{m.name}</span>
|
</button>
|
||||||
{m.duration && <span className="text-az-muted">{m.duration}</span>}
|
{/* Open folder button */}
|
||||||
</div>
|
<button
|
||||||
))}
|
type="button"
|
||||||
|
className="ibtn"
|
||||||
|
style={{ width: 22, height: 22 }}
|
||||||
|
title="Open Folder"
|
||||||
|
onClick={() => folderInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Filter input row */}
|
||||||
|
<div className="px-3 py-2 border-b border-border-hair shrink-0">
|
||||||
|
<div className="relative">
|
||||||
|
<svg
|
||||||
|
width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
||||||
|
className="absolute left-2 top-1/2 -translate-y-1/2 text-text-muted pointer-events-none"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
className="inp w-full pl-7"
|
||||||
|
style={{ height: 28, padding: '0 10px 0 28px' }}
|
||||||
|
value={filter}
|
||||||
|
onChange={e => setFilter(e.target.value)}
|
||||||
|
placeholder={t('annotations.filterByName')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
|
{filtered.map(m => {
|
||||||
|
const isActive = selectedMedia?.id === m.id
|
||||||
|
const isVideo = m.mediaType === MediaType.Video
|
||||||
|
const hasDuration = !!m.duration
|
||||||
|
const durationColor = isActive
|
||||||
|
? 'text-accent-amber'
|
||||||
|
: hasDuration
|
||||||
|
? 'text-text-secondary'
|
||||||
|
: 'text-text-muted'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => handleSelect(m)}
|
||||||
|
onContextMenu={e => { e.preventDefault(); setDeleteId(m.id) }}
|
||||||
|
className={`media-row${isActive ? ' active' : ''}`}
|
||||||
|
>
|
||||||
|
{isVideo
|
||||||
|
? <span className="chip-video">VIDEO</span>
|
||||||
|
: <span className="chip-photo">PHOTO</span>
|
||||||
|
}
|
||||||
|
<span className={`truncate${isActive ? ' font-medium text-text-primary' : ' text-text-primary'}`}>
|
||||||
|
{m.name}
|
||||||
|
</span>
|
||||||
|
<span className={`mono text-[11px] ${durationColor}`}>
|
||||||
|
{m.duration ?? '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!deleteId}
|
open={!!deleteId}
|
||||||
title={t('annotations.deleteMedia')}
|
title={t('annotations.deleteMedia')}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
export interface ScrubberMark {
|
||||||
|
time: number
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
current: number
|
||||||
|
duration: number
|
||||||
|
marks: ScrubberMark[]
|
||||||
|
onSeek: (time: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const TICK_PERCENTS = [0, 25, 50, 75, 100]
|
||||||
|
|
||||||
|
export default function Scrubber({ current, duration, marks, onSeek }: Props) {
|
||||||
|
const trackRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [dragging, setDragging] = useState(false)
|
||||||
|
const safeDuration = duration > 0 ? duration : 1
|
||||||
|
const pct = Math.max(0, Math.min(100, (current / safeDuration) * 100))
|
||||||
|
|
||||||
|
const seekFromClientX = useCallback((clientX: number) => {
|
||||||
|
const el = trackRef.current
|
||||||
|
if (!el) return
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
const x = Math.max(0, Math.min(rect.width, clientX - rect.left))
|
||||||
|
onSeek((x / rect.width) * safeDuration)
|
||||||
|
}, [onSeek, safeDuration])
|
||||||
|
|
||||||
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragging(true)
|
||||||
|
seekFromClientX(e.clientX)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dragging) return
|
||||||
|
const move = (e: MouseEvent) => seekFromClientX(e.clientX)
|
||||||
|
const up = () => setDragging(false)
|
||||||
|
window.addEventListener('mousemove', move)
|
||||||
|
window.addEventListener('mouseup', up)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', move)
|
||||||
|
window.removeEventListener('mouseup', up)
|
||||||
|
}
|
||||||
|
}, [dragging, seekFromClientX])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={trackRef} className="scrub" onMouseDown={handleMouseDown}>
|
||||||
|
<div className="fill" style={{ width: `${pct}%` }} />
|
||||||
|
{TICK_PERCENTS.map(p => (
|
||||||
|
<div key={p} className="tick" style={{ left: `${p}%` }} />
|
||||||
|
))}
|
||||||
|
{marks.map((m, i) => {
|
||||||
|
const mpct = Math.max(0, Math.min(100, (m.time / safeDuration) * 100))
|
||||||
|
return <div key={i} className="mark" style={{ left: `${mpct}%`, background: m.color }} />
|
||||||
|
})}
|
||||||
|
<div className="head" style={{ left: `${pct}%` }} />
|
||||||
|
<div className="head-knob" style={{ left: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,42 +1,52 @@
|
|||||||
import { useRef, useState, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'
|
import { useRef, useState, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'
|
||||||
import { FaPlay, FaPause, FaStop, FaStepBackward, FaStepForward, FaVolumeMute, FaVolumeUp } from 'react-icons/fa'
|
|
||||||
import { endpoints } from '../../api'
|
import { endpoints } from '../../api'
|
||||||
import type { Media } from '../../types'
|
import type { Media } from '../../types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
media: Media
|
media: Media
|
||||||
onTimeUpdate: (time: number) => void
|
onTimeUpdate: (time: number) => void
|
||||||
|
/** Fires when the <video> emits 'play'/'pause' (no polling needed). */
|
||||||
|
onPlayingChange?: (playing: boolean) => void
|
||||||
|
/** Fires when the <video> reports a valid duration. */
|
||||||
|
onDurationChange?: (duration: number) => void
|
||||||
|
/** Fires when the <video> mute state changes (incl. the M keyboard shortcut). */
|
||||||
|
onMutedChange?: (muted: boolean) => void
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
const STEP_BTN_CLASS = 'w-9 h-8 flex items-center justify-center bg-az-bg rounded hover:bg-az-border text-az-text text-xs font-mono'
|
|
||||||
const ICON_BTN_CLASS = 'w-10 h-10 flex items-center justify-center bg-az-bg rounded hover:bg-az-border text-white'
|
|
||||||
|
|
||||||
export interface VideoPlayerHandle {
|
export interface VideoPlayerHandle {
|
||||||
seek: (seconds: number) => void
|
seek: (seconds: number) => void
|
||||||
getVideoElement: () => HTMLVideoElement | null
|
getVideoElement: () => HTMLVideoElement | null
|
||||||
|
play: () => void
|
||||||
|
pause: () => void
|
||||||
|
toggle: () => void
|
||||||
|
isPlaying: () => boolean
|
||||||
|
frameStep: (deltaFrames: number) => void
|
||||||
|
getDuration: () => number
|
||||||
|
getCurrentTime: () => number
|
||||||
|
getFrameRate: () => number
|
||||||
|
getCurrentFrame: () => number
|
||||||
|
getTotalFrames: () => number
|
||||||
|
getVolume: () => number
|
||||||
|
setVolume: (v: number) => void
|
||||||
|
toggleMute: () => void
|
||||||
|
isMuted: () => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({ media, onTimeUpdate, children }, ref) {
|
const FPS = 30
|
||||||
const videoRef = useRef<HTMLVideoElement>(null)
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
|
||||||
seek(seconds: number) {
|
media, onTimeUpdate, onPlayingChange, onDurationChange, onMutedChange, children,
|
||||||
if (videoRef.current) {
|
}, ref) {
|
||||||
videoRef.current.currentTime = seconds
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
setCurrentTime(seconds)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getVideoElement() {
|
|
||||||
return videoRef.current
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [playing, setPlaying] = useState(false)
|
|
||||||
const [currentTime, setCurrentTime] = useState(0)
|
|
||||||
const [duration, setDuration] = useState(0)
|
|
||||||
const [muted, setMuted] = useState(false)
|
const [muted, setMuted] = useState(false)
|
||||||
|
|
||||||
|
const notifyMuted = useCallback((m: boolean) => {
|
||||||
|
setMuted(m)
|
||||||
|
onMutedChange?.(m)
|
||||||
|
}, [onMutedChange])
|
||||||
|
|
||||||
const videoUrl = media.path.startsWith('blob:')
|
const videoUrl = media.path.startsWith('blob:')
|
||||||
? media.path
|
? media.path
|
||||||
: endpoints.annotations.mediaFile(media.id)
|
: endpoints.annotations.mediaFile(media.id)
|
||||||
@@ -44,24 +54,47 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
|
|||||||
const stepFrames = useCallback((count: number) => {
|
const stepFrames = useCallback((count: number) => {
|
||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
if (!video) return
|
if (!video) return
|
||||||
const fps = 30
|
video.currentTime = Math.max(0, Math.min(video.duration || 0, video.currentTime + count / FPS))
|
||||||
video.currentTime = Math.max(0, Math.min(video.duration, video.currentTime + count / fps))
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const togglePlay = useCallback(() => {
|
const togglePlay = useCallback(() => {
|
||||||
const v = videoRef.current
|
const v = videoRef.current
|
||||||
if (!v) return
|
if (!v) return
|
||||||
if (v.paused) { v.play(); setPlaying(true) }
|
if (v.paused) v.play().catch(() => {})
|
||||||
else { v.pause(); setPlaying(false) }
|
else v.pause()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
useImperativeHandle(ref, () => ({
|
||||||
const v = videoRef.current
|
seek(seconds: number) {
|
||||||
if (!v) return
|
const v = videoRef.current
|
||||||
v.pause()
|
if (v) v.currentTime = seconds
|
||||||
v.currentTime = 0
|
},
|
||||||
setPlaying(false)
|
getVideoElement() { return videoRef.current },
|
||||||
}, [])
|
play() { videoRef.current?.play().catch(() => {}) },
|
||||||
|
pause() { videoRef.current?.pause() },
|
||||||
|
toggle() { togglePlay() },
|
||||||
|
isPlaying() { return !!videoRef.current && !videoRef.current.paused },
|
||||||
|
frameStep(delta) { stepFrames(delta) },
|
||||||
|
getDuration() { return videoRef.current?.duration ?? 0 },
|
||||||
|
getCurrentTime() { return videoRef.current?.currentTime ?? 0 },
|
||||||
|
getFrameRate() { return FPS },
|
||||||
|
getCurrentFrame() { return Math.floor((videoRef.current?.currentTime ?? 0) * FPS) },
|
||||||
|
getTotalFrames() { return Math.floor((videoRef.current?.duration ?? 0) * FPS) },
|
||||||
|
getVolume() { return videoRef.current?.volume ?? 1 },
|
||||||
|
setVolume(v) {
|
||||||
|
const el = videoRef.current
|
||||||
|
if (!el) return
|
||||||
|
el.volume = Math.max(0, Math.min(1, v))
|
||||||
|
if (el.volume > 0 && el.muted) { el.muted = false; notifyMuted(false) }
|
||||||
|
},
|
||||||
|
toggleMute() {
|
||||||
|
const el = videoRef.current
|
||||||
|
if (!el) return
|
||||||
|
el.muted = !el.muted
|
||||||
|
notifyMuted(el.muted)
|
||||||
|
},
|
||||||
|
isMuted() { return !!videoRef.current?.muted },
|
||||||
|
}))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
@@ -70,22 +103,22 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
|
|||||||
case ' ': e.preventDefault(); togglePlay(); break
|
case ' ': e.preventDefault(); togglePlay(); break
|
||||||
case 'ArrowLeft': e.preventDefault(); stepFrames(e.ctrlKey ? -150 : -1); break
|
case 'ArrowLeft': e.preventDefault(); stepFrames(e.ctrlKey ? -150 : -1); break
|
||||||
case 'ArrowRight': e.preventDefault(); stepFrames(e.ctrlKey ? 150 : 1); break
|
case 'ArrowRight': e.preventDefault(); stepFrames(e.ctrlKey ? 150 : 1); break
|
||||||
case 'm': case 'M': setMuted(m => !m); break
|
case 'm': case 'M': {
|
||||||
|
const v = videoRef.current
|
||||||
|
if (v) { v.muted = !v.muted; notifyMuted(v.muted) }
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', handler)
|
window.addEventListener('keydown', handler)
|
||||||
return () => window.removeEventListener('keydown', handler)
|
return () => window.removeEventListener('keydown', handler)
|
||||||
}, [togglePlay, stepFrames])
|
}, [togglePlay, stepFrames])
|
||||||
|
|
||||||
const formatTime = (s: number) => {
|
|
||||||
const m = Math.floor(s / 60)
|
|
||||||
const sec = Math.floor(s % 60)
|
|
||||||
return `${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-black flex flex-col flex-1 min-h-0">
|
<div className="flex flex-col flex-1 min-h-0 bg-surface-0">
|
||||||
{error && <div className="bg-az-red/80 text-white text-xs px-2 py-1">{error}</div>}
|
{error && (
|
||||||
|
<div className="bg-surface-1 border-b border-border-hair text-accent-red text-xs px-3 py-1">{error}</div>
|
||||||
|
)}
|
||||||
<div className="relative flex-1 min-h-0 flex items-center justify-center">
|
<div className="relative flex-1 min-h-0 flex items-center justify-center">
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
@@ -94,76 +127,18 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
|
|||||||
controls={false}
|
controls={false}
|
||||||
playsInline
|
playsInline
|
||||||
className="max-w-full max-h-full object-contain"
|
className="max-w-full max-h-full object-contain"
|
||||||
onTimeUpdate={e => {
|
onTimeUpdate={e => onTimeUpdate((e.target as HTMLVideoElement).currentTime)}
|
||||||
const t = (e.target as HTMLVideoElement).currentTime
|
onPlay={() => onPlayingChange?.(true)}
|
||||||
setCurrentTime(t)
|
onPause={() => onPlayingChange?.(false)}
|
||||||
onTimeUpdate(t)
|
onDurationChange={e => {
|
||||||
}}
|
const d = (e.target as HTMLVideoElement).duration
|
||||||
onLoadedMetadata={e => {
|
if (Number.isFinite(d)) onDurationChange?.(d)
|
||||||
setDuration((e.target as HTMLVideoElement).duration)
|
|
||||||
setError(null)
|
|
||||||
}}
|
}}
|
||||||
|
onLoadedMetadata={() => setError(null)}
|
||||||
onError={() => setError(`Failed to load video (${media.name})`)}
|
onError={() => setError(`Failed to load video (${media.name})`)}
|
||||||
/>
|
/>
|
||||||
{children && <div className="absolute inset-0">{children}</div>}
|
{children && <div className="absolute inset-0">{children}</div>}
|
||||||
</div>
|
</div>
|
||||||
{/* Progress row: time | slider | remaining */}
|
|
||||||
<div className="flex items-center gap-3 bg-az-header px-4 py-1.5">
|
|
||||||
<span className="text-white text-xs font-mono tabular-nums min-w-[40px] text-right">{formatTime(currentTime)}</span>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={duration || 1}
|
|
||||||
step={0.01}
|
|
||||||
value={currentTime}
|
|
||||||
onChange={e => {
|
|
||||||
const v = Number(e.target.value)
|
|
||||||
setCurrentTime(v)
|
|
||||||
if (videoRef.current) videoRef.current.currentTime = v
|
|
||||||
}}
|
|
||||||
className="flex-1 accent-az-orange h-1 cursor-pointer"
|
|
||||||
style={{
|
|
||||||
background: `linear-gradient(to right, #fd7e14 0%, #fd7e14 ${(currentTime / (duration || 1)) * 100}%, #495057 ${(currentTime / (duration || 1)) * 100}%, #495057 100%)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="text-white text-xs font-mono tabular-nums min-w-[40px]">-{formatTime(Math.max(0, duration - currentTime))}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Buttons row */}
|
|
||||||
<div className="flex items-center justify-center gap-2 bg-az-header pb-2 flex-wrap">
|
|
||||||
<button onClick={() => stepFrames(-1)} title="Previous frame" className={ICON_BTN_CLASS}>
|
|
||||||
<FaStepBackward size={14} />
|
|
||||||
</button>
|
|
||||||
<button onClick={togglePlay} title={playing ? 'Pause' : 'Play'} className="w-10 h-10 flex items-center justify-center bg-az-orange rounded hover:brightness-110 text-white">
|
|
||||||
{playing ? <FaPause size={14} /> : <FaPlay size={14} />}
|
|
||||||
</button>
|
|
||||||
<button onClick={() => stepFrames(1)} title="Next frame" className={ICON_BTN_CLASS}>
|
|
||||||
<FaStepForward size={14} />
|
|
||||||
</button>
|
|
||||||
<button onClick={stop} title="Stop" className={ICON_BTN_CLASS}>
|
|
||||||
<FaStop size={14} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<span className="w-px h-8 bg-az-border mx-1" />
|
|
||||||
|
|
||||||
{[1, 5, 10, 30, 60].map(n => (
|
|
||||||
<button key={`prev-${n}`} onClick={() => stepFrames(-n)} title={`-${n} frames`} className={STEP_BTN_CLASS}>
|
|
||||||
-{n}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<span className="w-px h-8 bg-az-border mx-1" />
|
|
||||||
{[1, 5, 10, 30, 60].map(n => (
|
|
||||||
<button key={`next-${n}`} onClick={() => stepFrames(n)} title={`+${n} frames`} className={STEP_BTN_CLASS}>
|
|
||||||
+{n}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<span className="w-px h-8 bg-az-border mx-1" />
|
|
||||||
|
|
||||||
<button onClick={() => setMuted(m => !m)} title={muted ? 'Unmute' : 'Mute'} className={ICON_BTN_CLASS}>
|
|
||||||
{muted ? <FaVolumeMute size={14} /> : <FaVolumeUp size={14} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Annotation time helpers — shared between AnnotationsPage and CanvasEditor.
|
||||||
|
* Annotation `time` is the backend's "HH:MM:SS.mmm" tick representation; this
|
||||||
|
* module owns the conversion to/from seconds + display formatting.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function parseAnnotationTime(t: string | null | undefined): number | null {
|
||||||
|
if (!t) return null
|
||||||
|
const parts = t.split(':').map(Number)
|
||||||
|
if (parts.length !== 3) return null
|
||||||
|
if (parts.some(p => !Number.isFinite(p))) return null
|
||||||
|
return (parts[0] || 0) * 3600 + (parts[1] || 0) * 60 + (parts[2] || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(seconds: number, withMs = false): string {
|
||||||
|
if (!Number.isFinite(seconds) || seconds < 0) seconds = 0
|
||||||
|
const m = Math.floor(seconds / 60)
|
||||||
|
const s = Math.floor(seconds % 60)
|
||||||
|
const base = `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||||
|
if (!withMs) return base
|
||||||
|
const ms = Math.floor((seconds - Math.floor(seconds)) * 1000)
|
||||||
|
return `${base}.${String(ms).padStart(3, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTicks(seconds: number): string {
|
||||||
|
if (!Number.isFinite(seconds) || seconds < 0) seconds = 0
|
||||||
|
const h = Math.floor(seconds / 3600)
|
||||||
|
const m = Math.floor((seconds % 3600) / 60)
|
||||||
|
const s = Math.floor(seconds % 60)
|
||||||
|
const ms = Math.floor((seconds - Math.floor(seconds)) * 1000)
|
||||||
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(ms).padStart(3, '0')}`
|
||||||
|
}
|
||||||
@@ -1,106 +1,727 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useMemo, type ReactNode } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { api, endpoints } from '../../api'
|
import { api, endpoints } from '../../api'
|
||||||
|
import { useAuth } from '../../auth'
|
||||||
|
import { LANG_STORAGE_KEY } from '../../i18n'
|
||||||
import type { SystemSettings, DirectorySettings, Aircraft } from '../../types'
|
import type { SystemSettings, DirectorySettings, Aircraft } from '../../types'
|
||||||
|
import { Modal } from '../admin/Modal'
|
||||||
|
|
||||||
|
type Lang = 'en' | 'ua'
|
||||||
|
const I18N_BUNDLE_VERSION = 'v2.4.1'
|
||||||
|
const DASH = '—'
|
||||||
|
|
||||||
|
type AircraftDraft = {
|
||||||
|
model: string
|
||||||
|
type: Aircraft['type']
|
||||||
|
resolution: string
|
||||||
|
maxMinutes: number
|
||||||
|
isDefault: boolean
|
||||||
|
}
|
||||||
|
const NEW_AIRCRAFT_DEFAULTS: AircraftDraft = {
|
||||||
|
model: '', type: 'Copter', resolution: '4K', maxMinutes: 30, isDefault: false,
|
||||||
|
}
|
||||||
|
const AIRCRAFT_TYPES = ['Plane', 'Copter', 'FixedWing'] as const
|
||||||
|
const RESOLUTIONS = ['HD', '1080P', '4K', '6K'] as const
|
||||||
|
const TYPE_LEGEND_KEY: Record<Aircraft['type'], 'legendPlane' | 'legendCopter' | 'legendFixedW'> = {
|
||||||
|
Plane: 'legendPlane', Copter: 'legendCopter', FixedWing: 'legendFixedW',
|
||||||
|
}
|
||||||
|
const TYPE_CHIP_COLOR: Record<Aircraft['type'], string> = {
|
||||||
|
Plane: 'var(--accent-blue)',
|
||||||
|
Copter: 'var(--accent-green)',
|
||||||
|
FixedWing: 'var(--accent-amber)',
|
||||||
|
}
|
||||||
|
const TYPE_CHIP_BORDER: Record<Aircraft['type'], string> = {
|
||||||
|
Plane: 'rgba(78,158,255,0.45)',
|
||||||
|
Copter: 'rgba(61,220,132,0.45)',
|
||||||
|
FixedWing: 'rgba(255,157,61,0.45)',
|
||||||
|
}
|
||||||
|
|
||||||
|
function FolderIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<path d="M3 6.5A1.5 1.5 0 0 1 4.5 5h4.4l1.6 2H19.5A1.5 1.5 0 0 1 21 8.5v9A1.5 1.5 0 0 1 19.5 19h-15A1.5 1.5 0 0 1 3 17.5v-11Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
function SignOutIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||||
|
<polyline points="16 17 21 12 16 7" />
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
function CheckIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dirtyTenant(a: SystemSettings | null, b: SystemSettings | null): boolean {
|
||||||
|
if (!a || !b) return false
|
||||||
|
return (
|
||||||
|
a.militaryUnit !== b.militaryUnit ||
|
||||||
|
a.name !== b.name ||
|
||||||
|
a.defaultCameraWidth !== b.defaultCameraWidth ||
|
||||||
|
a.defaultCameraFoV !== b.defaultCameraFoV
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dirtyDirs(a: DirectorySettings | null, b: DirectorySettings | null): boolean {
|
||||||
|
if (!a || !b) return false
|
||||||
|
return (
|
||||||
|
a.imagesDir !== b.imagesDir ||
|
||||||
|
a.labelsDir !== b.labelsDir ||
|
||||||
|
a.thumbnailsDir !== b.thumbnailsDir
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { t } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const [system, setSystem] = useState<SystemSettings | null>(null)
|
const [system, setSystem] = useState<SystemSettings | null>(null)
|
||||||
|
const [systemInitial, setSystemInitial] = useState<SystemSettings | null>(null)
|
||||||
const [dirs, setDirs] = useState<DirectorySettings | null>(null)
|
const [dirs, setDirs] = useState<DirectorySettings | null>(null)
|
||||||
|
const [dirsInitial, setDirsInitial] = useState<DirectorySettings | null>(null)
|
||||||
const [aircrafts, setAircrafts] = useState<Aircraft[]>([])
|
const [aircrafts, setAircrafts] = useState<Aircraft[]>([])
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
|
const lang: Lang = i18n.language === 'ua' ? 'ua' : 'en'
|
||||||
|
|
||||||
|
const [aircraftModalOpen, setAircraftModalOpen] = useState(false)
|
||||||
|
const [aircraftDraft, setAircraftDraft] = useState<AircraftDraft>(NEW_AIRCRAFT_DEFAULTS)
|
||||||
|
const [aircraftSaving, setAircraftSaving] = useState(false)
|
||||||
|
const [aircraftError, setAircraftError] = useState<'modelRequired' | 'saveFailed' | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get<SystemSettings>(endpoints.annotations.settingsSystem()).then(setSystem).catch(() => {})
|
api.get<SystemSettings>(endpoints.annotations.settingsSystem()).then(s => {
|
||||||
api.get<DirectorySettings>(endpoints.annotations.settingsDirectories()).then(setDirs).catch(() => {})
|
setSystem(s)
|
||||||
|
setSystemInitial(s)
|
||||||
|
}).catch(() => {})
|
||||||
|
api.get<DirectorySettings>(endpoints.annotations.settingsDirectories()).then(d => {
|
||||||
|
setDirs(d)
|
||||||
|
setDirsInitial(d)
|
||||||
|
}).catch(() => {})
|
||||||
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
|
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const saveSystem = async () => {
|
const tenantDirty = useMemo(() => dirtyTenant(system, systemInitial), [system, systemInitial])
|
||||||
if (!system) return
|
const dirsDirty = useMemo(() => dirtyDirs(dirs, dirsInitial), [dirs, dirsInitial])
|
||||||
|
const anyDirty = tenantDirty || dirsDirty
|
||||||
|
|
||||||
|
const dirtyLabel = useMemo(() => {
|
||||||
|
if (tenantDirty && dirsDirty) return `${t('settings.unitTenant')} · ${t('settings.unitDirectories')}`
|
||||||
|
if (tenantDirty) return t('settings.unitTenant')
|
||||||
|
if (dirsDirty) return t('settings.unitDirectories')
|
||||||
|
return ''
|
||||||
|
}, [tenantDirty, dirsDirty, t])
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
await api.put(endpoints.annotations.settingsSystem(), system)
|
setSaveError(null)
|
||||||
setSaving(false)
|
try {
|
||||||
|
const tasks: Promise<unknown>[] = []
|
||||||
|
if (tenantDirty && system) tasks.push(api.put(endpoints.annotations.settingsSystem(), system))
|
||||||
|
if (dirsDirty && dirs) tasks.push(api.put(endpoints.annotations.settingsDirectories(), dirs))
|
||||||
|
await Promise.all(tasks)
|
||||||
|
if (system) setSystemInitial(system)
|
||||||
|
if (dirs) setDirsInitial(dirs)
|
||||||
|
} catch {
|
||||||
|
setSaveError(t('settings.saveError'))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveDirs = async () => {
|
const cancel = () => {
|
||||||
if (!dirs) return
|
setSystem(systemInitial)
|
||||||
setSaving(true)
|
setDirs(dirsInitial)
|
||||||
await api.put(endpoints.annotations.settingsDirectories(), dirs)
|
setSaveError(null)
|
||||||
setSaving(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggleDefault = async (a: Aircraft) => {
|
const handleToggleDefault = async (a: Aircraft) => {
|
||||||
await api.patch(endpoints.flights.aircraft(a.id), { isDefault: !a.isDefault })
|
try {
|
||||||
setAircrafts(prev => prev.map(x => x.id === a.id ? { ...x, isDefault: !x.isDefault } : x))
|
await api.patch(endpoints.flights.aircraft(a.id), { isDefault: !a.isDefault })
|
||||||
|
setAircrafts(prev => prev.map(x => x.id === a.id ? { ...x, isDefault: !x.isDefault } : x))
|
||||||
|
} catch {
|
||||||
|
// best-effort — keep UI consistent on failure
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const field = (label: string, value: string | number | null | undefined, onChange: (v: string) => void, type = 'text') => (
|
const changeLanguage = async (next: Lang) => {
|
||||||
|
await i18n.changeLanguage(next)
|
||||||
|
try { localStorage.setItem(LANG_STORAGE_KEY, next) } catch { /* private mode etc. */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSignOutEverywhere = async () => {
|
||||||
|
await logout()
|
||||||
|
navigate('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAircraftModal = () => {
|
||||||
|
setAircraftDraft(NEW_AIRCRAFT_DEFAULTS)
|
||||||
|
setAircraftError(null)
|
||||||
|
setAircraftModalOpen(true)
|
||||||
|
}
|
||||||
|
const closeAircraftModal = () => {
|
||||||
|
if (aircraftSaving) return
|
||||||
|
setAircraftModalOpen(false)
|
||||||
|
}
|
||||||
|
const saveAircraft = async () => {
|
||||||
|
if (!aircraftDraft.model.trim()) { setAircraftError('modelRequired'); return }
|
||||||
|
setAircraftError(null)
|
||||||
|
setAircraftSaving(true)
|
||||||
|
try {
|
||||||
|
const created = await api.post<Aircraft>(endpoints.flights.aircrafts(), aircraftDraft)
|
||||||
|
setAircrafts(prev => {
|
||||||
|
if (created.isDefault) return [...prev.map(p => ({ ...p, isDefault: false })), created]
|
||||||
|
return [...prev, created]
|
||||||
|
})
|
||||||
|
setAircraftModalOpen(false)
|
||||||
|
} catch {
|
||||||
|
setAircraftError('saveFailed')
|
||||||
|
} finally {
|
||||||
|
setAircraftSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="settings-page h-full flex flex-col" style={{ background: 'var(--surface-0)' }}>
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 pt-5 pb-6 flex flex-col gap-5">
|
||||||
|
|
||||||
|
<section className="flex gap-5 items-start flex-wrap">
|
||||||
|
|
||||||
|
<div className="w-[300px] shrink-0">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h2 className="sect-head m-0">{t('settings.tenant')}</h2>
|
||||||
|
<span className="micro">01</span>
|
||||||
|
</div>
|
||||||
|
<BracketPanel className="p-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<FieldText
|
||||||
|
label={t('settings.militaryUnit')}
|
||||||
|
hint={t('settings.required')}
|
||||||
|
value={system?.militaryUnit ?? ''}
|
||||||
|
onChange={v => setSystem(p => p ? { ...p, militaryUnit: v } : p)}
|
||||||
|
/>
|
||||||
|
<FieldText
|
||||||
|
label={t('settings.unitName')}
|
||||||
|
value={system?.name ?? ''}
|
||||||
|
onChange={v => setSystem(p => p ? { ...p, name: v } : p)}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<FieldNumber
|
||||||
|
label={t('settings.camWidth')}
|
||||||
|
hint="PX"
|
||||||
|
suffix="px"
|
||||||
|
value={system?.defaultCameraWidth ?? 0}
|
||||||
|
onChange={v => setSystem(p => p ? { ...p, defaultCameraWidth: v } : p)}
|
||||||
|
/>
|
||||||
|
<FieldNumber
|
||||||
|
label={t('settings.camFoV')}
|
||||||
|
hint="DEG"
|
||||||
|
suffix="°"
|
||||||
|
step="0.1"
|
||||||
|
value={system?.defaultCameraFoV ?? 0}
|
||||||
|
onChange={v => setSystem(p => p ? { ...p, defaultCameraFoV: v } : p)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BracketPanel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-[340px] shrink-0">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h2 className="sect-head m-0">{t('settings.directories')}</h2>
|
||||||
|
<span className="micro">02</span>
|
||||||
|
</div>
|
||||||
|
<BracketPanel className="p-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<PathField
|
||||||
|
label={t('settings.imagesDir')}
|
||||||
|
statusLabel={t('settings.mounted')}
|
||||||
|
statusColor="var(--accent-green)"
|
||||||
|
browseLabel={t('settings.browse')}
|
||||||
|
value={dirs?.imagesDir ?? ''}
|
||||||
|
onChange={v => setDirs(p => p ? { ...p, imagesDir: v } : p)}
|
||||||
|
/>
|
||||||
|
<PathField
|
||||||
|
label={t('settings.labelsDir')}
|
||||||
|
statusLabel={t('settings.mounted')}
|
||||||
|
statusColor="var(--accent-green)"
|
||||||
|
browseLabel={t('settings.browse')}
|
||||||
|
value={dirs?.labelsDir ?? ''}
|
||||||
|
onChange={v => setDirs(p => p ? { ...p, labelsDir: v } : p)}
|
||||||
|
/>
|
||||||
|
<PathField
|
||||||
|
label={t('settings.thumbnailsDir')}
|
||||||
|
statusLabel={t('settings.cache')}
|
||||||
|
statusColor="var(--accent-amber)"
|
||||||
|
browseLabel={t('settings.browse')}
|
||||||
|
value={dirs?.thumbnailsDir ?? ''}
|
||||||
|
onChange={v => setDirs(p => p ? { ...p, thumbnailsDir: v } : p)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="mt-3 pt-3 flex items-center justify-between"
|
||||||
|
style={{ borderTop: '1px solid var(--border-hair)' }}
|
||||||
|
>
|
||||||
|
<span className="micro">{t('settings.storageFree')}</span>
|
||||||
|
<span className="mono tnum" style={{ fontSize: 11, color: 'var(--text-primary)' }}>{DASH}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BracketPanel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-[420px]">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="sect-head m-0">{t('settings.aircrafts')}</h2>
|
||||||
|
<span className="micro">03</span>
|
||||||
|
<span className="mono" style={{ fontSize: 10, color: 'var(--text-muted)' }}>
|
||||||
|
· {aircrafts.length} {t('settings.aircraftsRegistered')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" type="button" onClick={openAircraftModal}>
|
||||||
|
<span style={{ fontSize: 14, lineHeight: 1 }}>+</span>
|
||||||
|
<span>{t('settings.addAircraft')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<BracketPanel className="overflow-hidden">
|
||||||
|
<table className="w-full" style={{ borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: 'var(--surface-1)' }}>
|
||||||
|
<th className="text-left micro" style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-hair)', width: '44%', fontWeight: 500 }}>
|
||||||
|
{t('settings.colModel')}
|
||||||
|
</th>
|
||||||
|
<th className="text-left micro" style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-hair)', fontWeight: 500 }}>
|
||||||
|
{t('settings.colType')}
|
||||||
|
</th>
|
||||||
|
<th className="text-center micro" style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-hair)', width: 96, fontWeight: 500 }}>
|
||||||
|
{t('settings.colDefault')}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{aircrafts.map((a, idx) => (
|
||||||
|
<tr key={a.id} className="row-hover" style={{ borderBottom: idx === aircrafts.length - 1 ? 0 : '1px solid var(--border-hair)' }}>
|
||||||
|
<td className="mono" style={{ padding: '0 14px', height: 38, fontSize: 12, color: 'var(--text-primary)' }}>{a.model}</td>
|
||||||
|
<td style={{ padding: '0 14px', height: 38 }}>
|
||||||
|
<AircraftTypeChip type={a.type} label={t(`admin.aircrafts.${TYPE_LEGEND_KEY[a.type]}`)} />
|
||||||
|
</td>
|
||||||
|
<td className="text-center" style={{ padding: '0 14px', height: 38 }}>
|
||||||
|
<StarButton
|
||||||
|
active={a.isDefault}
|
||||||
|
onClick={() => void handleToggleDefault(a)}
|
||||||
|
aria-label={a.isDefault ? t('admin.aircrafts.default') : t('admin.aircrafts.setDefault')}
|
||||||
|
title={a.isDefault ? t('admin.aircrafts.default') : t('admin.aircrafts.setDefault')}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{aircrafts.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="micro text-center" style={{ padding: '24px 14px' }}>{DASH}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</BracketPanel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex gap-5 items-start flex-wrap">
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-[420px]">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="sect-head m-0">{t('settings.language')}</h2>
|
||||||
|
<span className="micro">04</span>
|
||||||
|
</div>
|
||||||
|
<span className="micro">
|
||||||
|
{t('settings.locale')} · <span style={{ color: 'var(--text-primary)' }}>{lang === 'ua' ? 'UK-UA' : 'EN-US'}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<BracketPanel className="p-4">
|
||||||
|
<div className="flex items-center gap-6 flex-wrap">
|
||||||
|
<div className="seg" role="group" aria-label={t('settings.language')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void changeLanguage('en')}
|
||||||
|
className={`seg-btn${lang === 'en' ? ' active' : ''}`}
|
||||||
|
aria-pressed={lang === 'en'}
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void changeLanguage('ua')}
|
||||||
|
className={`seg-btn${lang === 'ua' ? ' active' : ''}`}
|
||||||
|
aria-pressed={lang === 'ua'}
|
||||||
|
>
|
||||||
|
UA
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="micro">{t('settings.languageHint')}</span>
|
||||||
|
<span className="mono" style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 4 }}>
|
||||||
|
{t('settings.languageNote')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex items-center gap-2 mono" style={{ fontSize: 10, color: 'var(--text-muted)' }}>
|
||||||
|
<span
|
||||||
|
className="dot live"
|
||||||
|
style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: 'var(--accent-green)' }}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{t('settings.languageBundle')}{' '}
|
||||||
|
<span className="tnum" style={{ color: 'var(--text-secondary)' }}>{I18N_BUNDLE_VERSION}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BracketPanel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-[380px] shrink-0">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="sect-head m-0">{t('settings.session')}</h2>
|
||||||
|
<span className="micro">05</span>
|
||||||
|
</div>
|
||||||
|
<span className="micro" style={{ color: 'var(--accent-cyan)' }}>{t('settings.sessionActive')}</span>
|
||||||
|
</div>
|
||||||
|
<BracketPanel className="p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<span className="micro">{t('settings.lastLogin')}</span>
|
||||||
|
<span className="mono tnum" style={{ fontSize: 12, color: 'var(--text-primary)', marginTop: 4 }}>
|
||||||
|
{DASH}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="mono truncate"
|
||||||
|
style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 2 }}
|
||||||
|
>
|
||||||
|
{user?.email ?? DASH}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleSignOutEverywhere()}
|
||||||
|
className="btn btn-danger-ghost shrink-0"
|
||||||
|
>
|
||||||
|
<SignOutIcon />
|
||||||
|
{t('settings.signOutEverywhere')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</BracketPanel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="shrink-0 px-6 pb-6"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(180deg, rgba(10,13,16,0) 0%, var(--surface-0) 50%)',
|
||||||
|
paddingTop: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-4 pt-4"
|
||||||
|
style={{ borderTop: '1px solid var(--border-hair)' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mono uppercase" style={{ fontSize: 10, color: 'var(--text-muted)', letterSpacing: '0.14em' }}>
|
||||||
|
{anyDirty ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="dot live"
|
||||||
|
style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: 'var(--accent-cyan)' }}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{t('settings.unsavedChanges')}{' '}
|
||||||
|
<span style={{ color: 'var(--accent-amber)' }}>{dirtyLabel}</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{saveError && (
|
||||||
|
<div role="alert" className="micro" style={{ color: 'var(--accent-red)', textTransform: 'none', letterSpacing: 0 }}>
|
||||||
|
{saveError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={cancel}
|
||||||
|
disabled={saving || !anyDirty}
|
||||||
|
>
|
||||||
|
{t('settings.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => void save()}
|
||||||
|
disabled={saving || !anyDirty}
|
||||||
|
>
|
||||||
|
<CheckIcon />
|
||||||
|
{t('settings.saveChanges')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={aircraftModalOpen}
|
||||||
|
title={t('admin.aircrafts.addTitle')}
|
||||||
|
onClose={closeAircraftModal}
|
||||||
|
closeLabel={t('admin.classes.cancel')}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={closeAircraftModal}
|
||||||
|
disabled={aircraftSaving}
|
||||||
|
>
|
||||||
|
{t('admin.classes.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => void saveAircraft()}
|
||||||
|
disabled={aircraftSaving}
|
||||||
|
>
|
||||||
|
{t('admin.aircrafts.addTitle')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="micro block mb-1">{t('admin.aircrafts.fieldModel')}</label>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className="inp inp-mono"
|
||||||
|
value={aircraftDraft.model}
|
||||||
|
onChange={e => setAircraftDraft(p => ({ ...p, model: e.target.value }))}
|
||||||
|
placeholder="DJI Mavic 3"
|
||||||
|
aria-label={t('admin.aircrafts.fieldModel')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="micro block mb-1">{t('admin.aircrafts.fieldType')}</label>
|
||||||
|
<div className="seg" role="group" aria-label={t('admin.aircrafts.fieldType')}>
|
||||||
|
{AIRCRAFT_TYPES.map(typ => (
|
||||||
|
<button
|
||||||
|
key={typ}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAircraftDraft(p => ({ ...p, type: typ }))}
|
||||||
|
className={`seg-btn${aircraftDraft.type === typ ? ' active' : ''}`}
|
||||||
|
aria-pressed={aircraftDraft.type === typ}
|
||||||
|
>
|
||||||
|
{t(`admin.aircrafts.${TYPE_LEGEND_KEY[typ]}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="micro block mb-1">{t('admin.aircrafts.fieldResolution')}</label>
|
||||||
|
<select
|
||||||
|
className="inp inp-mono"
|
||||||
|
value={aircraftDraft.resolution}
|
||||||
|
onChange={e => setAircraftDraft(p => ({ ...p, resolution: e.target.value }))}
|
||||||
|
aria-label={t('admin.aircrafts.fieldResolution')}
|
||||||
|
>
|
||||||
|
{RESOLUTIONS.map(r => <option key={r} value={r}>{r}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="micro block mb-1">{t('admin.aircrafts.fieldMaxMinutes')}</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="inp inp-mono"
|
||||||
|
value={aircraftDraft.maxMinutes}
|
||||||
|
onChange={e => setAircraftDraft(p => ({ ...p, maxMinutes: Number(e.target.value) }))}
|
||||||
|
style={{ textAlign: 'right' }}
|
||||||
|
aria-label={t('admin.aircrafts.fieldMaxMinutes')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="checkbox-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox"
|
||||||
|
checked={aircraftDraft.isDefault}
|
||||||
|
onChange={e => setAircraftDraft(p => ({ ...p, isDefault: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
<span>{t('admin.aircrafts.fieldDefault')}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{aircraftError && (
|
||||||
|
<div role="alert" style={{ color: 'var(--accent-red)', fontSize: 11 }}>
|
||||||
|
{t(`admin.aircrafts.${aircraftError}`)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Sub-components =====
|
||||||
|
|
||||||
|
function BracketPanel({ className, children }: { className?: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className={className ? `bracket panel ${className}` : 'bracket panel'}>
|
||||||
|
<span className="br" />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLabel({ label, hint, hintColor }: { label: string; hint?: string; hintColor?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<label className="micro">{label}</label>
|
||||||
|
{hint && (
|
||||||
|
<span className="mono" style={{ fontSize: 9, color: hintColor ?? 'var(--text-muted)' }}>{hint}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldText({
|
||||||
|
label, hint, value, onChange,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
hint?: string
|
||||||
|
value: string
|
||||||
|
onChange: (v: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-az-muted text-xs block mb-0.5">{label}</label>
|
<FieldLabel label={label} hint={hint} />
|
||||||
<input
|
<input
|
||||||
type={type}
|
className="inp"
|
||||||
value={value ?? ''}
|
type="text"
|
||||||
|
value={value}
|
||||||
onChange={e => onChange(e.target.value)}
|
onChange={e => onChange(e.target.value)}
|
||||||
className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text outline-none focus:border-az-orange"
|
aria-label={label}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldNumber({
|
||||||
|
label, hint, suffix, value, onChange, step,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
hint?: string
|
||||||
|
suffix: string
|
||||||
|
value: number
|
||||||
|
onChange: (v: number) => void
|
||||||
|
step?: string
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full overflow-y-auto p-4 gap-6">
|
<div>
|
||||||
{/* Tenant config */}
|
<FieldLabel label={label} hint={hint} />
|
||||||
<div className="w-[300px] shrink-0">
|
<div className="relative">
|
||||||
<h2 className="text-sm font-semibold text-white mb-2">{t('settings.tenant')}</h2>
|
<input
|
||||||
{system && (
|
className="inp inp-mono"
|
||||||
<div className="bg-az-panel border border-az-border rounded p-3 space-y-2">
|
type="number"
|
||||||
{field('Military Unit', system.militaryUnit, v => setSystem(p => p ? { ...p, militaryUnit: v } : p))}
|
step={step}
|
||||||
{field('Name', system.name, v => setSystem(p => p ? { ...p, name: v } : p))}
|
value={value}
|
||||||
{field('Default Camera Width', system.defaultCameraWidth, v => setSystem(p => p ? { ...p, defaultCameraWidth: parseInt(v) || 0 } : p), 'number')}
|
onChange={e => onChange(step ? parseFloat(e.target.value) || 0 : parseInt(e.target.value) || 0)}
|
||||||
{field('Default Camera FoV', system.defaultCameraFoV, v => setSystem(p => p ? { ...p, defaultCameraFoV: parseFloat(v) || 0 } : p), 'number')}
|
aria-label={label}
|
||||||
<button onClick={saveSystem} disabled={saving} className="bg-az-orange text-white text-xs px-3 py-1 rounded disabled:opacity-50">
|
style={{ paddingRight: 36 }}
|
||||||
{t('settings.save')}
|
/>
|
||||||
</button>
|
<span
|
||||||
</div>
|
className="mono"
|
||||||
)}
|
style={{
|
||||||
</div>
|
position: 'absolute', right: 10, top: '50%', transform: 'translateY(-50%)',
|
||||||
|
fontSize: 11, color: 'var(--text-muted)', pointerEvents: 'none',
|
||||||
{/* Directories */}
|
}}
|
||||||
<div className="w-[300px] shrink-0">
|
>
|
||||||
<h2 className="text-sm font-semibold text-white mb-2">{t('settings.directories')}</h2>
|
{suffix}
|
||||||
{dirs && (
|
</span>
|
||||||
<div className="bg-az-panel border border-az-border rounded p-3 space-y-2">
|
|
||||||
{field('Videos Dir', dirs.videosDir, v => setDirs(p => p ? { ...p, videosDir: v } : p))}
|
|
||||||
{field('Images Dir', dirs.imagesDir, v => setDirs(p => p ? { ...p, imagesDir: v } : p))}
|
|
||||||
{field('Labels Dir', dirs.labelsDir, v => setDirs(p => p ? { ...p, labelsDir: v } : p))}
|
|
||||||
{field('Results Dir', dirs.resultsDir, v => setDirs(p => p ? { ...p, resultsDir: v } : p))}
|
|
||||||
{field('Thumbnails Dir', dirs.thumbnailsDir, v => setDirs(p => p ? { ...p, thumbnailsDir: v } : p))}
|
|
||||||
{field('GPS Sat Dir', dirs.gpsSatDir, v => setDirs(p => p ? { ...p, gpsSatDir: v } : p))}
|
|
||||||
{field('GPS Route Dir', dirs.gpsRouteDir, v => setDirs(p => p ? { ...p, gpsRouteDir: v } : p))}
|
|
||||||
<button onClick={saveDirs} disabled={saving} className="bg-az-orange text-white text-xs px-3 py-1 rounded disabled:opacity-50">
|
|
||||||
{t('settings.save')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Aircrafts */}
|
|
||||||
<div className="flex-1 max-w-sm">
|
|
||||||
<h2 className="text-sm font-semibold text-white mb-2">{t('settings.aircrafts')}</h2>
|
|
||||||
<div className="bg-az-panel border border-az-border rounded p-2 space-y-1">
|
|
||||||
{aircrafts.map(a => (
|
|
||||||
<div key={a.id} className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-az-bg text-xs text-az-text">
|
|
||||||
<span className="flex-1">{a.model}</span>
|
|
||||||
<span className={`px-1 rounded text-[10px] ${a.type === 'Plane' ? 'bg-az-blue/20 text-az-blue' : 'bg-az-green/20 text-az-green'}`}>
|
|
||||||
{a.type}
|
|
||||||
</span>
|
|
||||||
<button onClick={() => handleToggleDefault(a)} className={`text-sm ${a.isDefault ? 'text-az-orange' : 'text-az-muted hover:text-az-orange'}`}>
|
|
||||||
★
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PathField({
|
||||||
|
label, statusLabel, statusColor, browseLabel, value, onChange,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
statusLabel: string
|
||||||
|
statusColor: string
|
||||||
|
browseLabel: string
|
||||||
|
value: string
|
||||||
|
onChange: (v: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<FieldLabel label={label} hint={statusLabel} hintColor={statusColor} />
|
||||||
|
<div className="path-wrap">
|
||||||
|
<span className="path-icon"><FolderIcon /></span>
|
||||||
|
<input
|
||||||
|
className="inp inp-mono"
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={e => onChange(e.target.value)}
|
||||||
|
aria-label={label}
|
||||||
|
/>
|
||||||
|
<button type="button" aria-label={browseLabel} className="browse">
|
||||||
|
{browseLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AircraftTypeChip({ type, label }: { type: Aircraft['type']; label: string }) {
|
||||||
|
const color = TYPE_CHIP_COLOR[type]
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 mono uppercase"
|
||||||
|
style={{
|
||||||
|
fontSize: 10, letterSpacing: '0.12em',
|
||||||
|
padding: '2px 8px', borderRadius: 2,
|
||||||
|
border: `1px solid ${TYPE_CHIP_BORDER[type]}`,
|
||||||
|
color, background: 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ width: 6, height: 6, borderRadius: '50%', background: color, display: 'inline-block' }} />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StarButton({
|
||||||
|
active, onClick, ...rest
|
||||||
|
}: {
|
||||||
|
active: boolean
|
||||||
|
onClick: () => void
|
||||||
|
} & React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={active ? 'star active' : 'star'}
|
||||||
|
aria-pressed={active}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{active ? '★' : '☆'}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
+65
-4
@@ -85,18 +85,47 @@
|
|||||||
},
|
},
|
||||||
"annotations": {
|
"annotations": {
|
||||||
"title": "Annotations",
|
"title": "Annotations",
|
||||||
"mediaList": "Media",
|
"mediaList": "Media Files",
|
||||||
|
"filterByName": "filter by name…",
|
||||||
"upload": "Upload Files",
|
"upload": "Upload Files",
|
||||||
"deleteMedia": "Delete media?",
|
"deleteMedia": "Delete media?",
|
||||||
"detect": "AI Detect",
|
"detect": "AI Detect",
|
||||||
|
"detectInProgress": "AI DETECTION IN PROGRESS",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"deleteAll": "Delete All",
|
"deleteAll": "Delete All",
|
||||||
|
"deleteAllTitle": "Delete all on frame",
|
||||||
"classes": "Detection Classes",
|
"classes": "Detection Classes",
|
||||||
"photoMode": "Photo Mode",
|
"photoMode": "PhotoMode",
|
||||||
"regular": "Regular",
|
"regular": "Regular",
|
||||||
"winter": "Winter",
|
"winter": "Winter",
|
||||||
"night": "Night"
|
"night": "Night",
|
||||||
|
"colName": "NAME",
|
||||||
|
"colKey": "KEY",
|
||||||
|
"colNum": "#",
|
||||||
|
"colTime": "TIME",
|
||||||
|
"colClass": "CLASS",
|
||||||
|
"colConf": "CONF",
|
||||||
|
"canvas": "Canvas",
|
||||||
|
"zoom": "ZOOM",
|
||||||
|
"cursor": "CURSOR",
|
||||||
|
"frameStep": "FRAME STEP",
|
||||||
|
"frame": "FRAME",
|
||||||
|
"summary": "SUMMARY",
|
||||||
|
"emptyFrame": "empty frame",
|
||||||
|
"filter": "Filter",
|
||||||
|
"sort": "Sort",
|
||||||
|
"play": "Play",
|
||||||
|
"pause": "Pause",
|
||||||
|
"previousMedia": "Previous media",
|
||||||
|
"nextMedia": "Next media",
|
||||||
|
"back5s": "Back 5s",
|
||||||
|
"forward5s": "Forward 5s",
|
||||||
|
"mute": "Mute",
|
||||||
|
"selectMedia": "Select a media file to start",
|
||||||
|
"annCount_one": "{{count}} ann",
|
||||||
|
"annCount_other": "{{count}} ann",
|
||||||
|
"emptyCount": "{{count}} empty"
|
||||||
},
|
},
|
||||||
"dataset": {
|
"dataset": {
|
||||||
"title": "Dataset Explorer",
|
"title": "Dataset Explorer",
|
||||||
@@ -189,7 +218,39 @@
|
|||||||
"tenant": "Tenant Configuration",
|
"tenant": "Tenant Configuration",
|
||||||
"directories": "Directories",
|
"directories": "Directories",
|
||||||
"aircrafts": "Aircrafts",
|
"aircrafts": "Aircrafts",
|
||||||
"save": "Save"
|
"save": "Save",
|
||||||
|
"militaryUnit": "Military Unit",
|
||||||
|
"unitName": "Name",
|
||||||
|
"camWidth": "Cam Width",
|
||||||
|
"camFoV": "Cam FoV",
|
||||||
|
"required": "REQ",
|
||||||
|
"imagesDir": "Images Dir",
|
||||||
|
"labelsDir": "Labels Dir",
|
||||||
|
"thumbnailsDir": "Thumbnails Dir",
|
||||||
|
"mounted": "MOUNTED",
|
||||||
|
"cache": "CACHE",
|
||||||
|
"browse": "Browse",
|
||||||
|
"storageFree": "Storage Free",
|
||||||
|
"aircraftsRegistered": "REGISTERED",
|
||||||
|
"addAircraft": "Add Aircraft",
|
||||||
|
"colModel": "Model",
|
||||||
|
"colType": "Type",
|
||||||
|
"colDefault": "Default",
|
||||||
|
"language": "Language",
|
||||||
|
"languageHint": "Affects all UI text",
|
||||||
|
"languageNote": "Detection class names also use the localized field from seed data.",
|
||||||
|
"languageBundle": "i18n BUNDLE",
|
||||||
|
"locale": "Locale",
|
||||||
|
"session": "Session",
|
||||||
|
"sessionActive": "ACTIVE",
|
||||||
|
"lastLogin": "Last Login",
|
||||||
|
"signOutEverywhere": "Sign out everywhere",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"saveChanges": "Save Changes",
|
||||||
|
"saveError": "Save failed. Please try again.",
|
||||||
|
"unsavedChanges": "Unsaved changes detected in",
|
||||||
|
"unitTenant": "TENANT",
|
||||||
|
"unitDirectories": "DIRECTORIES"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
|
|||||||
+13
-1
@@ -3,9 +3,21 @@ import { initReactI18next } from 'react-i18next'
|
|||||||
import en from './en.json'
|
import en from './en.json'
|
||||||
import ua from './ua.json'
|
import ua from './ua.json'
|
||||||
|
|
||||||
|
export const LANG_STORAGE_KEY = 'azaion.lang'
|
||||||
|
|
||||||
|
function readPersistedLanguage(): 'en' | 'ua' {
|
||||||
|
// Safari private mode throws on localStorage access — fall back to 'en'.
|
||||||
|
try {
|
||||||
|
const persisted = localStorage.getItem(LANG_STORAGE_KEY)
|
||||||
|
return persisted === 'ua' || persisted === 'en' ? persisted : 'en'
|
||||||
|
} catch {
|
||||||
|
return 'en'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
i18n.use(initReactI18next).init({
|
i18n.use(initReactI18next).init({
|
||||||
resources: { en: { translation: en }, ua: { translation: ua } },
|
resources: { en: { translation: en }, ua: { translation: ua } },
|
||||||
lng: 'en',
|
lng: readPersistedLanguage(),
|
||||||
fallbackLng: 'en',
|
fallbackLng: 'en',
|
||||||
interpolation: { escapeValue: false },
|
interpolation: { escapeValue: false },
|
||||||
})
|
})
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
export { default } from './i18n'
|
export { default, LANG_STORAGE_KEY } from './i18n'
|
||||||
|
|||||||
+66
-3
@@ -85,18 +85,49 @@
|
|||||||
},
|
},
|
||||||
"annotations": {
|
"annotations": {
|
||||||
"title": "Анотації",
|
"title": "Анотації",
|
||||||
"mediaList": "Медіа",
|
"mediaList": "Медіа файли",
|
||||||
|
"filterByName": "фільтр за назвою…",
|
||||||
"upload": "Завантажити файли",
|
"upload": "Завантажити файли",
|
||||||
"deleteMedia": "Видалити медіа?",
|
"deleteMedia": "Видалити медіа?",
|
||||||
"detect": "AI Розпізнавання",
|
"detect": "AI Розпізнавання",
|
||||||
|
"detectInProgress": "AI РОЗПІЗНАВАННЯ ТРИВАЄ",
|
||||||
"save": "Зберегти",
|
"save": "Зберегти",
|
||||||
"delete": "Видалити",
|
"delete": "Видалити",
|
||||||
"deleteAll": "Видалити все",
|
"deleteAll": "Видалити все",
|
||||||
|
"deleteAllTitle": "Видалити все на кадрі",
|
||||||
"classes": "Класи детекцій",
|
"classes": "Класи детекцій",
|
||||||
"photoMode": "Режим фото",
|
"photoMode": "Режим фото",
|
||||||
"regular": "Звичайний",
|
"regular": "Звичайний",
|
||||||
"winter": "Зимовий",
|
"winter": "Зимовий",
|
||||||
"night": "Нічний"
|
"night": "Нічний",
|
||||||
|
"colName": "НАЗВА",
|
||||||
|
"colKey": "КЛВ",
|
||||||
|
"colNum": "№",
|
||||||
|
"colTime": "ЧАС",
|
||||||
|
"colClass": "КЛАС",
|
||||||
|
"colConf": "ВПЕВ",
|
||||||
|
"canvas": "Канва",
|
||||||
|
"zoom": "ЗУМ",
|
||||||
|
"cursor": "КУРСОР",
|
||||||
|
"frameStep": "КРОК КАДРУ",
|
||||||
|
"frame": "КАДР",
|
||||||
|
"summary": "ПІДСУМОК",
|
||||||
|
"emptyFrame": "порожній кадр",
|
||||||
|
"filter": "Фільтр",
|
||||||
|
"sort": "Сортувати",
|
||||||
|
"play": "Програти",
|
||||||
|
"pause": "Пауза",
|
||||||
|
"previousMedia": "Попереднє медіа",
|
||||||
|
"nextMedia": "Наступне медіа",
|
||||||
|
"back5s": "Назад 5с",
|
||||||
|
"forward5s": "Вперед 5с",
|
||||||
|
"mute": "Без звуку",
|
||||||
|
"selectMedia": "Оберіть файл медіа щоб почати",
|
||||||
|
"annCount_one": "{{count}} анот.",
|
||||||
|
"annCount_few": "{{count}} анот.",
|
||||||
|
"annCount_many": "{{count}} анот.",
|
||||||
|
"annCount_other": "{{count}} анот.",
|
||||||
|
"emptyCount": "{{count}} порожн."
|
||||||
},
|
},
|
||||||
"dataset": {
|
"dataset": {
|
||||||
"title": "Датасет",
|
"title": "Датасет",
|
||||||
@@ -189,7 +220,39 @@
|
|||||||
"tenant": "Конфігурація",
|
"tenant": "Конфігурація",
|
||||||
"directories": "Директорії",
|
"directories": "Директорії",
|
||||||
"aircrafts": "Літальні апарати",
|
"aircrafts": "Літальні апарати",
|
||||||
"save": "Зберегти"
|
"save": "Зберегти",
|
||||||
|
"militaryUnit": "Військова частина",
|
||||||
|
"unitName": "Назва",
|
||||||
|
"camWidth": "Ширина кадру",
|
||||||
|
"camFoV": "Кут огляду",
|
||||||
|
"required": "ОБ.",
|
||||||
|
"imagesDir": "Директорія зображень",
|
||||||
|
"labelsDir": "Директорія міток",
|
||||||
|
"thumbnailsDir": "Директорія мініатюр",
|
||||||
|
"mounted": "ПІД'ЄДНАНО",
|
||||||
|
"cache": "КЕШ",
|
||||||
|
"browse": "Огляд",
|
||||||
|
"storageFree": "Вільно",
|
||||||
|
"aircraftsRegistered": "ЗАРЕЄСТРОВАНО",
|
||||||
|
"addAircraft": "Додати апарат",
|
||||||
|
"colModel": "Модель",
|
||||||
|
"colType": "Тип",
|
||||||
|
"colDefault": "За замовч.",
|
||||||
|
"language": "Мова",
|
||||||
|
"languageHint": "Впливає на весь UI",
|
||||||
|
"languageNote": "Назви класів детекцій теж беруться з локалізованого поля seed-даних.",
|
||||||
|
"languageBundle": "i18n БАНДЛ",
|
||||||
|
"locale": "Локаль",
|
||||||
|
"session": "Сесія",
|
||||||
|
"sessionActive": "АКТИВНА",
|
||||||
|
"lastLogin": "Останній вхід",
|
||||||
|
"signOutEverywhere": "Вийти всюди",
|
||||||
|
"cancel": "Скасувати",
|
||||||
|
"saveChanges": "Зберегти зміни",
|
||||||
|
"saveError": "Не вдалося зберегти. Спробуйте ще раз.",
|
||||||
|
"unsavedChanges": "Незбережені зміни в",
|
||||||
|
"unitTenant": "КОНФІГУРАЦІЇ",
|
||||||
|
"unitDirectories": "ДИРЕКТОРІЯХ"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"confirm": "Підтвердити",
|
"confirm": "Підтвердити",
|
||||||
|
|||||||
+258
-1
@@ -209,6 +209,15 @@ body {
|
|||||||
border-color: var(--border-hair);
|
border-color: var(--border-hair);
|
||||||
}
|
}
|
||||||
.btn-ghost:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-raised); }
|
.btn-ghost:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-raised); }
|
||||||
|
.btn-danger-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent-red);
|
||||||
|
border-color: rgba(255,71,86,0.5);
|
||||||
|
}
|
||||||
|
.btn-danger-ghost:hover:not(:disabled) {
|
||||||
|
background: rgba(255,71,86,0.08);
|
||||||
|
border-color: var(--accent-red);
|
||||||
|
}
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
background: var(--accent-red);
|
background: var(--accent-red);
|
||||||
color: #0A0D10;
|
color: #0A0D10;
|
||||||
@@ -336,10 +345,79 @@ header .ibtn.danger:hover { color: var(--accent-red); border-color: var(--accent
|
|||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Settings v2 — settings.html mock specs larger buttons than admin.html mock.
|
||||||
|
Scope to the Settings page only so Admin keeps its tighter spec.
|
||||||
|
line-height: 1.5 matches the mock's body inheritance (its .btn doesn't use
|
||||||
|
the font shorthand, so it inherits body's line-height instead of "normal"). */
|
||||||
|
.settings-page .btn {
|
||||||
|
height: auto;
|
||||||
|
padding: 7px 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.5;
|
||||||
|
letter-spacing: 0.10em;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.settings-page .seg-btn {
|
||||||
|
height: auto;
|
||||||
|
padding: 7px 18px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.settings-page .seg-btn.active { font-weight: 600; border-right: 0; }
|
||||||
|
.settings-page .seg-btn + .seg-btn { border-left: 1px solid var(--border-hair); }
|
||||||
|
.settings-page .btn-primary:hover:not(:disabled) { filter: brightness(1.05); }
|
||||||
|
|
||||||
/* Star button */
|
/* Star button */
|
||||||
.star { color: var(--accent-amber); }
|
.star {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 4px;
|
||||||
|
transition: color .12s, transform .12s;
|
||||||
|
}
|
||||||
|
.star:hover { color: var(--accent-amber); }
|
||||||
|
.star.active { color: var(--accent-amber); }
|
||||||
.star-off { color: var(--text-muted); }
|
.star-off { color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Path input with Browse button (Settings v2 directories panel). */
|
||||||
|
.path-wrap { position: relative; display: flex; align-items: center; }
|
||||||
|
.path-wrap .path-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.path-wrap .browse {
|
||||||
|
position: absolute;
|
||||||
|
right: 4px;
|
||||||
|
top: 4px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-hair);
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color .12s, border-color .12s, background .12s;
|
||||||
|
}
|
||||||
|
.path-wrap .browse:hover {
|
||||||
|
color: var(--accent-amber);
|
||||||
|
border-color: var(--accent-amber);
|
||||||
|
background: rgba(255,157,61,0.06);
|
||||||
|
}
|
||||||
|
.path-wrap > input.inp { padding-left: 30px; padding-right: 70px; }
|
||||||
|
|
||||||
/* Pulse for live dot */
|
/* Pulse for live dot */
|
||||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
|
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
|
||||||
.live { animation: pulse 1.6s ease-in-out infinite; }
|
.live { animation: pulse 1.6s ease-in-out infinite; }
|
||||||
@@ -366,3 +444,182 @@ select.inp {
|
|||||||
::-webkit-scrollbar-track { background: var(--surface-0); }
|
::-webkit-scrollbar-track { background: var(--surface-0); }
|
||||||
::-webkit-scrollbar-thumb { background: #1f2630; border-radius: 2px; }
|
::-webkit-scrollbar-thumb { background: #1f2630; border-radius: 2px; }
|
||||||
::-webkit-scrollbar-thumb:hover { background: #2a323e; }
|
::-webkit-scrollbar-thumb:hover { background: #2a323e; }
|
||||||
|
|
||||||
|
/* =========================================================================
|
||||||
|
ANNOTATIONS PAGE — v2 surfaces
|
||||||
|
========================================================================= */
|
||||||
|
|
||||||
|
/* Splitter affordance between resizable panes */
|
||||||
|
.split { width: 4px; cursor: col-resize; background: transparent; position: relative; }
|
||||||
|
.split::after {
|
||||||
|
content: ''; position: absolute; left: 1px; top: 0; bottom: 0; width: 1px;
|
||||||
|
background: var(--border-hair);
|
||||||
|
}
|
||||||
|
.split:hover::after { background: var(--accent-amber); }
|
||||||
|
|
||||||
|
/* Media list row (264px left aside) */
|
||||||
|
.media-row {
|
||||||
|
position: relative;
|
||||||
|
display: grid; grid-template-columns: 44px 1fr auto; gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
height: 32px; padding: 0 12px 0 14px;
|
||||||
|
border-bottom: 1px solid var(--border-hair);
|
||||||
|
cursor: pointer; user-select: none;
|
||||||
|
}
|
||||||
|
.media-row:hover { background: var(--surface-2); }
|
||||||
|
.media-row.active { background: var(--surface-2); }
|
||||||
|
.media-row.active::before {
|
||||||
|
content: ''; position: absolute; left: 0; top: 0; bottom: 0;
|
||||||
|
width: 2px; background: var(--accent-amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Type chips inside media rows */
|
||||||
|
.chip-photo {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 40px; height: 16px; border-radius: 2px;
|
||||||
|
font: 600 9px/1 'JetBrains Mono', monospace; letter-spacing: 0.1em;
|
||||||
|
color: var(--accent-cyan); border: 1px solid rgba(54,214,197,0.45);
|
||||||
|
background: rgba(54,214,197,0.06);
|
||||||
|
}
|
||||||
|
.chip-video {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 40px; height: 16px; border-radius: 2px;
|
||||||
|
font: 600 9px/1 'JetBrains Mono', monospace; letter-spacing: 0.1em;
|
||||||
|
color: var(--accent-amber); border: 1px solid rgba(255,157,61,0.45);
|
||||||
|
background: rgba(255,157,61,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detection class row */
|
||||||
|
.class-row {
|
||||||
|
display: grid; grid-template-columns: 16px 1fr auto; gap: 10px;
|
||||||
|
align-items: center; height: 28px; padding: 0 12px;
|
||||||
|
border-bottom: 1px solid var(--border-hair);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.class-row:hover { background: var(--surface-2); }
|
||||||
|
.class-row.active { background: var(--surface-2); }
|
||||||
|
.class-row.active .kbd { color: var(--accent-amber); border-color: var(--accent-amber); }
|
||||||
|
|
||||||
|
/* Keycap chip */
|
||||||
|
.kbd {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 18px; height: 16px; padding: 0;
|
||||||
|
font: 600 10px/1 'JetBrains Mono', monospace;
|
||||||
|
color: var(--text-muted); border: 1px solid var(--border-hair); border-radius: 2px;
|
||||||
|
background: var(--surface-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Annotation row in right sidebar (gradient stripe via --row-grad) */
|
||||||
|
.ann-row {
|
||||||
|
position: relative;
|
||||||
|
display: grid; grid-template-columns: 44px 1fr auto; gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
height: 36px; padding: 0 12px;
|
||||||
|
border-bottom: 1px solid var(--border-hair);
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: var(--surface-1);
|
||||||
|
}
|
||||||
|
.ann-row::after {
|
||||||
|
content: ''; position: absolute; left: 0; right: 0; top: 0; bottom: 0;
|
||||||
|
background-image: var(--row-grad, none);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.ann-row > * { position: relative; z-index: 1; }
|
||||||
|
.ann-row:hover { background-color: var(--surface-2); }
|
||||||
|
.ann-row.active { background-color: var(--surface-2); }
|
||||||
|
|
||||||
|
/* Faux terrain wash behind canvas */
|
||||||
|
.terrain {
|
||||||
|
background-color: #11181B;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(900px 500px at 30% 40%, rgba(48,72,60,0.45), transparent 60%),
|
||||||
|
radial-gradient(700px 400px at 75% 65%, rgba(40,52,68,0.35), transparent 65%),
|
||||||
|
radial-gradient(400px 300px at 60% 30%, rgba(82,64,40,0.18), transparent 70%),
|
||||||
|
linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px);
|
||||||
|
background-size: auto, auto, auto, 48px 48px, 48px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floating AI Detection banner over canvas */
|
||||||
|
.ai-banner {
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
background: rgba(10,13,16,0.78);
|
||||||
|
border: 1px solid rgba(54,214,197,0.4);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bounding-box label chip (DOM overlay on canvas) */
|
||||||
|
.bbox-label {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
height: 22px; padding: 0 8px;
|
||||||
|
font: 600 10px/1 'JetBrains Mono', monospace; letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: rgba(10,13,16,0.92);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-hair);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.bbox-label .conf { color: var(--text-secondary); font-weight: 500; }
|
||||||
|
|
||||||
|
/* Selection handles on bounding boxes */
|
||||||
|
.handle {
|
||||||
|
position: absolute; width: 6px; height: 6px;
|
||||||
|
background: var(--accent-amber); border: 1px solid #0A0D10;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrubber (timeline with annotation marks) */
|
||||||
|
.scrub {
|
||||||
|
height: 4px; background: var(--surface-2); border: 1px solid var(--border-hair);
|
||||||
|
border-radius: 2px; position: relative; cursor: pointer;
|
||||||
|
}
|
||||||
|
.scrub .fill { position: absolute; left: 0; top: 0; bottom: 0; background: var(--accent-amber); pointer-events: none; }
|
||||||
|
.scrub .head {
|
||||||
|
position: absolute; top: 50%; width: 2px; height: 10px; background: var(--accent-amber);
|
||||||
|
transform: translate(-50%, -50%); pointer-events: none;
|
||||||
|
}
|
||||||
|
.scrub .head-knob {
|
||||||
|
position: absolute; top: 50%; width: 12px; height: 12px;
|
||||||
|
background: var(--accent-amber);
|
||||||
|
border: 2px solid var(--surface-1);
|
||||||
|
border-radius: 999px;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
box-shadow: 0 0 0 1px var(--accent-amber), 0 0 8px rgba(255,157,61,0.45);
|
||||||
|
z-index: 2;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.scrub .head-knob:active { cursor: grabbing; }
|
||||||
|
.scrub .tick {
|
||||||
|
position: absolute; top: 50%; width: 1px; height: 6px; background: var(--text-muted);
|
||||||
|
transform: translateY(-50%); pointer-events: none;
|
||||||
|
}
|
||||||
|
.scrub .mark {
|
||||||
|
position: absolute; top: -3px; width: 2px; height: 10px; pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Volume slider (range input next to mute) */
|
||||||
|
.vol {
|
||||||
|
appearance: none; -webkit-appearance: none;
|
||||||
|
height: 2px; width: 72px; background: var(--border-hair); outline: none; border-radius: 2px;
|
||||||
|
}
|
||||||
|
.vol::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none; appearance: none;
|
||||||
|
width: 10px; height: 10px; background: var(--accent-amber); border-radius: 0; cursor: pointer;
|
||||||
|
}
|
||||||
|
.vol::-moz-range-thumb {
|
||||||
|
width: 10px; height: 10px; background: var(--accent-amber); border-radius: 0; cursor: pointer; border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Live pulse dot (cyan glow) — annotations LIVE indicators */
|
||||||
|
.live-dot {
|
||||||
|
width: 6px; height: 6px; border-radius: 999px;
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
box-shadow: 0 0 0 0 rgba(54,214,197,0.5);
|
||||||
|
animation: live-pulse 1.6s ease-in-out infinite;
|
||||||
|
display: inline-block; flex: none;
|
||||||
|
}
|
||||||
|
@keyframes live-pulse {
|
||||||
|
0%,100% { box-shadow: 0 0 0 0 rgba(54,214,197,0.5); }
|
||||||
|
50% { box-shadow: 0 0 0 6px rgba(54,214,197,0); }
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+2
@@ -8,6 +8,8 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_OWM_API_KEY?: string
|
readonly VITE_OWM_API_KEY?: string
|
||||||
readonly VITE_OWM_BASE_URL?: string
|
readonly VITE_OWM_BASE_URL?: string
|
||||||
readonly VITE_SATELLITE_TILE_URL?: string
|
readonly VITE_SATELLITE_TILE_URL?: string
|
||||||
|
/** Dev-only: when 'true', skip backend auth and inject a fake admin user. */
|
||||||
|
readonly VITE_DEV_AUTH_BYPASS?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|||||||
import { http, HttpResponse } from 'msw'
|
import { http, HttpResponse } from 'msw'
|
||||||
import { server } from './msw/server'
|
import { server } from './msw/server'
|
||||||
import { jsonResponse } from './msw/helpers'
|
import { jsonResponse } from './msw/helpers'
|
||||||
import { renderWithProviders, screen, waitFor, userEvent, within } from './helpers/render'
|
import { renderWithProviders, screen, waitFor, userEvent } from './helpers/render'
|
||||||
import { seedBearer, clearBearer } from './helpers/auth'
|
import { seedBearer, clearBearer } from './helpers/auth'
|
||||||
import { SettingsPage } from '../src/features/settings'
|
import { SettingsPage } from '../src/features/settings'
|
||||||
import { seedAircraft } from './fixtures/seed_aircraft'
|
import { seedAircraft } from './fixtures/seed_aircraft'
|
||||||
@@ -18,16 +18,9 @@ import type { SystemSettings, DirectorySettings } from '../src/types'
|
|||||||
// AC-3 (NFT-PERF-09) — Deadline: wall-clock from PUT response/error
|
// AC-3 (NFT-PERF-09) — Deadline: wall-clock from PUT response/error
|
||||||
// to error visibility ≤ 2 s.
|
// to error visibility ≤ 2 s.
|
||||||
//
|
//
|
||||||
// Production today (`SettingsPage.saveSystem` / `saveDirs`) does
|
// v2 SettingsPage wraps `save()` in try/catch/finally and renders an inline
|
||||||
// setSaving(true); await api.put(...); setSaving(false)
|
// role="alert" in the sticky footer when the PUT rejects. The three contract
|
||||||
// with no try/finally and no error region in the JSX. Both AC-1 and AC-2 are
|
// tests below assert that wiring directly.
|
||||||
// drift today: the button stays disabled forever and no alert appears. The
|
|
||||||
// AC-3 deadline assertion is also vacuously failing (no DOM element to find).
|
|
||||||
// We mark the contract assertions `it.fails()` and pin the current drift with
|
|
||||||
// control tests, so:
|
|
||||||
// - The drift is documented in the test suite.
|
|
||||||
// - The contract tests will start passing the moment SettingsPage wires
|
|
||||||
// try/finally + an error region — no edits to this file required.
|
|
||||||
|
|
||||||
const SYSTEM_SEED: SystemSettings = {
|
const SYSTEM_SEED: SystemSettings = {
|
||||||
id: 'sys-1',
|
id: 'sys-1',
|
||||||
@@ -84,163 +77,93 @@ function rigSettingsEnv(failure: SettingsFailure): SettingsRig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SettingsPage renders two "Save" buttons (one per panel) once both GETs
|
* SettingsPage (v2) renders a single sticky-footer "Save Changes" button that
|
||||||
* resolve. We always exercise the *system* panel — its handler (`saveSystem`)
|
* persists whichever panels are dirty in parallel. The footer button is the
|
||||||
* has the same try-finally drift as `saveDirs`, and scoping the query to
|
* only Save affordance; per-panel Save buttons no longer exist. We must mark
|
||||||
* "Tenant Configuration" makes the selector unambiguous regardless of which
|
* the Tenant panel as dirty by editing a field before the footer button
|
||||||
* GET resolves first.
|
* becomes enabled — selecting the Military Unit input by accessible name and
|
||||||
|
* typing a single character is enough to flip the dirty flag.
|
||||||
*/
|
*/
|
||||||
async function findSystemSaveButton(): Promise<HTMLElement> {
|
async function findSystemSaveButton(): Promise<HTMLElement> {
|
||||||
const systemHeading = await screen.findByRole('heading', { name: /Tenant Configuration/i })
|
// Wait until the data has loaded (heading is present immediately, but the
|
||||||
const panel = systemHeading.parentElement as HTMLElement
|
// input is rendered only after the GET resolves).
|
||||||
return within(panel).getByRole('button', { name: /^Save$/i })
|
await screen.findByRole('heading', { name: /Tenant Configuration/i })
|
||||||
|
return screen.getByRole('button', { name: /^Save Changes$/i })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeTenantDirty(): Promise<void> {
|
||||||
|
const militaryUnit = await screen.findByLabelText(/Military Unit/i)
|
||||||
|
await userEvent.type(militaryUnit, '!')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderAndClickSave(): Promise<void> {
|
async function renderAndClickSave(): Promise<void> {
|
||||||
renderWithProviders(<SettingsPage />)
|
renderWithProviders(<SettingsPage />)
|
||||||
|
await makeTenantDirty()
|
||||||
const saveButton = await findSystemSaveButton()
|
const saveButton = await findSystemSaveButton()
|
||||||
await userEvent.click(saveButton)
|
await userEvent.click(saveButton)
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('AZ-477 — Settings save resilience + 2 s error budget', () => {
|
describe('AZ-477 — Settings save resilience + 2 s error budget', () => {
|
||||||
// Production today has no try/catch around the settings-save api.put().
|
|
||||||
// When MSW returns 500 (or HttpResponse.error()), the rejected promise
|
|
||||||
// becomes an unhandled rejection at the process level and Vitest fails
|
|
||||||
// the run with exit code 1 — even though every test assertion passes.
|
|
||||||
// This handler swallows the *expected* rejection pattern only, so any
|
|
||||||
// unexpected unhandled rejection still surfaces as a hard failure.
|
|
||||||
// The drift itself is asserted by the it.fails() contract tests above
|
|
||||||
// ("Save button stays disabled" / "no DOM error region").
|
|
||||||
let suppressedRejections: unknown[] = []
|
|
||||||
const onUnhandled = (reason: unknown): void => {
|
|
||||||
const msg =
|
|
||||||
reason instanceof Error
|
|
||||||
? reason.message
|
|
||||||
: typeof reason === 'string'
|
|
||||||
? reason
|
|
||||||
: ''
|
|
||||||
if (
|
|
||||||
msg.startsWith('500: upstream failure') ||
|
|
||||||
msg.startsWith('Failed to fetch') ||
|
|
||||||
msg === 'Network error' ||
|
|
||||||
msg.includes('network error')
|
|
||||||
) {
|
|
||||||
suppressedRejections.push(reason)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Re-throw — surface unexpected rejections to the test runner.
|
|
||||||
throw reason
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
seedBearer()
|
seedBearer()
|
||||||
suppressedRejections = []
|
|
||||||
process.on('unhandledRejection', onUnhandled)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
clearBearer()
|
clearBearer()
|
||||||
process.off('unhandledRejection', onUnhandled)
|
|
||||||
// Sanity: every test in this file expects exactly one swallowed
|
|
||||||
// rejection (the settings PUT). If a test triggers more — or zero — the
|
|
||||||
// drift assumption changed and the harness should flag it.
|
|
||||||
if (suppressedRejections.length > 1) {
|
|
||||||
throw new Error(
|
|
||||||
`AZ-477 harness: expected at most 1 suppressed rejection, got ${suppressedRejections.length}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('AC-1 (FT-N-13 / NFT-RES-05) — 500 recovery', () => {
|
describe('AC-1 (FT-N-13 / NFT-RES-05) — 500 recovery', () => {
|
||||||
it.fails(
|
it('PUT 500 → Save button is no longer disabled within 2 s', async () => {
|
||||||
'PUT 500 → Save button is no longer disabled within 2 s',
|
rigSettingsEnv({ kind: 'http', status: 500 })
|
||||||
async () => {
|
|
||||||
// Drift: saveSystem awaits api.put() outside a try/finally; on a
|
|
||||||
// rejected promise the trailing `setSaving(false)` is never reached
|
|
||||||
// and the button stays disabled forever.
|
|
||||||
rigSettingsEnv({ kind: 'http', status: 500 })
|
|
||||||
await renderAndClickSave()
|
|
||||||
const saveButton = await findSystemSaveButton()
|
|
||||||
await waitFor(
|
|
||||||
() => expect(saveButton).not.toBeDisabled(),
|
|
||||||
{ timeout: 2000 },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
it.fails(
|
|
||||||
'PUT 500 → an in-DOM error region (role="alert") appears within 2 s',
|
|
||||||
async () => {
|
|
||||||
// Drift: SettingsPage renders no error region. Will pass once a
|
|
||||||
// toast / inline alert is wired into the save handler.
|
|
||||||
rigSettingsEnv({ kind: 'http', status: 500 })
|
|
||||||
await renderAndClickSave()
|
|
||||||
const alertEl = await screen.findByRole('alert', {}, { timeout: 2000 })
|
|
||||||
// Message shape: production task picks the i18n key; the test only
|
|
||||||
// asserts that *some* user-visible error text is present.
|
|
||||||
expect((alertEl.textContent ?? '').trim().length).toBeGreaterThan(0)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
it('control: today the Save button stays disabled after a 500 (current drift)', async () => {
|
|
||||||
// Pins the silent-failure drift: button remains in `disabled` state
|
|
||||||
// because setSaving(false) is unreachable.
|
|
||||||
const rig = rigSettingsEnv({ kind: 'http', status: 500 })
|
|
||||||
await renderAndClickSave()
|
await renderAndClickSave()
|
||||||
await waitFor(() => expect(rig.systemPuts).toBe(1))
|
|
||||||
// Wait briefly past the response; the button must stay disabled
|
|
||||||
// (drift: setSaving(false) is unreachable past the rejected await).
|
|
||||||
await new Promise((r) => setTimeout(r, 100))
|
|
||||||
const saveButton = await findSystemSaveButton()
|
const saveButton = await findSystemSaveButton()
|
||||||
expect(saveButton).toBeDisabled()
|
await waitFor(
|
||||||
|
() => expect(saveButton).not.toBeDisabled(),
|
||||||
|
{ timeout: 2000 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('PUT 500 → an in-DOM error region (role="alert") appears within 2 s', async () => {
|
||||||
|
rigSettingsEnv({ kind: 'http', status: 500 })
|
||||||
|
await renderAndClickSave()
|
||||||
|
const alertEl = await screen.findByRole('alert', {}, { timeout: 2000 })
|
||||||
|
// Message shape: production task picks the i18n key; the test only
|
||||||
|
// asserts that *some* user-visible error text is present.
|
||||||
|
expect((alertEl.textContent ?? '').trim().length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('AC-2 (FT-N-14 / NFT-RES-06) — network drop', () => {
|
describe('AC-2 (FT-N-14 / NFT-RES-06) — network drop', () => {
|
||||||
it.fails(
|
it('network error → Save button is no longer disabled within 2 s', async () => {
|
||||||
'network error → Save button is no longer disabled within 2 s',
|
rigSettingsEnv({ kind: 'network' })
|
||||||
async () => {
|
await renderAndClickSave()
|
||||||
rigSettingsEnv({ kind: 'network' })
|
const saveButton = await findSystemSaveButton()
|
||||||
await renderAndClickSave()
|
await waitFor(
|
||||||
const saveButton = await findSystemSaveButton()
|
() => expect(saveButton).not.toBeDisabled(),
|
||||||
await waitFor(
|
{ timeout: 2000 },
|
||||||
() => expect(saveButton).not.toBeDisabled(),
|
)
|
||||||
{ timeout: 2000 },
|
})
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
it.fails(
|
it('network error → an in-DOM error region (role="alert") appears within 2 s', async () => {
|
||||||
'network error → an in-DOM error region (role="alert") appears within 2 s',
|
rigSettingsEnv({ kind: 'network' })
|
||||||
async () => {
|
await renderAndClickSave()
|
||||||
rigSettingsEnv({ kind: 'network' })
|
const alertEl = await screen.findByRole('alert', {}, { timeout: 2000 })
|
||||||
await renderAndClickSave()
|
expect((alertEl.textContent ?? '').trim().length).toBeGreaterThan(0)
|
||||||
const alertEl = await screen.findByRole('alert', {}, { timeout: 2000 })
|
})
|
||||||
expect((alertEl.textContent ?? '').trim().length).toBeGreaterThan(0)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('AC-3 (NFT-PERF-09) — deadline ≤ 2 s', () => {
|
describe('AC-3 (NFT-PERF-09) — deadline ≤ 2 s', () => {
|
||||||
it.fails(
|
it('500 → DOM error region visible within 2000 ms of the response', async () => {
|
||||||
'500 → DOM error region visible within 2000 ms of the response',
|
const rig = rigSettingsEnv({ kind: 'http', status: 500 })
|
||||||
async () => {
|
await renderAndClickSave()
|
||||||
// The deadline is measured from the moment the 500 response is
|
const alertEl = await screen.findByRole('alert', {}, { timeout: 2500 })
|
||||||
// returned by MSW (rig.responseAt.value) to the moment role="alert"
|
const alertVisibleAt = performance.now()
|
||||||
// is found. Today the alert never appears; the assertion is set so
|
expect(rig.responseAt.value).not.toBeNull()
|
||||||
// it will pass the moment the alert is wired AND comes up under the
|
const elapsed = alertVisibleAt - (rig.responseAt.value as number)
|
||||||
// 2-second budget.
|
// Elapsed must be ≥ 0 (response landed first) AND ≤ 2000 ms.
|
||||||
const rig = rigSettingsEnv({ kind: 'http', status: 500 })
|
expect(elapsed).toBeGreaterThanOrEqual(0)
|
||||||
await renderAndClickSave()
|
expect(elapsed).toBeLessThanOrEqual(2000)
|
||||||
const alertEl = await screen.findByRole('alert', {}, { timeout: 2500 })
|
expect(alertEl).toBeInTheDocument()
|
||||||
const alertVisibleAt = performance.now()
|
})
|
||||||
expect(rig.responseAt.value).not.toBeNull()
|
|
||||||
const elapsed = alertVisibleAt - (rig.responseAt.value as number)
|
|
||||||
// Elapsed must be ≥ 0 (response landed first) AND ≤ 2000 ms.
|
|
||||||
expect(elapsed).toBeGreaterThanOrEqual(0)
|
|
||||||
expect(elapsed).toBeLessThanOrEqual(2000)
|
|
||||||
expect(alertEl).toBeInTheDocument()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user