Enhance annotations: save/download, fallback classes, photo mode icons

Add local annotation save fallback, PNG+txt download with drawn boxes,
shared classColors helper, photo mode icon toggles, and react-dropzone
/ react-icons dependencies.
This commit is contained in:
Armen Rohalov
2026-04-17 23:33:00 +03:00
parent 567092188d
commit 63cc18e788
15 changed files with 782 additions and 218 deletions
+129 -33
View File
@@ -1,9 +1,11 @@
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useDropzone } from 'react-dropzone'
import { useFlight } from '../../components/FlightContext'
import { api } from '../../api/client'
import { useDebounce } from '../../hooks/useDebounce'
import ConfirmDialog from '../../components/ConfirmDialog'
import { MediaType } from '../../types'
import type { Media, PaginatedResponse, AnnotationListItem } from '../../types'
interface Props {
@@ -19,7 +21,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
const [filter, setFilter] = useState('')
const debouncedFilter = useDebounce(filter, 300)
const [deleteId, setDeleteId] = useState<string | null>(null)
const [dragging, setDragging] = useState(false)
const folderInputRef = useRef<HTMLInputElement>(null)
const fetchMedia = useCallback(async () => {
const params = new URLSearchParams({ pageSize: '1000' })
@@ -27,57 +29,124 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
if (debouncedFilter) params.set('name', debouncedFilter)
try {
const res = await api.get<PaginatedResponse<Media>>(`/api/annotations/media?${params}`)
setMedia(res.items)
} catch {}
setMedia(prev => {
// Keep local-only (blob URL) entries, merge with backend entries
const local = prev.filter(m => m.path.startsWith('blob:'))
return [...local, ...res.items]
})
} catch {
// backend unavailable — keep local-only entries
}
}, [selectedFlight, debouncedFilter])
useEffect(() => { fetchMedia() }, [fetchMedia])
useEffect(() => {
return () => {
for (const m of media) if (m.path.startsWith('blob:')) URL.revokeObjectURL(m.path)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleSelect = async (m: Media) => {
onSelect(m)
if (m.path.startsWith('blob:')) {
onAnnotationsLoaded([])
return
}
try {
const res = await api.get<PaginatedResponse<AnnotationListItem>>(
`/api/annotations/annotations?mediaId=${m.id}&pageSize=1000`
)
onAnnotationsLoaded(res.items)
} catch {}
} catch {
onAnnotationsLoaded([])
}
}
const handleDelete = async () => {
if (!deleteId) return
await api.delete(`/api/annotations/media/${deleteId}`)
const target = media.find(m => m.id === deleteId)
if (target?.path.startsWith('blob:')) {
URL.revokeObjectURL(target.path)
setMedia(prev => prev.filter(m => m.id !== deleteId))
setDeleteId(null)
return
}
try { await api.delete(`/api/annotations/media/${deleteId}`) } catch {}
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 uploadFiles = useCallback(async (files: File[] | FileList) => {
if (!files.length) return
const arr = Array.from(files)
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()
// Try backend first
if (selectedFlight) {
try {
const form = new FormData()
form.append('waypointId', '')
for (const file of arr) form.append('files', file)
await api.upload('/api/annotations/media/batch', form)
fetchMedia()
return
} catch {
// fall through to local mode
}
}
// Local mode: add blob URL entries to state
const videoExts = /\.(mp4|mov|webm|mkv|avi|m4v|ogg|ogv)$/i
const imageExts = /\.(jpe?g|png|webp|gif|bmp|tiff?)$/i
const accepted: File[] = []
const rejected: string[] = []
for (const file of arr) {
const isVideo = file.type.startsWith('video/') || videoExts.test(file.name)
const isImage = file.type.startsWith('image/') || imageExts.test(file.name)
if (isVideo || isImage) accepted.push(file)
else rejected.push(file.name)
}
if (rejected.length) {
alert(`Unsupported file type (video/image only):\n${rejected.join('\n')}`)
}
const localItems: Media[] = accepted.map(file => {
const isVideo = file.type.startsWith('video/') || videoExts.test(file.name)
return {
id: `local-${crypto.randomUUID()}`,
name: file.name,
path: URL.createObjectURL(file),
mediaType: isVideo ? MediaType.Video : MediaType.Image,
mediaStatus: 0,
duration: null,
annotationCount: 0,
waypointId: null,
userId: 'local',
}
})
setMedia(prev => [...localItems, ...prev])
}, [selectedFlight, fetchMedia])
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop: uploadFiles,
multiple: true,
noClick: true,
noKeyboard: true,
})
const handleFolderInput = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files?.length) uploadFiles(e.target.files)
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}
{...getRootProps({
className: `flex-1 flex flex-col overflow-hidden ${isDragActive ? 'ring-2 ring-az-orange ring-inset' : ''}`,
})}
>
<input {...getInputProps()} />
<div className="p-2 border-b border-az-border flex gap-1">
<input
value={filter}
@@ -85,13 +154,40 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
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} />
</div>
<div className="px-2 pt-2 pb-2 flex gap-1">
<label className="flex-1 bg-az-orange text-white text-[10px] py-1 rounded text-center cursor-pointer hover:brightness-110">
Open File
<input
type="file"
multiple
className="hidden"
onChange={e => {
if (e.target.files?.length) uploadFiles(e.target.files)
e.target.value = ''
}}
/>
</label>
<button
type="button"
onClick={() => folderInputRef.current?.click()}
className="flex-1 bg-az-orange text-white text-[10px] py-1 rounded hover:brightness-110"
>
Open Folder
</button>
<input
ref={folderInputRef}
type="file"
multiple
className="hidden"
// @ts-expect-error webkitdirectory is non-standard but widely supported
webkitdirectory=""
directory=""
onChange={handleFolderInput}
/>
</div>
<div className="flex-1 overflow-y-auto">
{media.map(m => (
{media.filter(m => m.name.toLowerCase().includes(filter.toLowerCase())).map(m => (
<div
key={m.id}
onClick={() => handleSelect(m)}
@@ -100,8 +196,8 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
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 className={`font-mono text-[10px] px-1 rounded ${m.mediaType === MediaType.Video ? 'bg-az-blue/20 text-az-blue' : 'bg-az-green/20 text-az-green'}`}>
{m.mediaType === MediaType.Video ? 'V' : 'P'}
</span>
<span className="truncate flex-1">{m.name}</span>
{m.duration && <span className="text-az-muted">{m.duration}</span>}