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([]) const [filter, setFilter] = useState('') const debouncedFilter = useDebounce(filter, 300) const [deleteId, setDeleteId] = useState(null) const folderInputRef = useRef(null) const fileInputRef = useRef(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>(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>( 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) => { if (e.target.files?.length) uploadFiles(e.target.files) e.target.value = '' } const filtered = media.filter(m => m.name.toLowerCase().includes(filter.toLowerCase())) return (
{/* Dropzone hidden input */} {/* Hidden file inputs */} { if (e.target.files?.length) uploadFiles(e.target.files) e.target.value = '' }} /> {/* Header row */}
{t('annotations.mediaList')} {filtered.length}
{/* Upload file button */} {/* Open folder button */}
{/* Filter input row */}
setFilter(e.target.value)} placeholder={t('annotations.filterByName')} />
{/* List */}
{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 (
handleSelect(m)} onContextMenu={e => { e.preventDefault(); setDeleteId(m.id) }} className={`media-row${isActive ? ' active' : ''}`} > {isVideo ? VIDEO : PHOTO } {m.name} {m.duration ?? '—'}
) })}
setDeleteId(null)} />
) }