mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 08:11:10 +00:00
f754afff46
ci/woodpecker/push/build-arm Pipeline failed
Reskin to v2 surface/accent tokens + JetBrains Mono headings to match _docs/ui_design/v2/plugin/annotations.html. Add scrubber with class-colored annotation marks, canvas top bar (zoom/cursor/dims), floating AI-detection banner, multi-band gradient rows in the annotations sidebar, class-distribution summary footer, and DOM-overlay bbox labels with affiliation icon + readiness dot. Split VideoPlayer chrome out into the page-level controls row (transport/frame-step/save/delete/AI-detect/mute/volume) and a new Scrubber component; player events replace 200ms polling. Other: - Auth dev bypass via VITE_DEV_AUTH_BYPASS (gated on import.meta.env.DEV). - Mount SavedAnnotationsProvider in App so AnnotationsPage doesn't crash. - Extract hexToRgba to src/class-colors and time helpers to src/features/annotations/time.ts (dedup across CanvasEditor / Sidebar / AnnotationsPage). - CanvasEditor: shallow-compare label chips before commit, NaN-guard annotation-time parser, cancel cursor RAF on unmount. - AnnotationsPage: track AI-banner close timer, push initial volume to the <video> on media change, drop the duplicate parent muted state. - Fixed sidebar widths (resize handles removed per design).
272 lines
9.1 KiB
TypeScript
272 lines
9.1 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { useDropzone } from 'react-dropzone'
|
|
import { useFlight, ConfirmDialog } from '../../components'
|
|
import { api, endpoints } from '../../api'
|
|
import { useDebounce } from '../../hooks'
|
|
import { MediaType } from '../../types'
|
|
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 folderInputRef = useRef<HTMLInputElement>(null)
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
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>>(endpoints.annotations.media(params.toString()))
|
|
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>>(
|
|
endpoints.annotations.annotationsByMedia(m.id),
|
|
)
|
|
onAnnotationsLoaded(res.items)
|
|
} catch {
|
|
onAnnotationsLoaded([])
|
|
}
|
|
}
|
|
|
|
const handleDelete = async () => {
|
|
if (!deleteId) return
|
|
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(endpoints.annotations.mediaItem(deleteId)) } catch {}
|
|
setDeleteId(null)
|
|
fetchMedia()
|
|
}
|
|
|
|
const uploadFiles = useCallback(async (files: File[] | FileList) => {
|
|
if (!files.length) return
|
|
const arr = Array.from(files)
|
|
|
|
// 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(endpoints.annotations.mediaBatch(), 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 = ''
|
|
}
|
|
|
|
const filtered = media.filter(m => m.name.toLowerCase().includes(filter.toLowerCase()))
|
|
|
|
return (
|
|
<div
|
|
{...getRootProps({
|
|
className: `flex flex-col flex-1 min-h-0 bg-surface-1${isDragActive ? ' ring-2 ring-accent-amber ring-inset' : ''}`,
|
|
})}
|
|
>
|
|
{/* Dropzone hidden input */}
|
|
<input {...getInputProps()} />
|
|
|
|
{/* Hidden file inputs */}
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
className="hidden"
|
|
onChange={e => {
|
|
if (e.target.files?.length) uploadFiles(e.target.files)
|
|
e.target.value = ''
|
|
}}
|
|
/>
|
|
<input
|
|
ref={folderInputRef}
|
|
type="file"
|
|
multiple
|
|
className="hidden"
|
|
// @ts-expect-error webkitdirectory is non-standard but widely supported
|
|
webkitdirectory=""
|
|
directory=""
|
|
onChange={handleFolderInput}
|
|
/>
|
|
|
|
{/* Header row */}
|
|
<div className="flex items-center justify-between px-3 h-9 border-b border-border-hair shrink-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="sect-head">{t('annotations.mediaList')}</span>
|
|
<span className="mono text-[10px] text-text-muted">{filtered.length}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
{/* Upload file button */}
|
|
<button
|
|
type="button"
|
|
className="ibtn"
|
|
style={{ width: 22, height: 22 }}
|
|
title={t('annotations.upload')}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
>
|
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M12 5v14M5 12h14"/>
|
|
</svg>
|
|
</button>
|
|
{/* Open folder button */}
|
|
<button
|
|
type="button"
|
|
className="ibtn"
|
|
style={{ width: 22, height: 22 }}
|
|
title="Open Folder"
|
|
onClick={() => folderInputRef.current?.click()}
|
|
>
|
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filter input row */}
|
|
<div className="px-3 py-2 border-b border-border-hair shrink-0">
|
|
<div className="relative">
|
|
<svg
|
|
width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
|
className="absolute left-2 top-1/2 -translate-y-1/2 text-text-muted pointer-events-none"
|
|
>
|
|
<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/>
|
|
</svg>
|
|
<input
|
|
className="inp w-full pl-7"
|
|
style={{ height: 28, padding: '0 10px 0 28px' }}
|
|
value={filter}
|
|
onChange={e => setFilter(e.target.value)}
|
|
placeholder={t('annotations.filterByName')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* List */}
|
|
<div className="flex-1 overflow-y-auto min-h-0">
|
|
{filtered.map(m => {
|
|
const isActive = selectedMedia?.id === m.id
|
|
const isVideo = m.mediaType === MediaType.Video
|
|
const hasDuration = !!m.duration
|
|
const durationColor = isActive
|
|
? 'text-accent-amber'
|
|
: hasDuration
|
|
? 'text-text-secondary'
|
|
: 'text-text-muted'
|
|
|
|
return (
|
|
<div
|
|
key={m.id}
|
|
onClick={() => handleSelect(m)}
|
|
onContextMenu={e => { e.preventDefault(); setDeleteId(m.id) }}
|
|
className={`media-row${isActive ? ' active' : ''}`}
|
|
>
|
|
{isVideo
|
|
? <span className="chip-video">VIDEO</span>
|
|
: <span className="chip-photo">PHOTO</span>
|
|
}
|
|
<span className={`truncate${isActive ? ' font-medium text-text-primary' : ' text-text-primary'}`}>
|
|
{m.name}
|
|
</span>
|
|
<span className={`mono text-[11px] ${durationColor}`}>
|
|
{m.duration ?? '—'}
|
|
</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
<ConfirmDialog
|
|
open={!!deleteId}
|
|
title={t('annotations.deleteMedia')}
|
|
onConfirm={handleDelete}
|
|
onCancel={() => setDeleteId(null)}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|