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:
Oleksandr Bezdieniezhnykh
2026-03-25 03:10:15 +02:00
parent e407308284
commit 157a33096a
112 changed files with 6530 additions and 17843 deletions
@@ -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>
)
}
+347
View File
@@ -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>
)
}
+119
View File
@@ -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>
)
}
+111
View File
@@ -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>
)
}