mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 14:51:11 +00:00
Refactor project structure and dependencies; rename package to azaion-ui, update version to 0.0.1, and remove unused files. Introduce new routing and authentication features in App component.
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { api } from '../../api/client'
|
||||
import ConfirmDialog from '../../components/ConfirmDialog'
|
||||
import type { DetectionClass, Aircraft, User } from '../../types'
|
||||
|
||||
export default function AdminPage() {
|
||||
const { t } = useTranslation()
|
||||
const [classes, setClasses] = useState<DetectionClass[]>([])
|
||||
const [aircrafts, setAircrafts] = useState<Aircraft[]>([])
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [newClass, setNewClass] = useState({ name: '', shortName: '', color: '#FF0000', maxSizeM: 7 })
|
||||
const [newUser, setNewUser] = useState({ name: '', email: '', password: '', role: 'Annotator' })
|
||||
const [deactivateId, setDeactivateId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.get<DetectionClass[]>('/api/annotations/classes').then(setClasses).catch(() => {})
|
||||
api.get<Aircraft[]>('/api/flights/aircrafts').then(setAircrafts).catch(() => {})
|
||||
api.get<User[]>('/api/admin/users').then(setUsers).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleAddClass = async () => {
|
||||
if (!newClass.name) return
|
||||
await api.post('/api/admin/classes', newClass)
|
||||
const updated = await api.get<DetectionClass[]>('/api/annotations/classes')
|
||||
setClasses(updated)
|
||||
setNewClass({ name: '', shortName: '', color: '#FF0000', maxSizeM: 7 })
|
||||
}
|
||||
|
||||
const handleDeleteClass = async (id: number) => {
|
||||
await api.delete(`/api/admin/classes/${id}`)
|
||||
setClasses(prev => prev.filter(c => c.id !== id))
|
||||
}
|
||||
|
||||
const handleAddUser = async () => {
|
||||
if (!newUser.email || !newUser.password) return
|
||||
await api.post('/api/admin/users', newUser)
|
||||
const updated = await api.get<User[]>('/api/admin/users')
|
||||
setUsers(updated)
|
||||
setNewUser({ name: '', email: '', password: '', role: 'Annotator' })
|
||||
}
|
||||
|
||||
const handleDeactivate = async () => {
|
||||
if (!deactivateId) return
|
||||
await api.patch(`/api/admin/users/${deactivateId}`, { isActive: false })
|
||||
setUsers(prev => prev.map(u => u.id === deactivateId ? { ...u, isActive: false } : u))
|
||||
setDeactivateId(null)
|
||||
}
|
||||
|
||||
const handleToggleDefault = async (a: Aircraft) => {
|
||||
await api.patch(`/api/flights/aircrafts/${a.id}`, { isDefault: !a.isDefault })
|
||||
setAircrafts(prev => prev.map(x => x.id === a.id ? { ...x, isDefault: !x.isDefault } : x))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-y-auto p-4 gap-4">
|
||||
{/* Detection classes */}
|
||||
<div className="w-[340px] shrink-0">
|
||||
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.classes')}</h2>
|
||||
<div className="bg-az-panel border border-az-border rounded overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-az-border text-az-muted">
|
||||
<th className="px-2 py-1 text-left">#</th>
|
||||
<th className="px-2 py-1 text-left">Name</th>
|
||||
<th className="px-2 py-1">Color</th>
|
||||
<th className="px-2 py-1"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{classes.map(c => (
|
||||
<tr key={c.id} className="border-b border-az-border text-az-text">
|
||||
<td className="px-2 py-1">{c.id}</td>
|
||||
<td className="px-2 py-1">{c.name}</td>
|
||||
<td className="px-2 py-1 text-center"><span className="inline-block w-3 h-3 rounded-full" style={{ backgroundColor: c.color }} /></td>
|
||||
<td className="px-2 py-1"><button onClick={() => handleDeleteClass(c.id)} className="text-az-muted hover:text-az-red">×</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="p-2 flex gap-1 border-t border-az-border">
|
||||
<input value={newClass.name} onChange={e => setNewClass(p => ({ ...p, name: e.target.value }))} placeholder="Name" className="flex-1 bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text" />
|
||||
<input type="color" value={newClass.color} onChange={e => setNewClass(p => ({ ...p, color: e.target.value }))} className="w-8 h-7 border-0 bg-transparent cursor-pointer" />
|
||||
<button onClick={handleAddClass} className="bg-az-orange text-white text-xs px-2 py-1 rounded">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: AI + GPS settings */}
|
||||
<div className="flex-1 space-y-4 max-w-md">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.aiSettings')}</h2>
|
||||
<div className="bg-az-panel border border-az-border rounded p-3 space-y-2 text-xs">
|
||||
<div>
|
||||
<label className="text-az-muted">Frame Period Recognition</label>
|
||||
<input type="number" defaultValue={5} className="w-full bg-az-bg border border-az-border rounded px-2 py-1 mt-0.5 text-az-text" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-az-muted">Frame Recognition Seconds</label>
|
||||
<input type="number" defaultValue={1} className="w-full bg-az-bg border border-az-border rounded px-2 py-1 mt-0.5 text-az-text" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-az-muted">Probability Threshold</label>
|
||||
<input type="number" defaultValue={0.5} step={0.05} min={0} max={1} className="w-full bg-az-bg border border-az-border rounded px-2 py-1 mt-0.5 text-az-text" />
|
||||
</div>
|
||||
<button className="bg-az-orange text-white text-xs px-3 py-1 rounded">{t('common.save')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.gpsSettings')}</h2>
|
||||
<div className="bg-az-panel border border-az-border rounded p-3 space-y-2 text-xs">
|
||||
<div>
|
||||
<label className="text-az-muted">Device Address</label>
|
||||
<input defaultValue="192.168.1.100" className="w-full bg-az-bg border border-az-border rounded px-2 py-1 mt-0.5 text-az-text" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-az-muted">Port</label>
|
||||
<input type="number" defaultValue={5535} className="w-full bg-az-bg border border-az-border rounded px-2 py-1 mt-0.5 text-az-text" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-az-muted">Protocol</label>
|
||||
<select className="w-full bg-az-bg border border-az-border rounded px-2 py-1 mt-0.5 text-az-text">
|
||||
<option>TCP</option>
|
||||
<option>UDP</option>
|
||||
</select>
|
||||
</div>
|
||||
<button className="bg-az-orange text-white text-xs px-3 py-1 rounded">{t('common.save')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users */}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.users')}</h2>
|
||||
<div className="bg-az-panel border border-az-border rounded overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-az-border text-az-muted">
|
||||
<th className="px-2 py-1 text-left">Name</th>
|
||||
<th className="px-2 py-1 text-left">Email</th>
|
||||
<th className="px-2 py-1">Role</th>
|
||||
<th className="px-2 py-1">Status</th>
|
||||
<th className="px-2 py-1"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(u => (
|
||||
<tr key={u.id} className="border-b border-az-border text-az-text">
|
||||
<td className="px-2 py-1">{u.name}</td>
|
||||
<td className="px-2 py-1">{u.email}</td>
|
||||
<td className="px-2 py-1 text-center">{u.role}</td>
|
||||
<td className="px-2 py-1 text-center">
|
||||
<span className={`px-1 rounded ${u.isActive ? 'text-az-green' : 'text-az-red'}`}>
|
||||
{u.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
{u.isActive && (
|
||||
<button onClick={() => setDeactivateId(u.id)} className="text-az-muted hover:text-az-red text-xs">
|
||||
{t('admin.deactivate')}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="p-2 flex gap-1 border-t border-az-border">
|
||||
<input value={newUser.name} onChange={e => setNewUser(p => ({ ...p, name: e.target.value }))} placeholder="Name" className="flex-1 bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text" />
|
||||
<input value={newUser.email} onChange={e => setNewUser(p => ({ ...p, email: e.target.value }))} placeholder="Email" className="flex-1 bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text" />
|
||||
<input value={newUser.password} onChange={e => setNewUser(p => ({ ...p, password: e.target.value }))} placeholder="Password" type="password" className="flex-1 bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text" />
|
||||
<select value={newUser.role} onChange={e => setNewUser(p => ({ ...p, role: e.target.value }))} className="bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text">
|
||||
<option>Annotator</option>
|
||||
<option>Admin</option>
|
||||
<option>Viewer</option>
|
||||
</select>
|
||||
<button onClick={handleAddUser} className="bg-az-orange text-white text-xs px-2 py-1 rounded">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Aircrafts sidebar */}
|
||||
<div className="w-[280px] shrink-0">
|
||||
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.aircrafts')}</h2>
|
||||
<div className="bg-az-panel border border-az-border rounded p-2 space-y-1">
|
||||
{aircrafts.map(a => (
|
||||
<div key={a.id} onClick={() => handleToggleDefault(a)} className="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-az-bg text-xs text-az-text">
|
||||
<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 === 'Plane' ? 'P' : 'C'}
|
||||
</span>
|
||||
<span className="flex-1">{a.model}</span>
|
||||
<span className={`text-sm ${a.isDefault ? 'text-az-orange' : 'text-az-muted'}`}>★</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deactivateId}
|
||||
title={t('admin.deactivate')}
|
||||
message="Deactivate this user?"
|
||||
onConfirm={handleDeactivate}
|
||||
onCancel={() => setDeactivateId(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useResizablePanel } from '../../hooks/useResizablePanel'
|
||||
import MediaList from './MediaList'
|
||||
import VideoPlayer from './VideoPlayer'
|
||||
import CanvasEditor from './CanvasEditor'
|
||||
import AnnotationsSidebar from './AnnotationsSidebar'
|
||||
import DetectionClasses from '../../components/DetectionClasses'
|
||||
import type { Media, AnnotationListItem, Detection } from '../../types'
|
||||
|
||||
export default function AnnotationsPage() {
|
||||
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [annotations, setAnnotations] = useState<AnnotationListItem[]>([])
|
||||
const [selectedAnnotation, setSelectedAnnotation] = useState<AnnotationListItem | null>(null)
|
||||
const [selectedClassNum, setSelectedClassNum] = useState(0)
|
||||
const [photoMode, setPhotoMode] = useState(0)
|
||||
const [detections, setDetections] = useState<Detection[]>([])
|
||||
const leftPanel = useResizablePanel(250, 200, 400)
|
||||
const rightPanel = useResizablePanel(200, 150, 350)
|
||||
|
||||
const handleAnnotationSelect = useCallback((ann: AnnotationListItem) => {
|
||||
setSelectedAnnotation(ann)
|
||||
setDetections(ann.detections)
|
||||
}, [])
|
||||
|
||||
const handleDetectionsChange = useCallback((dets: Detection[]) => {
|
||||
setDetections(dets)
|
||||
}, [])
|
||||
|
||||
const isVideo = selectedMedia?.mediaType === 2
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* Left panel */}
|
||||
<div style={{ width: leftPanel.width }} className="bg-az-panel border-r border-az-border flex flex-col shrink-0">
|
||||
<MediaList
|
||||
selectedMedia={selectedMedia}
|
||||
onSelect={setSelectedMedia}
|
||||
onAnnotationsLoaded={setAnnotations}
|
||||
/>
|
||||
<DetectionClasses
|
||||
selectedClassNum={selectedClassNum}
|
||||
onSelect={setSelectedClassNum}
|
||||
photoMode={photoMode}
|
||||
onPhotoModeChange={setPhotoMode}
|
||||
/>
|
||||
</div>
|
||||
<div onMouseDown={leftPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" />
|
||||
|
||||
{/* Center - video/canvas */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{selectedMedia && isVideo && (
|
||||
<VideoPlayer
|
||||
media={selectedMedia}
|
||||
onTimeUpdate={setCurrentTime}
|
||||
selectedClassNum={selectedClassNum}
|
||||
/>
|
||||
)}
|
||||
{selectedMedia && (
|
||||
<CanvasEditor
|
||||
media={selectedMedia}
|
||||
annotation={selectedAnnotation}
|
||||
detections={detections}
|
||||
onDetectionsChange={handleDetectionsChange}
|
||||
selectedClassNum={selectedClassNum}
|
||||
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>
|
||||
|
||||
{/* Right panel */}
|
||||
<div onMouseDown={rightPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" />
|
||||
<div style={{ width: rightPanel.width }} className="bg-az-panel border-l border-az-border flex flex-col shrink-0">
|
||||
<AnnotationsSidebar
|
||||
media={selectedMedia}
|
||||
annotations={annotations}
|
||||
selectedAnnotation={selectedAnnotation}
|
||||
onSelect={handleAnnotationSelect}
|
||||
onAnnotationsUpdate={setAnnotations}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { api } from '../../api/client'
|
||||
import { createSSE } from '../../api/sse'
|
||||
import type { Media, AnnotationListItem, PaginatedResponse } from '../../types'
|
||||
|
||||
interface Props {
|
||||
media: Media | null
|
||||
annotations: AnnotationListItem[]
|
||||
selectedAnnotation: AnnotationListItem | null
|
||||
onSelect: (ann: AnnotationListItem) => void
|
||||
onAnnotationsUpdate: (anns: AnnotationListItem[]) => void
|
||||
}
|
||||
|
||||
export default function AnnotationsSidebar({ media, annotations, selectedAnnotation, onSelect, onAnnotationsUpdate }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const [detecting, setDetecting] = useState(false)
|
||||
const [detectLog, setDetectLog] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!media) return
|
||||
return createSSE<{ annotationId: string; mediaId: string; status: number }>('/api/annotations/annotations/events', (event) => {
|
||||
if (event.mediaId === media.id) {
|
||||
api.get<PaginatedResponse<AnnotationListItem>>(
|
||||
`/api/annotations/annotations?mediaId=${media.id}&pageSize=1000`
|
||||
).then(res => onAnnotationsUpdate(res.items)).catch(() => {})
|
||||
}
|
||||
})
|
||||
}, [media, onAnnotationsUpdate])
|
||||
|
||||
const handleDetect = async () => {
|
||||
if (!media) return
|
||||
setDetecting(true)
|
||||
setDetectLog(['Starting AI detection...'])
|
||||
try {
|
||||
await api.post(`/api/detect/${media.id}`)
|
||||
setDetectLog(prev => [...prev, 'Detection complete.'])
|
||||
} catch (e: any) {
|
||||
setDetectLog(prev => [...prev, `Error: ${e.message}`])
|
||||
}
|
||||
}
|
||||
|
||||
const getRowGradient = (ann: AnnotationListItem) => {
|
||||
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 `${d.label ? getClassColor(d.classNum) : '#888'}${Math.round(alpha * 40).toString(16).padStart(2, '0')} ${pct}%`
|
||||
})
|
||||
return `linear-gradient(to right, ${stops.join(', ')})`
|
||||
}
|
||||
|
||||
const classColors: Record<number, string> = {}
|
||||
const getClassColor = (classNum: number) => {
|
||||
if (classColors[classNum]) return classColors[classNum]
|
||||
const colors = ['#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF', '#188021', '#800000', '#008000', '#000080']
|
||||
return colors[classNum % colors.length]
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-2 border-b border-az-border flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-az-muted">{t('annotations.title')}</span>
|
||||
<button
|
||||
onClick={handleDetect}
|
||||
disabled={!media}
|
||||
className="text-xs bg-az-blue text-white px-2 py-0.5 rounded disabled:opacity-50"
|
||||
>
|
||||
{t('annotations.detect')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{annotations.map(ann => (
|
||||
<div
|
||||
key={ann.id}
|
||||
onClick={() => onSelect(ann)}
|
||||
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' : ''
|
||||
}`}
|
||||
style={{ background: getRowGradient(ann) }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-az-text font-mono">{ann.time || '—'}</span>
|
||||
<span className="text-az-muted">{ann.detections.length > 0 ? ann.detections[0].label : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{annotations.length === 0 && (
|
||||
<div className="p-2 text-az-muted text-xs text-center">{t('common.noData')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{detecting && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[100]">
|
||||
<div className="bg-az-panel border border-az-border rounded-lg p-4 w-96 max-h-80 flex flex-col">
|
||||
<h3 className="text-white font-semibold mb-2">{t('annotations.detect')}</h3>
|
||||
<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">
|
||||
{detectLog.map((line, i) => <div key={i}>{line}</div>)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import { useRef, useEffect, useState, useCallback } from 'react'
|
||||
import type { Media, AnnotationListItem, Detection, Affiliation, CombatReadiness } from '../../types'
|
||||
|
||||
interface Props {
|
||||
media: Media
|
||||
annotation: AnnotationListItem | null
|
||||
detections: Detection[]
|
||||
onDetectionsChange: (dets: Detection[]) => void
|
||||
selectedClassNum: number
|
||||
currentTime: number
|
||||
annotations: AnnotationListItem[]
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
type: 'draw' | 'move' | 'resize'
|
||||
startX: number
|
||||
startY: number
|
||||
detectionIndex?: number
|
||||
handle?: string
|
||||
}
|
||||
|
||||
const HANDLE_SIZE = 6
|
||||
const MIN_BOX_SIZE = 12
|
||||
|
||||
const AFFILIATION_COLORS: Record<number, string> = {
|
||||
0: '#FFD700',
|
||||
1: '#228be6',
|
||||
2: '#fa5252',
|
||||
}
|
||||
|
||||
export default function CanvasEditor({ media, annotation, detections, onDetectionsChange, selectedClassNum, currentTime, annotations }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const imgRef = useRef<HTMLImageElement | null>(null)
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 })
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||
const [dragState, setDragState] = useState<DragState | 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 loadImage = useCallback(() => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
if (annotation) {
|
||||
img.src = `/api/annotations/annotations/${annotation.id}/image`
|
||||
} else {
|
||||
img.src = `/api/annotations/media/${media.id}/file`
|
||||
}
|
||||
img.onload = () => {
|
||||
imgRef.current = img
|
||||
setImgSize({ w: img.naturalWidth, h: img.naturalHeight })
|
||||
}
|
||||
}, [media, annotation])
|
||||
|
||||
useEffect(() => { loadImage() }, [loadImage])
|
||||
|
||||
const toCanvas = useCallback((nx: number, ny: number) => ({
|
||||
x: nx * imgSize.w * zoom + pan.x,
|
||||
y: ny * imgSize.h * zoom + pan.y,
|
||||
}), [imgSize, zoom, pan])
|
||||
|
||||
const fromCanvas = useCallback((cx: number, cy: number) => ({
|
||||
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))),
|
||||
}), [imgSize, zoom, pan])
|
||||
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
const ctx = canvas?.getContext('2d')
|
||||
if (!canvas || !ctx || !imgRef.current) return
|
||||
|
||||
const container = containerRef.current
|
||||
if (container) {
|
||||
canvas.width = container.clientWidth
|
||||
canvas.height = container.clientHeight
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.save()
|
||||
ctx.drawImage(imgRef.current, pan.x, pan.y, imgSize.w * zoom, imgSize.h * zoom)
|
||||
|
||||
const timeWindowDets = getTimeWindowDetections()
|
||||
const allDets = [...detections, ...timeWindowDets]
|
||||
|
||||
allDets.forEach((det, i) => {
|
||||
const isSelected = selected.has(i) && i < detections.length
|
||||
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 w = det.width * imgSize.w * zoom
|
||||
const h = det.height * imgSize.h * zoom
|
||||
|
||||
const color = AFFILIATION_COLORS[det.affiliation] || '#FFD700'
|
||||
ctx.strokeStyle = color
|
||||
ctx.lineWidth = isSelected ? 2 : 1
|
||||
ctx.strokeRect(cx, cy, w, h)
|
||||
|
||||
ctx.fillStyle = color
|
||||
ctx.globalAlpha = 0.1
|
||||
ctx.fillRect(cx, cy, w, h)
|
||||
ctx.globalAlpha = 1
|
||||
|
||||
const label = det.confidence < 0.995
|
||||
? `${det.label} ${(det.confidence * 100).toFixed(0)}%`
|
||||
: det.label
|
||||
ctx.fillStyle = color
|
||||
ctx.font = '11px sans-serif'
|
||||
ctx.fillText(label, cx + 2, cy - 3)
|
||||
|
||||
if (det.combatReadiness === 1) {
|
||||
ctx.fillStyle = '#40c057'
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx + w - 6, cy + 6, 3, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
const handles = getHandles(cx, cy, w, h)
|
||||
handles.forEach(hp => {
|
||||
ctx.fillStyle = '#fff'
|
||||
ctx.fillRect(hp.x - HANDLE_SIZE / 2, hp.y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE)
|
||||
ctx.strokeStyle = color
|
||||
ctx.strokeRect(hp.x - HANDLE_SIZE / 2, hp.y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (drawRect) {
|
||||
ctx.strokeStyle = '#fd7e14'
|
||||
ctx.lineWidth = 1
|
||||
ctx.setLineDash([4, 4])
|
||||
ctx.strokeRect(drawRect.x, drawRect.y, drawRect.w, drawRect.h)
|
||||
ctx.setLineDash([])
|
||||
}
|
||||
|
||||
ctx.restore()
|
||||
}, [detections, selected, zoom, pan, imgSize, drawRect, currentTime, annotations])
|
||||
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(draw)
|
||||
return () => cancelAnimationFrame(id)
|
||||
}, [draw])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
const obs = new ResizeObserver(() => draw())
|
||||
obs.observe(container)
|
||||
return () => obs.disconnect()
|
||||
}, [draw])
|
||||
|
||||
const getTimeWindowDetections = (): Detection[] => {
|
||||
if (media.mediaType !== 2) 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) => {
|
||||
for (let i = detections.length - 1; i >= 0; i--) {
|
||||
const d = detections[i]
|
||||
const bx = (d.centerX - d.width / 2) * imgSize.w * zoom + pan.x
|
||||
const by = (d.centerY - d.height / 2) * imgSize.h * zoom + pan.y
|
||||
const bw = d.width * imgSize.w * zoom
|
||||
const bh = d.height * imgSize.h * zoom
|
||||
|
||||
if (selected.has(i)) {
|
||||
const handles = getHandles(bx, by, bw, bh)
|
||||
for (const h of handles) {
|
||||
if (Math.abs(cx - h.x) < HANDLE_SIZE && Math.abs(cy - h.y) < HANDLE_SIZE) {
|
||||
return { type: 'handle' as const, index: i, handle: h.name }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cx >= bx && cx <= bx + bw && cy >= by && cy <= by + bh) {
|
||||
return { type: 'box' as const, index: i }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
const mx = e.clientX - rect.left
|
||||
const my = e.clientY - rect.top
|
||||
|
||||
if (e.ctrlKey && e.button === 0) {
|
||||
setDragState({ type: 'draw', startX: mx, startY: my })
|
||||
return
|
||||
}
|
||||
|
||||
const hit = hitTest(mx, my)
|
||||
if (hit?.type === 'handle') {
|
||||
setDragState({ type: 'resize', startX: mx, startY: my, detectionIndex: hit.index, handle: hit.handle })
|
||||
} else if (hit?.type === 'box') {
|
||||
if (e.ctrlKey) {
|
||||
setSelected(prev => { const n = new Set(prev); n.has(hit.index) ? n.delete(hit.index) : n.add(hit.index); return n })
|
||||
} else {
|
||||
setSelected(new Set([hit.index]))
|
||||
}
|
||||
setDragState({ type: 'move', startX: mx, startY: my, detectionIndex: hit.index })
|
||||
} else {
|
||||
setSelected(new Set())
|
||||
setDragState({ type: 'draw', startX: mx, startY: my })
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!dragState) return
|
||||
const rect = canvasRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
const mx = e.clientX - rect.left
|
||||
const my = e.clientY - rect.top
|
||||
|
||||
if (dragState.type === 'draw') {
|
||||
setDrawRect({
|
||||
x: Math.min(dragState.startX, mx),
|
||||
y: Math.min(dragState.startY, my),
|
||||
w: Math.abs(mx - dragState.startX),
|
||||
h: Math.abs(my - dragState.startY),
|
||||
})
|
||||
} else if (dragState.type === 'move' && dragState.detectionIndex !== undefined) {
|
||||
const dx = (mx - dragState.startX) / (imgSize.w * zoom)
|
||||
const dy = (my - dragState.startY) / (imgSize.h * zoom)
|
||||
const newDets = [...detections]
|
||||
const indices = selected.size > 0 ? Array.from(selected) : [dragState.detectionIndex]
|
||||
indices.forEach(i => {
|
||||
if (newDets[i]) {
|
||||
newDets[i] = {
|
||||
...newDets[i],
|
||||
centerX: Math.max(newDets[i].width / 2, Math.min(1 - newDets[i].width / 2, newDets[i].centerX + dx)),
|
||||
centerY: Math.max(newDets[i].height / 2, Math.min(1 - newDets[i].height / 2, newDets[i].centerY + dy)),
|
||||
}
|
||||
}
|
||||
})
|
||||
onDetectionsChange(newDets)
|
||||
setDragState({ ...dragState, startX: mx, startY: my })
|
||||
} else if (dragState.type === 'resize' && dragState.detectionIndex !== undefined && dragState.handle) {
|
||||
const idx = dragState.detectionIndex
|
||||
const d = detections[idx]
|
||||
const norm = fromCanvas(mx, my)
|
||||
const newDets = [...detections]
|
||||
let x1 = d.centerX - d.width / 2, y1 = d.centerY - d.height / 2
|
||||
let x2 = d.centerX + d.width / 2, y2 = d.centerY + d.height / 2
|
||||
|
||||
if (dragState.handle.includes('l')) x1 = norm.x
|
||||
if (dragState.handle.includes('r')) x2 = norm.x
|
||||
if (dragState.handle.includes('t')) y1 = norm.y
|
||||
if (dragState.handle.includes('b')) y2 = norm.y
|
||||
|
||||
const w = Math.abs(x2 - x1), h = Math.abs(y2 - y1)
|
||||
if (w * imgSize.w * zoom >= MIN_BOX_SIZE && h * imgSize.h * zoom >= MIN_BOX_SIZE) {
|
||||
newDets[idx] = {
|
||||
...d,
|
||||
centerX: Math.min(x1, x2) + w / 2,
|
||||
centerY: Math.min(y1, y2) + h / 2,
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
onDetectionsChange(newDets)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (dragState?.type === 'draw' && drawRect) {
|
||||
const w = drawRect.w / (imgSize.w * zoom)
|
||||
const h = drawRect.h / (imgSize.h * zoom)
|
||||
if (w * imgSize.w >= MIN_BOX_SIZE && h * imgSize.h >= MIN_BOX_SIZE) {
|
||||
const center = fromCanvas(drawRect.x + drawRect.w / 2, drawRect.y + drawRect.h / 2)
|
||||
const newDet: Detection = {
|
||||
id: crypto.randomUUID(),
|
||||
classNum: selectedClassNum,
|
||||
label: '',
|
||||
confidence: 1,
|
||||
affiliation: 0 as Affiliation,
|
||||
combatReadiness: 0 as CombatReadiness,
|
||||
centerX: center.x,
|
||||
centerY: center.y,
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
onDetectionsChange([...detections, newDet])
|
||||
setSelected(new Set([detections.length]))
|
||||
}
|
||||
setDrawRect(null)
|
||||
}
|
||||
setDragState(null)
|
||||
}
|
||||
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
if (!e.ctrlKey) return
|
||||
e.preventDefault()
|
||||
const delta = e.deltaY > 0 ? 0.9 : 1.1
|
||||
setZoom(z => Math.max(0.1, Math.min(10, z * delta)))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement) return
|
||||
if (e.key === 'Delete' && selected.size > 0) {
|
||||
onDetectionsChange(detections.filter((_, i) => !selected.has(i)))
|
||||
setSelected(new Set())
|
||||
}
|
||||
if (e.key === 'x' || e.key === 'X') {
|
||||
if (e.target instanceof HTMLInputElement) return
|
||||
onDetectionsChange([])
|
||||
setSelected(new Set())
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [detections, selected, onDetectionsChange])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex-1 relative overflow-hidden cursor-crosshair">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onWheel={handleWheel}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useFlight } from '../../components/FlightContext'
|
||||
import { api } from '../../api/client'
|
||||
import { useDebounce } from '../../hooks/useDebounce'
|
||||
import ConfirmDialog from '../../components/ConfirmDialog'
|
||||
import type { Media, PaginatedResponse, AnnotationListItem } from '../../types'
|
||||
|
||||
interface Props {
|
||||
selectedMedia: Media | null
|
||||
onSelect: (m: Media) => void
|
||||
onAnnotationsLoaded: (anns: AnnotationListItem[]) => void
|
||||
}
|
||||
|
||||
export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const { selectedFlight } = useFlight()
|
||||
const [media, setMedia] = useState<Media[]>([])
|
||||
const [filter, setFilter] = useState('')
|
||||
const debouncedFilter = useDebounce(filter, 300)
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
const fetchMedia = useCallback(async () => {
|
||||
const params = new URLSearchParams({ pageSize: '1000' })
|
||||
if (selectedFlight) params.set('flightId', selectedFlight.id)
|
||||
if (debouncedFilter) params.set('name', debouncedFilter)
|
||||
try {
|
||||
const res = await api.get<PaginatedResponse<Media>>(`/api/annotations/media?${params}`)
|
||||
setMedia(res.items)
|
||||
} catch {}
|
||||
}, [selectedFlight, debouncedFilter])
|
||||
|
||||
useEffect(() => { fetchMedia() }, [fetchMedia])
|
||||
|
||||
const handleSelect = async (m: Media) => {
|
||||
onSelect(m)
|
||||
try {
|
||||
const res = await api.get<PaginatedResponse<AnnotationListItem>>(
|
||||
`/api/annotations/annotations?mediaId=${m.id}&pageSize=1000`
|
||||
)
|
||||
onAnnotationsLoaded(res.items)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteId) return
|
||||
await api.delete(`/api/annotations/media/${deleteId}`)
|
||||
setDeleteId(null)
|
||||
fetchMedia()
|
||||
}
|
||||
|
||||
const handleDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
if (!selectedFlight || !e.dataTransfer.files.length) return
|
||||
const form = new FormData()
|
||||
form.append('waypointId', '')
|
||||
for (const file of e.dataTransfer.files) form.append('files', file)
|
||||
await api.upload('/api/annotations/media/batch', form)
|
||||
fetchMedia()
|
||||
}
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target.files?.length) return
|
||||
const form = new FormData()
|
||||
form.append('waypointId', '')
|
||||
for (const file of e.target.files) form.append('files', file)
|
||||
await api.upload('/api/annotations/media/batch', form)
|
||||
fetchMedia()
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex-1 flex flex-col overflow-hidden ${dragging ? 'ring-2 ring-az-orange ring-inset' : ''}`}
|
||||
onDragOver={e => { e.preventDefault(); setDragging(true) }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="p-2 border-b border-az-border flex gap-1">
|
||||
<input
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
placeholder={t('annotations.mediaList')}
|
||||
className="flex-1 bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text outline-none"
|
||||
/>
|
||||
<label className="bg-az-orange text-white text-xs px-2 py-1 rounded cursor-pointer">
|
||||
↑
|
||||
<input type="file" multiple className="hidden" onChange={handleFileUpload} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{media.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 === 2 ? 'bg-az-blue/20 text-az-blue' : 'bg-az-green/20 text-az-green'}`}>
|
||||
{m.mediaType === 2 ? 'V' : 'P'}
|
||||
</span>
|
||||
<span className="truncate flex-1">{m.name}</span>
|
||||
{m.duration && <span className="text-az-muted">{m.duration}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
open={!!deleteId}
|
||||
title={t('annotations.deleteMedia')}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteId(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useRef, useState, useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { api } from '../../api/client'
|
||||
import { getToken } from '../../api/client'
|
||||
import type { Media } from '../../types'
|
||||
|
||||
interface Props {
|
||||
media: Media
|
||||
onTimeUpdate: (time: number) => void
|
||||
selectedClassNum: number
|
||||
}
|
||||
|
||||
export default function VideoPlayer({ media, onTimeUpdate, selectedClassNum }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [muted, setMuted] = useState(false)
|
||||
|
||||
const token = getToken()
|
||||
const videoUrl = `/api/annotations/media/${media.id}/file`
|
||||
|
||||
const stepFrames = useCallback((count: number) => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
const fps = 30
|
||||
video.currentTime = Math.max(0, Math.min(video.duration, video.currentTime + count / fps))
|
||||
}, [])
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
const v = videoRef.current
|
||||
if (!v) return
|
||||
if (v.paused) { v.play(); setPlaying(true) }
|
||||
else { v.pause(); setPlaying(false) }
|
||||
}, [])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
const v = videoRef.current
|
||||
if (!v) return
|
||||
v.pause()
|
||||
v.currentTime = 0
|
||||
setPlaying(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
||||
switch (e.key) {
|
||||
case ' ': e.preventDefault(); togglePlay(); break
|
||||
case 'ArrowLeft': 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
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [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 (
|
||||
<div className="bg-black flex flex-col">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
muted={muted}
|
||||
className="w-full max-h-[50vh] object-contain"
|
||||
onTimeUpdate={e => {
|
||||
const t = (e.target as HTMLVideoElement).currentTime
|
||||
setCurrentTime(t)
|
||||
onTimeUpdate(t)
|
||||
}}
|
||||
onLoadedMetadata={e => setDuration((e.target as HTMLVideoElement).duration)}
|
||||
onClick={togglePlay}
|
||||
/>
|
||||
{/* Progress bar */}
|
||||
<div
|
||||
className="h-1 bg-az-border cursor-pointer"
|
||||
onClick={e => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const pct = (e.clientX - rect.left) / rect.width
|
||||
if (videoRef.current) videoRef.current.currentTime = pct * duration
|
||||
}}
|
||||
>
|
||||
<div className="h-full bg-az-orange" style={{ width: `${duration ? (currentTime / duration) * 100 : 0}%` }} />
|
||||
</div>
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-az-header text-xs">
|
||||
<button onClick={togglePlay} className="text-az-text hover:text-white px-1">{playing ? '⏸' : '▶'}</button>
|
||||
<button onClick={stop} className="text-az-text hover:text-white px-1">⏹</button>
|
||||
{[1, 5, 10, 30, 60].map(n => (
|
||||
<button key={`prev-${n}`} onClick={() => stepFrames(-n)} className="text-az-muted hover:text-white px-0.5">-{n}</button>
|
||||
))}
|
||||
<span className="text-az-muted mx-1">|</span>
|
||||
{[1, 5, 10, 30, 60].map(n => (
|
||||
<button key={`next-${n}`} onClick={() => stepFrames(n)} className="text-az-muted hover:text-white px-0.5">+{n}</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<button onClick={() => setMuted(m => !m)} className="text-az-text hover:text-white px-1">
|
||||
{muted ? '🔇' : '🔊'}
|
||||
</button>
|
||||
<span className="text-az-muted">{formatTime(currentTime)} / {formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { api } from '../../api/client'
|
||||
import { useDebounce } from '../../hooks/useDebounce'
|
||||
import { useResizablePanel } from '../../hooks/useResizablePanel'
|
||||
import { useFlight } from '../../components/FlightContext'
|
||||
import DetectionClasses from '../../components/DetectionClasses'
|
||||
import ConfirmDialog from '../../components/ConfirmDialog'
|
||||
import CanvasEditor from '../annotations/CanvasEditor'
|
||||
import type { DatasetItem, PaginatedResponse, ClassDistributionItem, AnnotationListItem, Detection, Media } from '../../types'
|
||||
import { AnnotationStatus } from '../../types'
|
||||
|
||||
type Tab = 'annotations' | 'editor' | 'distribution'
|
||||
|
||||
export default function DatasetPage() {
|
||||
const { t } = useTranslation()
|
||||
const { selectedFlight } = useFlight()
|
||||
const leftPanel = useResizablePanel(250, 200, 400)
|
||||
|
||||
const [items, setItems] = useState<DatasetItem[]>([])
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize] = useState(20)
|
||||
const [fromDate, setFromDate] = useState('')
|
||||
const [toDate, setToDate] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<AnnotationStatus | null>(null)
|
||||
const [objectsOnly, setObjectsOnly] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSearch = useDebounce(search, 400)
|
||||
const [selectedClassNum, setSelectedClassNum] = useState(0)
|
||||
const [photoMode, setPhotoMode] = useState(0)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [tab, setTab] = useState<Tab>('annotations')
|
||||
const [editorAnnotation, setEditorAnnotation] = useState<AnnotationListItem | null>(null)
|
||||
const [editorDetections, setEditorDetections] = useState<Detection[]>([])
|
||||
const [distribution, setDistribution] = useState<ClassDistributionItem[]>([])
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
||||
if (fromDate) params.set('fromDate', fromDate)
|
||||
if (toDate) params.set('toDate', toDate)
|
||||
if (selectedFlight) params.set('flightId', selectedFlight.id)
|
||||
if (statusFilter !== null) params.set('status', String(statusFilter))
|
||||
if (selectedClassNum) params.set('classNum', String(selectedClassNum))
|
||||
if (objectsOnly) params.set('hasDetections', 'true')
|
||||
if (debouncedSearch) params.set('name', debouncedSearch)
|
||||
try {
|
||||
const res = await api.get<PaginatedResponse<DatasetItem>>(`/api/annotations/dataset?${params}`)
|
||||
setItems(res.items)
|
||||
setTotalCount(res.totalCount)
|
||||
} catch {}
|
||||
}, [page, pageSize, fromDate, toDate, selectedFlight, statusFilter, selectedClassNum, objectsOnly, debouncedSearch])
|
||||
|
||||
useEffect(() => { fetchItems() }, [fetchItems])
|
||||
|
||||
const handleDoubleClick = async (item: DatasetItem) => {
|
||||
try {
|
||||
const ann = await api.get<AnnotationListItem>(`/api/annotations/dataset/${item.annotationId}`)
|
||||
setEditorAnnotation(ann)
|
||||
setEditorDetections(ann.detections)
|
||||
setTab('editor')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const handleValidate = async () => {
|
||||
if (selectedIds.size === 0) return
|
||||
await api.post('/api/annotations/dataset/bulk-status', {
|
||||
annotationIds: Array.from(selectedIds),
|
||||
status: AnnotationStatus.Validated,
|
||||
})
|
||||
setSelectedIds(new Set())
|
||||
fetchItems()
|
||||
}
|
||||
|
||||
const loadDistribution = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.get<ClassDistributionItem[]>('/api/annotations/dataset/class-distribution')
|
||||
setDistribution(data)
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { if (tab === 'distribution') loadDistribution() }, [tab, loadDistribution])
|
||||
|
||||
const maxDistCount = Math.max(...distribution.map(d => d.count), 1)
|
||||
const totalPages = Math.ceil(totalCount / pageSize)
|
||||
|
||||
const editorMedia: Media | null = editorAnnotation ? {
|
||||
id: editorAnnotation.mediaId, name: '', path: '', mediaType: 1, mediaStatus: 0,
|
||||
duration: null, annotationCount: 0, waypointId: null, userId: '',
|
||||
} : null
|
||||
|
||||
const statusButtons = [
|
||||
{ label: 'All', value: null },
|
||||
{ label: t('dataset.status.created'), value: AnnotationStatus.Created },
|
||||
{ label: t('dataset.status.edited'), value: AnnotationStatus.Edited },
|
||||
{ label: t('dataset.status.validated'), value: AnnotationStatus.Validated },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* Left panel */}
|
||||
<div style={{ width: leftPanel.width }} className="bg-az-panel border-r border-az-border flex flex-col shrink-0">
|
||||
<DetectionClasses
|
||||
selectedClassNum={selectedClassNum}
|
||||
onSelect={setSelectedClassNum}
|
||||
photoMode={photoMode}
|
||||
onPhotoModeChange={setPhotoMode}
|
||||
/>
|
||||
<div className="p-2 border-t border-az-border">
|
||||
<label className="flex items-center gap-1.5 text-xs text-az-text cursor-pointer">
|
||||
<input type="checkbox" checked={objectsOnly} onChange={e => setObjectsOnly(e.target.checked)} className="accent-az-orange" />
|
||||
{t('dataset.objectsOnly')}
|
||||
</label>
|
||||
</div>
|
||||
<div className="p-2 border-t border-az-border">
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder={t('dataset.search')}
|
||||
className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div onMouseDown={leftPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" />
|
||||
|
||||
{/* Main area */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Filter bar */}
|
||||
<div className="flex items-center gap-2 p-2 border-b border-az-border bg-az-panel text-xs flex-wrap">
|
||||
<input type="date" value={fromDate} onChange={e => setFromDate(e.target.value)} className="bg-az-bg border border-az-border rounded px-2 py-1 text-az-text" />
|
||||
<input type="date" value={toDate} onChange={e => setToDate(e.target.value)} className="bg-az-bg border border-az-border rounded px-2 py-1 text-az-text" />
|
||||
{statusButtons.map(sb => (
|
||||
<button
|
||||
key={String(sb.value)}
|
||||
onClick={() => { setStatusFilter(sb.value); setPage(1) }}
|
||||
className={`px-2 py-0.5 rounded ${statusFilter === sb.value ? 'bg-az-orange text-white' : 'bg-az-bg text-az-muted'}`}
|
||||
>
|
||||
{sb.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
{selectedIds.size > 0 && (
|
||||
<button onClick={handleValidate} className="bg-az-green text-white px-2 py-0.5 rounded">
|
||||
{t('dataset.validate')} ({selectedIds.size})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-az-border bg-az-panel">
|
||||
{(['annotations', 'editor', 'distribution'] as Tab[]).map(tb => (
|
||||
<button
|
||||
key={tb}
|
||||
onClick={() => setTab(tb)}
|
||||
className={`px-3 py-1.5 text-xs ${tab === tb ? 'bg-az-bg text-white border-b-2 border-az-orange' : 'text-az-muted'}`}
|
||||
>
|
||||
{t(`dataset.${tb === 'distribution' ? 'classDistribution' : tb}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{tab === 'annotations' && (
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))' }}>
|
||||
{items.map(item => (
|
||||
<div
|
||||
key={item.annotationId}
|
||||
onClick={e => {
|
||||
if (e.ctrlKey) {
|
||||
setSelectedIds(prev => {
|
||||
const n = new Set(prev)
|
||||
n.has(item.annotationId) ? n.delete(item.annotationId) : n.add(item.annotationId)
|
||||
return n
|
||||
})
|
||||
} else {
|
||||
setSelectedIds(new Set([item.annotationId]))
|
||||
}
|
||||
}}
|
||||
onDoubleClick={() => handleDoubleClick(item)}
|
||||
className={`bg-az-panel border rounded overflow-hidden cursor-pointer ${
|
||||
selectedIds.has(item.annotationId) ? 'border-az-orange' : 'border-az-border'
|
||||
} ${item.isSeed ? 'ring-2 ring-az-red' : ''}`}
|
||||
>
|
||||
<img
|
||||
src={`/api/annotations/annotations/${item.annotationId}/thumbnail`}
|
||||
alt={item.imageName}
|
||||
className="w-full h-32 object-cover bg-az-bg"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="p-1.5 text-xs">
|
||||
<div className="truncate text-az-text">{item.imageName}</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-az-muted">{new Date(item.createdDate).toLocaleDateString()}</span>
|
||||
<span className={`px-1 rounded ${
|
||||
item.status === AnnotationStatus.Validated ? 'bg-az-green/20 text-az-green' :
|
||||
item.status === AnnotationStatus.Edited ? 'bg-az-blue/20 text-az-blue' :
|
||||
'bg-az-muted/20 text-az-muted'
|
||||
}`}>
|
||||
{item.status === AnnotationStatus.Validated ? t('dataset.status.validated') :
|
||||
item.status === AnnotationStatus.Edited ? t('dataset.status.edited') :
|
||||
t('dataset.status.created')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center gap-2 py-3">
|
||||
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} className="text-xs text-az-muted disabled:opacity-30 px-2 py-1 bg-az-panel rounded">Prev</button>
|
||||
<span className="text-xs text-az-text py-1">{page} / {totalPages}</span>
|
||||
<button onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page === totalPages} className="text-xs text-az-muted disabled:opacity-30 px-2 py-1 bg-az-panel rounded">Next</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'editor' && editorMedia && editorAnnotation && (
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<CanvasEditor
|
||||
media={editorMedia}
|
||||
annotation={editorAnnotation}
|
||||
detections={editorDetections}
|
||||
onDetectionsChange={setEditorDetections}
|
||||
selectedClassNum={selectedClassNum}
|
||||
currentTime={0}
|
||||
annotations={[]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'distribution' && (
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-1.5 max-w-2xl">
|
||||
{distribution.map(d => (
|
||||
<div key={d.classNum} className="flex items-center gap-2 text-xs">
|
||||
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: d.color }} />
|
||||
<span className="w-40 truncate text-az-text">{d.label}</span>
|
||||
<div className="flex-1 bg-az-bg rounded h-4 overflow-hidden">
|
||||
<div className="h-full rounded" style={{ width: `${(d.count / maxDistCount) * 100}%`, backgroundColor: d.color, opacity: 0.7 }} />
|
||||
</div>
|
||||
<span className="text-az-muted w-12 text-right">{d.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MapContainer, TileLayer, Marker, Polyline, Popup } from 'react-leaflet'
|
||||
import type { Waypoint } from '../../types'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import L from 'leaflet'
|
||||
|
||||
const icon = L.divIcon({ className: 'bg-az-orange rounded-full w-3 h-3 border border-white', iconSize: [12, 12] })
|
||||
|
||||
interface Props {
|
||||
waypoints: Waypoint[]
|
||||
}
|
||||
|
||||
export default function FlightMap({ waypoints }: Props) {
|
||||
const center: [number, number] = waypoints.length > 0
|
||||
? [waypoints[0].latitude, waypoints[0].longitude]
|
||||
: [50.45, 30.52]
|
||||
|
||||
const positions = waypoints
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map(w => [w.latitude, w.longitude] as [number, number])
|
||||
|
||||
return (
|
||||
<MapContainer center={center} zoom={13} className="h-full w-full" key={center.join(',')}>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OSM</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
{waypoints.map(wp => (
|
||||
<Marker key={wp.id} position={[wp.latitude, wp.longitude]} icon={icon}>
|
||||
<Popup>{wp.name}</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
{positions.length > 1 && <Polyline positions={positions} color="#fd7e14" weight={2} />}
|
||||
</MapContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useFlight } from '../../components/FlightContext'
|
||||
import { api } from '../../api/client'
|
||||
import { createSSE } from '../../api/sse'
|
||||
import { useResizablePanel } from '../../hooks/useResizablePanel'
|
||||
import ConfirmDialog from '../../components/ConfirmDialog'
|
||||
import type { Flight, Waypoint, Aircraft } from '../../types'
|
||||
import FlightMap from './FlightMap'
|
||||
|
||||
export default function FlightsPage() {
|
||||
const { t } = useTranslation()
|
||||
const { flights, selectedFlight, selectFlight, refreshFlights } = useFlight()
|
||||
const [mode, setMode] = useState<'params' | 'gps'>('params')
|
||||
const [waypoints, setWaypoints] = useState<Waypoint[]>([])
|
||||
const [aircrafts, setAircrafts] = useState<Aircraft[]>([])
|
||||
const [liveGps, setLiveGps] = useState<{ lat: number; lon: number; satellites: number; status: string } | null>(null)
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
const [newName, setNewName] = useState('')
|
||||
const leftPanel = useResizablePanel(200, 150, 350)
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Aircraft[]>('/api/flights/aircrafts').then(setAircrafts).catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFlight) { setWaypoints([]); return }
|
||||
api.get<Waypoint[]>(`/api/flights/${selectedFlight.id}/waypoints`).then(setWaypoints).catch(() => {})
|
||||
}, [selectedFlight])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFlight || mode !== 'gps') return
|
||||
return createSSE(`/api/flights/${selectedFlight.id}/live-gps`, (data: any) => setLiveGps(data))
|
||||
}, [selectedFlight, mode])
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim()) return
|
||||
await api.post('/api/flights', { name: newName.trim() })
|
||||
setNewName('')
|
||||
refreshFlights()
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteId) return
|
||||
await api.delete(`/api/flights/${deleteId}`)
|
||||
if (selectedFlight?.id === deleteId) selectFlight(null)
|
||||
setDeleteId(null)
|
||||
refreshFlights()
|
||||
}
|
||||
|
||||
const handleAddWaypoint = async () => {
|
||||
if (!selectedFlight) return
|
||||
await api.post(`/api/flights/${selectedFlight.id}/waypoints`, {
|
||||
name: `Point ${waypoints.length}`,
|
||||
latitude: 50.45, longitude: 30.52, order: waypoints.length,
|
||||
})
|
||||
const wps = await api.get<Waypoint[]>(`/api/flights/${selectedFlight.id}/waypoints`)
|
||||
setWaypoints(wps)
|
||||
}
|
||||
|
||||
const handleDeleteWaypoint = async (wpId: string) => {
|
||||
if (!selectedFlight) return
|
||||
await api.delete(`/api/flights/${selectedFlight.id}/waypoints/${wpId}`)
|
||||
setWaypoints(prev => prev.filter(w => w.id !== wpId))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* Flight list sidebar */}
|
||||
<div style={{ width: leftPanel.width }} className="bg-az-panel border-r border-az-border flex flex-col shrink-0">
|
||||
<div className="p-2 border-b border-az-border">
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleCreate()}
|
||||
placeholder={t('flights.create')}
|
||||
className="flex-1 bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text outline-none"
|
||||
/>
|
||||
<button onClick={handleCreate} className="bg-az-orange text-white text-xs px-2 py-1 rounded">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{flights.map(f => (
|
||||
<div
|
||||
key={f.id}
|
||||
onClick={() => selectFlight(f)}
|
||||
className={`px-2 py-1.5 cursor-pointer border-b border-az-border text-sm ${
|
||||
selectedFlight?.id === f.id ? 'bg-az-bg text-white' : 'text-az-text hover:bg-az-bg'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="truncate">{f.name}</span>
|
||||
<button onClick={e => { e.stopPropagation(); setDeleteId(f.id) }} className="text-az-muted hover:text-az-red text-xs">×</button>
|
||||
</div>
|
||||
<div className="text-xs text-az-muted">{new Date(f.createdDate).toLocaleDateString()}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resize handle */}
|
||||
<div onMouseDown={leftPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" />
|
||||
|
||||
{/* Left params panel */}
|
||||
{selectedFlight && (
|
||||
<div className="w-64 bg-az-panel border-r border-az-border flex flex-col shrink-0 overflow-y-auto">
|
||||
<div className="flex border-b border-az-border">
|
||||
<button
|
||||
onClick={() => setMode('params')}
|
||||
className={`flex-1 py-1.5 text-xs ${mode === 'params' ? 'bg-az-bg text-white' : 'text-az-muted'}`}
|
||||
>
|
||||
{t('flights.params')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('gps')}
|
||||
className={`flex-1 py-1.5 text-xs ${mode === 'gps' ? 'bg-az-bg text-white' : 'text-az-muted'}`}
|
||||
>
|
||||
{t('flights.gpsDenied')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'params' && (
|
||||
<div className="p-2 space-y-2 text-xs">
|
||||
<div>
|
||||
<label className="text-az-muted block mb-0.5">{t('flights.aircraft')}</label>
|
||||
<select className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-az-text">
|
||||
{aircrafts.map(a => <option key={a.id} value={a.id}>{a.model}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-az-muted block mb-0.5">{t('flights.height')}</label>
|
||||
<input type="number" className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-az-text" defaultValue={100} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<label className="text-az-muted">{t('flights.waypoints')}</label>
|
||||
<button onClick={handleAddWaypoint} className="text-az-orange text-xs">+ Add</button>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{waypoints.map(wp => (
|
||||
<div key={wp.id} className="flex items-center justify-between bg-az-bg rounded px-1.5 py-0.5">
|
||||
<span className="text-az-text">{wp.name}</span>
|
||||
<button onClick={() => handleDeleteWaypoint(wp.id)} className="text-az-muted hover:text-az-red">×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'gps' && (
|
||||
<div className="p-2 space-y-2 text-xs">
|
||||
<div>
|
||||
<label className="text-az-muted block mb-1">{t('flights.liveGps')}</label>
|
||||
{liveGps ? (
|
||||
<div className="bg-az-bg rounded p-1.5 space-y-0.5">
|
||||
<div className="text-az-text">Status: <span className="text-az-green">{liveGps.status}</span></div>
|
||||
<div className="text-az-text">Lat: {liveGps.lat.toFixed(6)}</div>
|
||||
<div className="text-az-text">Lon: {liveGps.lon.toFixed(6)}</div>
|
||||
<div className="text-az-text">Sats: {liveGps.satellites}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-az-muted">Waiting for GPS signal...</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => setMode('params')} className="text-az-orange text-xs">
|
||||
← {t('flights.back')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Map view */}
|
||||
<div className="flex-1 relative">
|
||||
<FlightMap waypoints={waypoints} />
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteId}
|
||||
title={t('common.delete')}
|
||||
message="Delete this flight and all its data?"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteId(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../../auth/AuthContext'
|
||||
|
||||
type UnlockStep = 'idle' | 'authenticating' | 'downloadingKey' | 'decrypting' | 'startingServices' | 'ready'
|
||||
|
||||
const STEP_KEYS: Record<UnlockStep, string> = {
|
||||
idle: '',
|
||||
authenticating: 'login.authenticating',
|
||||
downloadingKey: 'login.downloadingKey',
|
||||
decrypting: 'login.decrypting',
|
||||
startingServices: 'login.startingServices',
|
||||
ready: 'login.ready',
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const { t } = useTranslation()
|
||||
const { login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [step, setStep] = useState<UnlockStep>('idle')
|
||||
|
||||
const runUnlockSequence = async () => {
|
||||
const steps: UnlockStep[] = ['downloadingKey', 'decrypting', 'startingServices', 'ready']
|
||||
for (const s of steps) {
|
||||
setStep(s)
|
||||
await new Promise(r => setTimeout(r, 600))
|
||||
}
|
||||
navigate('/flights')
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setStep('authenticating')
|
||||
try {
|
||||
await login(email, password)
|
||||
await runUnlockSequence()
|
||||
} catch {
|
||||
setStep('idle')
|
||||
setError(t('login.error'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-az-bg">
|
||||
<form onSubmit={handleSubmit} className="bg-az-panel border border-az-border rounded-lg p-6 w-[400px] shadow-2xl">
|
||||
<h1 className="text-2xl font-bold text-az-orange text-center mb-6 tracking-widest">{t('login.title')}</h1>
|
||||
|
||||
{step !== 'idle' && (
|
||||
<div className="mb-4 text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-6 w-6 border-2 border-az-orange border-t-transparent mb-2" />
|
||||
<div className="text-sm text-az-text">{t(STEP_KEYS[step])}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'idle' && (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<label className="block text-xs text-az-muted mb-1">{t('login.email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className="w-full bg-az-bg border border-az-border rounded px-3 py-2 text-az-text outline-none focus:border-az-orange"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs text-az-muted mb-1">{t('login.password')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="w-full bg-az-bg border border-az-border rounded px-3 py-2 text-az-text outline-none focus:border-az-orange"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="text-az-red text-sm mb-3">{error}</div>}
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-az-orange text-white font-semibold py-2 rounded hover:bg-orange-600 transition"
|
||||
>
|
||||
{t('login.submit')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { api } from '../../api/client'
|
||||
import type { SystemSettings, DirectorySettings, Aircraft } from '../../types'
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { t } = useTranslation()
|
||||
const [system, setSystem] = useState<SystemSettings | null>(null)
|
||||
const [dirs, setDirs] = useState<DirectorySettings | null>(null)
|
||||
const [aircrafts, setAircrafts] = useState<Aircraft[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api.get<SystemSettings>('/api/annotations/settings/system').then(setSystem).catch(() => {})
|
||||
api.get<DirectorySettings>('/api/annotations/settings/directories').then(setDirs).catch(() => {})
|
||||
api.get<Aircraft[]>('/api/flights/aircrafts').then(setAircrafts).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const saveSystem = async () => {
|
||||
if (!system) return
|
||||
setSaving(true)
|
||||
await api.put('/api/annotations/settings/system', system)
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
const saveDirs = async () => {
|
||||
if (!dirs) return
|
||||
setSaving(true)
|
||||
await api.put('/api/annotations/settings/directories', dirs)
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
const handleToggleDefault = async (a: Aircraft) => {
|
||||
await api.patch(`/api/flights/aircrafts/${a.id}`, { isDefault: !a.isDefault })
|
||||
setAircrafts(prev => prev.map(x => x.id === a.id ? { ...x, isDefault: !x.isDefault } : x))
|
||||
}
|
||||
|
||||
const field = (label: string, value: string | number | null | undefined, onChange: (v: string) => void, type = 'text') => (
|
||||
<div>
|
||||
<label className="text-az-muted text-xs block mb-0.5">{label}</label>
|
||||
<input
|
||||
type={type}
|
||||
value={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"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-y-auto p-4 gap-6">
|
||||
{/* Tenant config */}
|
||||
<div className="w-[300px] shrink-0">
|
||||
<h2 className="text-sm font-semibold text-white mb-2">{t('settings.tenant')}</h2>
|
||||
{system && (
|
||||
<div className="bg-az-panel border border-az-border rounded p-3 space-y-2">
|
||||
{field('Military Unit', system.militaryUnit, v => setSystem(p => p ? { ...p, militaryUnit: v } : p))}
|
||||
{field('Name', system.name, v => setSystem(p => p ? { ...p, name: v } : p))}
|
||||
{field('Default Camera Width', system.defaultCameraWidth, v => setSystem(p => p ? { ...p, defaultCameraWidth: parseInt(v) || 0 } : p), 'number')}
|
||||
{field('Default Camera FoV', system.defaultCameraFoV, v => setSystem(p => p ? { ...p, defaultCameraFoV: parseFloat(v) || 0 } : p), 'number')}
|
||||
<button onClick={saveSystem} disabled={saving} className="bg-az-orange text-white text-xs px-3 py-1 rounded disabled:opacity-50">
|
||||
{t('settings.save')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Directories */}
|
||||
<div className="w-[300px] shrink-0">
|
||||
<h2 className="text-sm font-semibold text-white mb-2">{t('settings.directories')}</h2>
|
||||
{dirs && (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user