2 Commits

Author SHA1 Message Date
Armen Rohalov dfcdc26630 dataset v2: code-review fixes
ci/woodpecker/push/build-arm Pipeline failed
- Guard global class-list keydown against input focus (digits in search/dates no longer hijack the class filter)
- Relabel null-value status chip "None" → "All" to match its show-all behavior
- Filter savedAnnotations by selectedFlight when computing grandTotal
- Preserve seed indicator under selection (amber border + red ring)
- Reset to page 1 after bulk validate so the user isn't stranded
- Remove always-disabled Refresh Thumbnails button
- Lift class-distribution fetch into DatasetPage; pass counts down (one fetch, shared by sidebar and chart)
- Hoist Intl.DateTimeFormat to module scope; cache tile date per render
2026-05-29 02:15:23 +03:00
Armen Rohalov 60d77d0f29 dataset v2: implement redesign
Split the monolithic DatasetPage into an orchestrator plus DatasetLeftPanel,
DatasetFilterBar, DatasetClassList, DatasetTile, and DatasetStatusBar.
Migrated every az-* legacy token to v2 surface / accent / border / text-text
utilities. Built a dataset-specific class list (counts instead of keycaps,
no photo-mode control) rather than reusing the shared DetectionClasses,
which targets the annotations page. Added LIVE SYNC indicator, tab badges,
hover-revealed tile edit button, composite tile scrim with grid lines, and
amber primary Validate button. Date pickers hide the native calendar icon
while staying click-to-open.
2026-05-29 02:05:24 +03:00
9 changed files with 879 additions and 220 deletions
+94
View File
@@ -0,0 +1,94 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { api, endpoints } from '../../api'
import { getClassColor, FALLBACK_CLASS_NAMES } from '../../class-colors'
import type { DetectionClass } from '../../types'
const FALLBACK_CLASSES: DetectionClass[] = FALLBACK_CLASS_NAMES.map((name, i) => ({
id: i + 1,
name,
shortName: name.slice(0, 3),
color: getClassColor(i),
maxSizeM: 10,
photoMode: 0,
}))
interface DatasetClassListProps {
selectedClassNum: number
onSelect: (classNum: number) => void
counts: Record<number, number>
}
export default function DatasetClassList({ selectedClassNum, onSelect, counts }: DatasetClassListProps) {
const { t } = useTranslation()
const [classes, setClasses] = useState<DetectionClass[]>([])
useEffect(() => {
api.get<DetectionClass[]>(endpoints.annotations.classes())
.then(list => setClasses(list?.length ? list : FALLBACK_CLASSES))
.catch(() => setClasses(FALLBACK_CLASSES))
}, [])
const regularClasses = useMemo(() => classes.filter(c => c.photoMode === 0), [classes])
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const t = e.target as HTMLElement | null
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return
const num = parseInt(e.key)
if (num >= 1 && num <= 9) {
const cls = regularClasses[num - 1]
if (cls) onSelect(cls.id)
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [regularClasses, onSelect])
return (
<>
<div className="px-3 pt-3 pb-2 flex items-center justify-between border-b border-border-hair shrink-0">
<span className="sect-head" style={{ lineHeight: 1.2 }}>{t('annotations.classes')}</span>
<span className="mono text-[10px] text-text-muted tabular-nums">
{regularClasses.length.toString().padStart(2, '0')}
</span>
</div>
<div
className="px-2 py-2 flex flex-col gap-0.5 overflow-y-auto"
style={{ maxHeight: '46vh' }}
>
{regularClasses.map(c => {
const isActive = c.id === selectedClassNum
const count = counts[c.id] ?? 0
return (
<div
key={c.id}
role="button"
tabIndex={0}
onClick={() => onSelect(c.id)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') onSelect(c.id) }}
className={`flex items-center gap-2.5 h-7 px-2 rounded-[2px] cursor-pointer transition-colors ${
isActive
? 'bg-surface-2 text-text-primary'
: 'text-text-secondary hover:bg-surface-2 hover:text-text-primary'
}`}
>
<span className="swatch shrink-0" style={{ background: c.color }} />
<span className="text-[12px] truncate flex-1">{c.name}</span>
<span
className={`font-mono font-medium text-[10px] tabular-nums leading-none rounded-[2px] border bg-surface-input ${
isActive
? 'text-accent-amber border-accent-amber'
: 'text-text-secondary border-border-hair'
}`}
style={{ padding: '2px 6px' }}
>
{count.toLocaleString()}
</span>
</div>
)
})}
</div>
</>
)
}
+187
View File
@@ -0,0 +1,187 @@
import { useTranslation } from 'react-i18next'
import { AnnotationStatus } from '../../types'
interface DatasetFilterBarProps {
fromDate: string
toDate: string
onFromDateChange: (v: string) => void
onToDateChange: (v: string) => void
statusFilter: AnnotationStatus | null
onStatusFilterChange: (s: AnnotationStatus | null) => void
flightName: string | null
shownCount: number
totalCount: number
}
export default function DatasetFilterBar({
fromDate,
toDate,
onFromDateChange,
onToDateChange,
statusFilter,
onStatusFilterChange,
flightName,
shownCount,
totalCount,
}: DatasetFilterBarProps) {
const { t } = useTranslation()
const STATUS_OPTIONS = [
{
value: null,
label: t('dataset.status.all'),
tone: 'muted' as const,
dot: 'var(--text-muted)',
},
{
value: AnnotationStatus.Created,
label: t('dataset.status.created'),
tone: 'amber' as const,
dot: 'var(--accent-amber)',
},
{
value: AnnotationStatus.Edited,
label: t('dataset.status.edited'),
tone: 'blue' as const,
dot: 'var(--accent-blue)',
},
{
value: AnnotationStatus.Validated,
label: t('dataset.status.validated'),
tone: 'green' as const,
dot: 'var(--accent-green)',
},
]
return (
<div
className="bracket panel relative flex items-center gap-3 px-3 shrink-0"
style={{ height: 48 }}
>
<span className="br" />
{/* Range group */}
<div className="flex items-center gap-2">
<span className="micro">{t('dataset.range')}</span>
<input
type="date"
className="inp inp-mono cursor-pointer [&::-webkit-calendar-picker-indicator]:opacity-0 [&::-webkit-calendar-picker-indicator]:absolute [&::-webkit-calendar-picker-indicator]:inset-0 [&::-webkit-calendar-picker-indicator]:w-full [&::-webkit-calendar-picker-indicator]:h-full [&::-webkit-calendar-picker-indicator]:cursor-pointer"
style={{ width: 104, height: 28, padding: '0 10px' }}
value={fromDate}
onChange={e => onFromDateChange(e.target.value)}
onClick={e => e.currentTarget.showPicker?.()}
/>
<span className="mono text-text-muted"></span>
<input
type="date"
className="inp inp-mono cursor-pointer [&::-webkit-calendar-picker-indicator]:opacity-0 [&::-webkit-calendar-picker-indicator]:absolute [&::-webkit-calendar-picker-indicator]:inset-0 [&::-webkit-calendar-picker-indicator]:w-full [&::-webkit-calendar-picker-indicator]:h-full [&::-webkit-calendar-picker-indicator]:cursor-pointer"
style={{ width: 104, height: 28, padding: '0 10px' }}
value={toDate}
onChange={e => onToDateChange(e.target.value)}
onClick={e => e.currentTarget.showPicker?.()}
/>
</div>
{/* divider */}
<span className="w-px h-5 bg-border-hair shrink-0" />
{/* Flight group — display-only chip */}
<div className="flex items-center gap-2">
<span className="micro">{t('dataset.flight')}</span>
<div
className="inp inline-flex items-center gap-2"
style={{ padding: '0 10px', height: 28, cursor: 'default' }}
>
<span className="w-1.5 h-1.5 rounded-full bg-accent-amber" />
<span className="mono text-[12px] text-text-primary tracking-wider">
{flightName ?? '—'}
</span>
<span className="text-[10px] text-text-muted ml-1"></span>
</div>
</div>
{/* divider */}
<span className="w-px h-5 bg-border-hair shrink-0" />
{/* Status chips */}
<div className="flex items-center gap-1.5">
<span className="micro mr-1">{t('dataset.statusLabel')}</span>
{STATUS_OPTIONS.map(opt => {
const isActive = statusFilter === opt.value
const stateCls = !isActive
? 'text-text-secondary border-border-hair hover:text-text-primary hover:border-border-raised'
: opt.tone === 'muted'
? 'text-text-primary border-border-raised bg-text-muted/20'
: opt.tone === 'amber'
? 'text-accent-amber border-accent-amber bg-accent-amber/10'
: opt.tone === 'blue'
? 'text-accent-blue border-accent-blue bg-accent-blue/10'
: /* green */ 'text-accent-green border-accent-green bg-accent-green/10'
return (
<button
key={String(opt.value)}
type="button"
onClick={() => onStatusFilterChange(opt.value)}
className={`inline-flex items-center gap-1.5 h-6 px-2.5 rounded-[2px] border font-mono text-[10px] font-semibold uppercase tracking-widest cursor-pointer transition-colors ${stateCls}`}
>
<span
className="rounded-full shrink-0"
style={{ width: 6, height: 6, background: opt.dot }}
/>
{opt.label}
</button>
)
})}
</div>
{/* right side */}
<div className="ml-auto flex items-center gap-3">
<span className="micro" style={{ color: 'var(--text-muted)' }}>
{t('dataset.showing')}
</span>
<span className="mono text-[12px] text-text-primary tabular-nums">
{shownCount.toLocaleString()}
<span className="text-text-muted"> / {totalCount.toLocaleString()}</span>
</span>
<span className="w-px h-5 bg-border-hair shrink-0" />
<button
type="button"
title={t('dataset.sort')}
className="w-7 h-7 inline-flex items-center justify-center border border-border-hair rounded-[2px] text-text-secondary hover:text-text-primary hover:border-border-raised transition-colors"
disabled
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
>
<path d="M3 6h18M6 12h12M10 18h4" />
</svg>
</button>
<button
type="button"
title={t('dataset.gridDensity')}
className="w-7 h-7 inline-flex items-center justify-center border border-border-hair rounded-[2px] text-text-secondary hover:text-text-primary hover:border-border-raised transition-colors"
disabled
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
</svg>
</button>
</div>
</div>
)
}
+108
View File
@@ -0,0 +1,108 @@
import { useTranslation } from 'react-i18next'
import DatasetClassList from './DatasetClassList'
interface DatasetLeftPanelProps {
selectedClassNum: number
onSelectClass: (n: number) => void
classCounts: Record<number, number>
objectsOnly: boolean
onObjectsOnlyChange: (v: boolean) => void
search: string
onSearchChange: (v: string) => void
totalCount: number
validatedCount: number
}
export default function DatasetLeftPanel({
selectedClassNum,
onSelectClass,
classCounts,
objectsOnly,
onObjectsOnlyChange,
search,
onSearchChange,
totalCount,
validatedCount,
}: DatasetLeftPanelProps) {
const { t } = useTranslation()
return (
<aside className="bracket panel flex flex-col shrink-0" style={{ width: 250 }}>
<span className="br" />
<DatasetClassList
selectedClassNum={selectedClassNum}
onSelect={onSelectClass}
counts={classCounts}
/>
<div className="mt-auto border-t border-border-hair px-3 py-3 flex flex-col gap-3">
<span className="micro">{t('dataset.filters')}</span>
<div className="flex items-center justify-between">
<div className="flex flex-col">
<span className="text-[12px] text-text-primary">{t('dataset.objectsOnly')}</span>
<span className="text-[10px] text-text-muted">{t('dataset.hideEmpty')}</span>
</div>
<button
type="button"
role="switch"
aria-checked={objectsOnly}
onClick={() => onObjectsOnlyChange(!objectsOnly)}
className={`relative shrink-0 rounded-[2px] border transition-colors ${
objectsOnly
? 'border-accent-amber bg-accent-amber/20'
: 'border-border-hair bg-surface-0'
}`}
style={{ width: 30, height: 16 }}
>
<span
className={`absolute top-px left-px block rounded-[2px] transition-transform ${
objectsOnly ? 'bg-accent-amber' : 'bg-text-muted'
}`}
style={{
width: 12,
height: 12,
transform: objectsOnly ? 'translateX(14px)' : 'translateX(0)',
}}
/>
</button>
</div>
<div className="relative">
<svg
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted pointer-events-none"
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
>
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
className="inp w-full"
style={{ height: 28, padding: '0 10px 0 28px' }}
placeholder={t('dataset.search')}
value={search}
onChange={e => onSearchChange(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-2 pt-1">
<div className="border border-border-hair rounded-[2px] p-2">
<div className="micro" style={{ color: 'var(--text-muted)' }}>{t('dataset.total')}</div>
<div className="mono text-[15px] text-text-primary">{totalCount.toLocaleString()}</div>
</div>
<div className="border border-border-hair rounded-[2px] p-2">
<div className="micro" style={{ color: 'var(--text-muted)' }}>{t('dataset.validatedCount')}</div>
<div className="mono text-[15px] text-accent-green">{validatedCount.toLocaleString()}</div>
</div>
</div>
</div>
</aside>
)
}
+242 -215
View File
@@ -1,30 +1,25 @@
import { useState, useEffect, useCallback, useMemo } from 'react' import { useState, useEffect, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { FaPen } from 'react-icons/fa'
import { api, endpoints } from '../../api' import { api, endpoints } from '../../api'
import { useDebounce, useResizablePanel } from '../../hooks' import { useDebounce } from '../../hooks'
import { useFlight, DetectionClasses } from '../../components' import { useFlight } from '../../components'
import { useSavedAnnotations } from '../../components/SavedAnnotationsContext' import { useSavedAnnotations } from '../../components/SavedAnnotationsContext'
import CanvasEditor from '../annotations/CanvasEditor' import CanvasEditor from '../annotations/CanvasEditor'
import { recaptureThumbnails } from '../annotations/thumbnail' import { recaptureThumbnails } from '../annotations/thumbnail'
import type { SavedDetection } from '../../components/SavedAnnotationsContext' import type { SavedDetection } from '../../components/SavedAnnotationsContext'
import type { DatasetItem, PaginatedResponse, ClassDistributionItem, AnnotationListItem, Detection, Media } from '../../types' import type {
DatasetItem,
PaginatedResponse,
ClassDistributionItem,
AnnotationListItem,
Detection,
Media,
} from '../../types'
import { AnnotationSource, AnnotationStatus } from '../../types' import { AnnotationSource, AnnotationStatus } from '../../types'
import DatasetLeftPanel from './DatasetLeftPanel'
interface DatasetCard { import DatasetFilterBar from './DatasetFilterBar'
annotationId: string import DatasetTile, { type DatasetCard } from './DatasetTile'
imageName: string import DatasetStatusBar from './DatasetStatusBar'
status: AnnotationStatus
createdDate: string
thumbnailUrl: string
isSeed: boolean
isLocal: boolean
detections?: Detection[]
mediaId?: string
time?: string | null
fullFrame?: string
annotationLocalId?: string
}
type Tab = 'annotations' | 'editor' | 'distribution' type Tab = 'annotations' | 'editor' | 'distribution'
@@ -32,7 +27,6 @@ export default function DatasetPage() {
const { t } = useTranslation() const { t } = useTranslation()
const { selectedFlight } = useFlight() const { selectedFlight } = useFlight()
const { saved: savedAnnotations, removeSaved, replaceGroup, updateStatus } = useSavedAnnotations() const { saved: savedAnnotations, removeSaved, replaceGroup, updateStatus } = useSavedAnnotations()
const leftPanel = useResizablePanel(250, 200, 400)
const [items, setItems] = useState<DatasetItem[]>([]) const [items, setItems] = useState<DatasetItem[]>([])
const [totalCount, setTotalCount] = useState(0) const [totalCount, setTotalCount] = useState(0)
@@ -45,12 +39,14 @@ export default function DatasetPage() {
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const debouncedSearch = useDebounce(search, 400) const debouncedSearch = useDebounce(search, 400)
const [selectedClassNum, setSelectedClassNum] = useState(0) const [selectedClassNum, setSelectedClassNum] = useState(0)
const [photoMode, setPhotoMode] = useState(0)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()) const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [tab, setTab] = useState<Tab>('annotations') const [tab, setTab] = useState<Tab>('annotations')
const [editorAnnotation, setEditorAnnotation] = useState<AnnotationListItem | null>(null) const [editorAnnotation, setEditorAnnotation] = useState<AnnotationListItem | null>(null)
const [editorDetections, setEditorDetections] = useState<Detection[]>([]) const [editorDetections, setEditorDetections] = useState<Detection[]>([])
const [distribution, setDistribution] = useState<ClassDistributionItem[]>([]) const [distribution, setDistribution] = useState<ClassDistributionItem[]>([])
const [editorFullFrame, setEditorFullFrame] = useState<string>('')
const [editorLocalGroupId, setEditorLocalGroupId] = useState<string | null>(null)
const [editorSaving, setEditorSaving] = useState(false)
const fetchItems = useCallback(async () => { const fetchItems = useCallback(async () => {
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) }) const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
@@ -107,11 +103,7 @@ export default function DatasetPage() {
})) }))
return [...localCards, ...remoteCards] return [...localCards, ...remoteCards]
}, [savedAnnotations, items, selectedFlight, statusFilter, objectsOnly, selectedClassNum, debouncedSearch, fromDate, toDate]) }, [savedAnnotations, items, selectedFlight, statusFilter, selectedClassNum, debouncedSearch, fromDate, toDate])
const [editorFullFrame, setEditorFullFrame] = useState<string>('')
const [editorLocalGroupId, setEditorLocalGroupId] = useState<string | null>(null)
const [editorSaving, setEditorSaving] = useState(false)
const handleDoubleClick = async (card: DatasetCard) => { const handleDoubleClick = async (card: DatasetCard) => {
if (card.isLocal && card.detections && card.mediaId) { if (card.isLocal && card.detections && card.mediaId) {
@@ -151,7 +143,7 @@ export default function DatasetPage() {
const existing = savedAnnotations.find(s => s.annotationLocalId === editorLocalGroupId) const existing = savedAnnotations.find(s => s.annotationLocalId === editorLocalGroupId)
const thumbs = await recaptureThumbnails(editorFullFrame, editorDetections) const thumbs = await recaptureThumbnails(editorFullFrame, editorDetections)
const now = new Date().toISOString() const now = new Date().toISOString()
const items: SavedDetection[] = editorDetections.map((d, i) => ({ const replacement: SavedDetection[] = editorDetections.map((d, i) => ({
id: `${editorLocalGroupId}:${d.id ?? i}`, id: `${editorLocalGroupId}:${d.id ?? i}`,
annotationLocalId: editorLocalGroupId, annotationLocalId: editorLocalGroupId,
mediaId: editorAnnotation.mediaId, mediaId: editorAnnotation.mediaId,
@@ -165,7 +157,7 @@ export default function DatasetPage() {
time: editorAnnotation.time, time: editorAnnotation.time,
flightId: existing?.flightId ?? null, flightId: existing?.flightId ?? null,
})) }))
replaceGroup(editorLocalGroupId, items) replaceGroup(editorLocalGroupId, replacement)
} }
setTab('annotations') setTab('annotations')
} finally { } finally {
@@ -196,114 +188,147 @@ export default function DatasetPage() {
updateStatus(localIds, AnnotationStatus.Validated) updateStatus(localIds, AnnotationStatus.Validated)
} }
setSelectedIds(new Set()) setSelectedIds(new Set())
setPage(1)
fetchItems() fetchItems()
} }
const loadDistribution = useCallback(async () => { useEffect(() => {
try { api.get<ClassDistributionItem[]>(endpoints.annotations.datasetClassDistribution())
const data = await api.get<ClassDistributionItem[]>(endpoints.annotations.datasetClassDistribution()) .then(setDistribution)
setDistribution(data) .catch(() => {})
} catch {}
}, []) }, [])
useEffect(() => { if (tab === 'distribution') loadDistribution() }, [tab, loadDistribution]) const classCounts = useMemo(() => {
const m: Record<number, number> = {}
for (const d of distribution) m[d.classNum] = d.count
return m
}, [distribution])
const maxDistCount = Math.max(...distribution.map(d => d.count), 1) const maxDistCount = useMemo(
() => Math.max(...distribution.map(d => d.count), 1),
[distribution],
)
const totalPages = Math.ceil(totalCount / pageSize) const totalPages = Math.ceil(totalCount / pageSize)
const relevantSavedCount = useMemo(() => {
if (!selectedFlight) return savedAnnotations.length
return savedAnnotations.filter(sd => !sd.flightId || sd.flightId === selectedFlight.id).length
}, [savedAnnotations, selectedFlight])
const grandTotal = totalCount + relevantSavedCount
const validatedCount = useMemo(
() => cards.filter(c => c.status === AnnotationStatus.Validated).length,
[cards],
)
const firstSelectedName = useMemo(() => {
const firstId = selectedIds.values().next().value
if (!firstId) return null
return cards.find(c => c.annotationId === firstId)?.imageName ?? null
}, [selectedIds, cards])
const editorMedia: Media | null = editorAnnotation ? { const editorMedia: Media | null = editorAnnotation ? {
id: editorAnnotation.mediaId, name: '', path: editorFullFrame, mediaType: 1, mediaStatus: 0, id: editorAnnotation.mediaId, name: '', path: editorFullFrame, mediaType: 1, mediaStatus: 0,
duration: null, annotationCount: 0, waypointId: null, userId: '', duration: null, annotationCount: 0, waypointId: null, userId: '',
} : null } : 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 ( return (
<div className="flex h-full"> <div className="flex-1 flex overflow-hidden p-3 gap-3 h-full">
{/* Left panel */} <DatasetLeftPanel
<div style={{ width: leftPanel.width }} className="bg-az-panel border-r border-az-border flex flex-col shrink-0"> selectedClassNum={selectedClassNum}
<DetectionClasses onSelectClass={setSelectedClassNum}
selectedClassNum={selectedClassNum} classCounts={classCounts}
onSelect={setSelectedClassNum} objectsOnly={objectsOnly}
photoMode={photoMode} onObjectsOnlyChange={setObjectsOnly}
onPhotoModeChange={setPhotoMode} search={search}
onSearchChange={setSearch}
totalCount={grandTotal}
validatedCount={validatedCount}
/>
<main className="flex-1 min-w-0 flex flex-col gap-3">
<DatasetFilterBar
fromDate={fromDate}
toDate={toDate}
onFromDateChange={setFromDate}
onToDateChange={setToDate}
statusFilter={statusFilter}
onStatusFilterChange={s => { setStatusFilter(s); setPage(1) }}
flightName={selectedFlight?.name ?? null}
shownCount={cards.length}
totalCount={grandTotal}
/> />
<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="bracket panel relative flex-1 flex flex-col min-h-0 overflow-hidden">
<div className="flex-1 min-w-0 min-h-0 flex flex-col overflow-hidden"> <span className="br" />
{/* Filter bar */}
<div className="flex items-center gap-2 p-2 border-b border-az-border bg-az-panel text-xs flex-wrap"> {/* Tab strip */}
<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" /> <div className="flex items-center px-2 border-b border-border-hair shrink-0">
<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 <button
key={String(sb.value)} type="button"
onClick={() => { setStatusFilter(sb.value); setPage(1) }} onClick={() => setTab('annotations')}
className={`px-2 py-0.5 rounded ${statusFilter === sb.value ? 'bg-az-orange text-white' : 'bg-az-bg text-az-muted'}`} className={`tab ${tab === 'annotations' ? 'active' : ''}`}
> >
{sb.label} <span>{t('dataset.annotations')}</span>
<span
className={`ml-1.5 px-1.5 py-px text-[10px] font-mono border rounded-[2px] tabular-nums ${
tab === 'annotations'
? 'text-accent-amber border-accent-amber'
: 'text-text-muted border-border-hair'
}`}
>
{cards.length}
</span>
</button> </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 <button
key={tb} type="button"
onClick={() => setTab(tb)} onClick={() => setTab('editor')}
className={`px-3 py-1.5 text-xs ${tab === tb ? 'bg-az-bg text-white border-b-2 border-az-orange' : 'text-az-muted'}`} className={`tab ${tab === 'editor' ? 'active' : ''}`}
> >
{t(`dataset.${tb === 'distribution' ? 'classDistribution' : tb}`)} <span>{t('dataset.editor')}</span>
<span
className={`ml-1.5 px-1.5 py-px text-[10px] font-mono border rounded-[2px] tabular-nums ${
tab === 'editor'
? 'text-accent-amber border-accent-amber'
: 'text-text-muted border-border-hair'
}`}
>
{editorAnnotation ? editorDetections.length : '—'}
</span>
</button>
<button
type="button"
onClick={() => setTab('distribution')}
className={`tab ${tab === 'distribution' ? 'active' : ''}`}
>
<span>{t('dataset.classDistribution')}</span>
</button> </button>
))}
</div>
{/* Content */} <div
{tab === 'annotations' && ( className="ml-auto flex items-center gap-2 px-2 micro"
<div className="flex-1 overflow-y-auto p-2"> style={{ color: 'var(--text-muted)' }}
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))' }}> >
{cards.map(card => { <span className="live-dot" />
const statusPill = <span>{t('dataset.liveSync')}</span>
card.status === AnnotationStatus.Validated ? { cls: 'bg-az-green text-white', label: t('dataset.status.validated') } : </div>
card.status === AnnotationStatus.Edited ? { cls: 'bg-az-blue text-white', label: t('dataset.status.edited') } : </div>
{ cls: 'bg-az-orange text-white', label: t('dataset.status.created') }
const isSelected = selectedIds.has(card.annotationId) {/* Content */}
return ( {tab === 'annotations' && (
<div <div className="flex-1 overflow-y-auto p-2">
<div
className="grid gap-2"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(170px, 1fr))' }}
>
{cards.map(card => (
<DatasetTile
key={card.annotationId} key={card.annotationId}
card={card}
isSelected={selectedIds.has(card.annotationId)}
onClick={e => { onClick={e => {
if (e.ctrlKey) { if (e.ctrlKey || e.metaKey) {
setSelectedIds(prev => { setSelectedIds(prev => {
const n = new Set(prev) const n = new Set(prev)
n.has(card.annotationId) ? n.delete(card.annotationId) : n.add(card.annotationId) if (n.has(card.annotationId)) n.delete(card.annotationId)
else n.add(card.annotationId)
return n return n
}) })
} else { } else {
@@ -316,119 +341,121 @@ export default function DatasetPage() {
e.preventDefault() e.preventDefault()
removeSaved(card.annotationId) removeSaved(card.annotationId)
}} }}
title={card.imageName} onEditClick={() => handleDoubleClick(card)}
className={`aspect-square bg-az-panel rounded border overflow-hidden cursor-pointer relative transition-colors ${ />
isSelected ? 'border-az-orange' : 'border-az-border hover:border-az-blue' ))}
} ${card.isSeed ? 'ring-2 ring-az-red' : ''}`} </div>
{cards.length === 0 && (
<div className="text-center text-text-muted text-xs py-8">{t('common.noData')}</div>
)}
{totalPages > 1 && (
<div className="flex justify-center items-center gap-3 py-3">
<button
type="button"
className="btn btn-ghost"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
> >
{card.thumbnailUrl ? ( Prev
<img </button>
src={card.thumbnailUrl} <span className="mono text-[12px] text-text-primary tabular-nums">
alt={card.imageName} {page} / {totalPages}
className="w-full h-full object-cover bg-az-bg" </span>
loading="lazy" <button
/> type="button"
) : ( className="btn btn-ghost"
<div className="w-full h-full bg-az-bg" /> onClick={() => setPage(p => Math.min(totalPages, p + 1))}
)} disabled={page === totalPages}
<span className={`absolute bottom-1.5 left-1.5 text-[10px] px-2 py-0.5 rounded-full ${statusPill.cls}`}> >
{statusPill.label} Next
</button>
</div>
)}
</div>
)}
{tab === 'editor' && editorMedia && editorAnnotation && (
<div className="flex-1 min-h-0 relative overflow-hidden">
<div className="absolute inset-0 flex flex-col">
<div className="bg-surface-1 border-b border-border-hair px-3 py-2 flex gap-2 items-center shrink-0">
<button
type="button"
className="btn btn-primary"
onClick={handleEditorSave}
disabled={editorSaving || (!editorLocalGroupId && editorDetections.length === 0)}
>
{editorSaving ? 'Saving…' : t('common.save')}
</button>
<button
type="button"
className="btn btn-ghost"
onClick={handleEditorCancel}
disabled={editorSaving}
>
{t('common.cancel')}
</button>
<span className="micro" style={{ color: 'var(--text-muted)' }}>
{editorDetections.length} detection{editorDetections.length !== 1 ? 's' : ''}
</span>
{!editorLocalGroupId && (
<span className="micro ml-auto" style={{ color: 'var(--text-muted)' }}>
remote save not wired yet
</span>
)}
</div>
<div className="flex-1 min-h-0 relative">
<div className="absolute inset-0">
<CanvasEditor
media={editorMedia}
annotation={editorAnnotation}
detections={editorDetections}
onDetectionsChange={setEditorDetections}
selectedClassNum={selectedClassNum}
currentTime={0}
annotations={[]}
/>
</div>
</div>
</div>
</div>
)}
{tab === 'distribution' && (
<div className="flex-1 overflow-y-auto">
{distribution.map(d => {
const pct = (d.count / maxDistCount) * 100
return (
<div
key={d.classNum}
className="relative flex items-center h-8 border-b border-border-hair px-3 gap-3"
>
<div
className="absolute inset-y-0 left-0 pointer-events-none"
style={{ width: `${pct}%`, backgroundColor: d.color, opacity: 0.18 }}
/>
<span className="swatch shrink-0 relative" style={{ background: d.color }} />
<span className="relative text-[12px] text-text-primary truncate">{d.label}</span>
<span className="relative ml-auto mono text-[12px] text-text-primary tabular-nums">
{d.count.toLocaleString()}
</span> </span>
{card.isLocal && (
<span className="absolute top-1.5 right-1.5 text-[9px] px-1.5 py-0.5 rounded bg-az-border text-az-text">
local
</span>
)}
<button
type="button"
onClick={e => { e.stopPropagation(); handleDoubleClick(card) }}
title={t('dataset.edit') ?? 'Edit'}
className="absolute bottom-1.5 right-1.5 w-6 h-6 flex items-center justify-center rounded bg-az-bg/80 text-az-text hover:bg-az-orange hover:text-white"
>
<FaPen size={10} />
</button>
</div> </div>
) )
})} })}
{distribution.length === 0 && (
<div className="text-center text-text-muted text-xs py-8">{t('common.noData')}</div>
)}
</div> </div>
{cards.length === 0 && ( )}
<div className="text-center text-az-muted text-xs py-8">{t('common.noData')}</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 && ( <DatasetStatusBar
<div className="flex-1 min-h-0 relative overflow-hidden"> selectedCount={selectedIds.size}
<div className="absolute inset-0 flex flex-col"> totalShown={cards.length}
<div className="bg-az-panel border-b border-az-border px-2 py-1 flex gap-2 items-center shrink-0"> firstSelectedName={firstSelectedName}
<button canValidate={selectedIds.size > 0}
onClick={handleEditorSave} onValidate={handleValidate}
disabled={editorSaving || (!editorLocalGroupId && editorDetections.length === 0)} />
className="px-2.5 py-1 rounded border border-az-green text-az-green text-[11px] hover:bg-az-green/10 disabled:opacity-40 disabled:cursor-not-allowed" </main>
>
{editorSaving ? 'Saving…' : t('common.save') ?? 'Save'}
</button>
<button
onClick={handleEditorCancel}
disabled={editorSaving}
className="px-2.5 py-1 rounded border border-az-border text-az-text text-[11px] hover:bg-az-border/30 disabled:opacity-40"
>
{t('common.cancel') ?? 'Cancel'}
</button>
<span className="text-az-muted text-[10px]">
{editorDetections.length} detection{editorDetections.length !== 1 ? 's' : ''}
</span>
{!editorLocalGroupId && (
<span className="text-az-muted text-[10px] ml-auto">
remote save not wired yet
</span>
)}
</div>
<div className="flex-1 min-h-0 relative">
<div className="absolute inset-0">
<CanvasEditor
media={editorMedia}
annotation={editorAnnotation}
detections={editorDetections}
onDetectionsChange={setEditorDetections}
selectedClassNum={selectedClassNum}
currentTime={0}
annotations={[]}
/>
</div>
</div>
</div>
</div>
)}
{tab === 'distribution' && (
<div className="flex-1 overflow-y-auto bg-az-bg">
{distribution.map(d => {
const pct = (d.count / maxDistCount) * 100
return (
<div key={d.classNum} className="relative h-6 border-b border-az-border/40">
<div
className="absolute inset-y-0 left-0"
style={{ width: `${pct}%`, backgroundColor: d.color, opacity: 0.85 }}
/>
<div className="relative flex items-center justify-between h-full px-2 text-xs text-white tabular-nums">
<span className="truncate">{d.label}: {d.count}</span>
<span className="pl-2">{d.count}</span>
</div>
</div>
)
})}
</div>
)}
</div>
</div> </div>
) )
} }
+52
View File
@@ -0,0 +1,52 @@
import { useTranslation } from 'react-i18next'
interface DatasetStatusBarProps {
selectedCount: number
totalShown: number
firstSelectedName: string | null
canValidate: boolean
onValidate: () => void
}
export default function DatasetStatusBar({
selectedCount,
totalShown,
firstSelectedName,
canValidate,
onValidate,
}: DatasetStatusBarProps) {
const { t } = useTranslation()
return (
<div className="bracket panel relative flex items-center gap-3 px-3 shrink-0" style={{ height: 44 }}>
<span className="br" />
<button
type="button"
className="btn btn-primary"
disabled={!canValidate}
onClick={onValidate}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
<polyline points="20 6 9 17 4 12" />
</svg>
{t('dataset.validate')} ({selectedCount})
</button>
<span className="w-px h-5 bg-border-hair shrink-0" />
<div className="flex items-center gap-2 min-w-0">
<span className="micro">{t('dataset.selected')}</span>
<span className="mono text-[12px] text-text-primary truncate">
{firstSelectedName ?? '—'}
</span>
</div>
<div className="ml-auto flex items-center gap-3">
<span className="text-[11px] text-text-muted">
{t('dataset.ofSelected', { count: selectedCount, total: totalShown })}
</span>
</div>
</div>
)
}
+155
View File
@@ -0,0 +1,155 @@
import { useTranslation } from 'react-i18next'
import { FaPen } from 'react-icons/fa'
import { AnnotationStatus } from '../../types'
import type { Detection } from '../../types'
export interface DatasetCard {
annotationId: string
imageName: string
status: AnnotationStatus
createdDate: string
thumbnailUrl: string
isSeed: boolean
isLocal: boolean
detections?: Detection[]
mediaId?: string
time?: string | null
fullFrame?: string
annotationLocalId?: string
}
interface DatasetTileProps {
card: DatasetCard
isSelected: boolean
onClick: (e: React.MouseEvent) => void
onDoubleClick: () => void
onContextMenu: (e: React.MouseEvent) => void
onEditClick: () => void
}
const TILE_DATE_FMT = new Intl.DateTimeFormat('en', { day: '2-digit', month: 'short' })
export function formatTileDate(iso: string): string {
try {
const d = new Date(iso)
if (isNaN(d.getTime())) return ''
return TILE_DATE_FMT.format(d).toUpperCase()
} catch {
return ''
}
}
export default function DatasetTile({
card,
isSelected,
onClick,
onDoubleClick,
onContextMenu,
onEditClick,
}: DatasetTileProps) {
const { t } = useTranslation()
const statusPill =
card.status === AnnotationStatus.Validated
? { cls: 'pill-green', label: t('dataset.status.validated') }
: card.status === AnnotationStatus.Edited
? { cls: 'pill-blue', label: t('dataset.status.edited') }
: card.status === AnnotationStatus.Created
? { cls: 'pill-amber', label: t('dataset.status.created') }
: { cls: 'pill-muted', label: t('dataset.status.none') }
const borderCls = isSelected
? card.isSeed
? 'border-2 border-accent-amber ring-1 ring-accent-red'
: 'border-2 border-accent-amber'
: card.isSeed
? 'border border-accent-red'
: 'border border-border-hair hover:border-accent-amber'
const tileDate = formatTileDate(card.createdDate)
return (
<div
onClick={onClick}
onDoubleClick={onDoubleClick}
onContextMenu={onContextMenu}
title={card.imageName}
className={`group aspect-square relative overflow-hidden rounded-[2px] bg-surface-1 cursor-pointer transition-colors ${borderCls}`}
>
{card.thumbnailUrl ? (
<img
src={card.thumbnailUrl}
alt={card.imageName}
loading="lazy"
className="absolute inset-0 w-full h-full object-cover bg-surface-0"
/>
) : (
<div className="absolute inset-0 bg-surface-0" />
)}
{/* composite scrim: grid lines + bottom fade (matches design .tile .scrim) */}
<div
className="absolute inset-0 pointer-events-none"
style={{
backgroundImage:
'linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px),' +
'linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px),' +
'linear-gradient(180deg, rgba(0,0,0,0) 55%, rgba(0,0,0,0.55) 100%)',
backgroundSize: '24px 24px, 24px 24px, 100% 100%',
}}
/>
{/* corner-tag top-right */}
{tileDate && (
<div
className="absolute top-1.5 right-1.5 font-mono text-[9px] tracking-wider text-text-primary border border-border-hair rounded-[2px]"
style={{ background: 'rgba(10,13,16,0.65)', padding: '1px 5px' }}
>
{tileDate}
</div>
)}
{/* local badge — top-left */}
{card.isLocal && (
<div
className="absolute top-1.5 left-1.5 font-mono text-[9px] tracking-wider text-accent-cyan border border-accent-cyan/50 rounded-[2px] px-1.5 py-px"
style={{ background: 'rgba(10,13,16,0.65)' }}
>
{t('dataset.local').toUpperCase()}
</div>
)}
{/* selected check badge (only when selected & not local — local already has top-left badge) */}
{isSelected && !card.isLocal && (
<div
className="absolute top-1 left-1 inline-flex items-center justify-center rounded-[2px] bg-accent-amber"
style={{ width: 14, height: 14, color: '#0A0D10' }}
>
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.5">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
)}
{/* status pill bottom-left */}
<span
className={`absolute bottom-1.5 left-1.5 pill ${statusPill.cls}`}
style={{ padding: '2px 6px', fontSize: 9, height: 'auto', lineHeight: 1 }}
>
<span className="dot" />
{statusPill.label}
</span>
{/* edit ibtn bottom-right (reveal on hover) */}
<button
type="button"
onClick={e => { e.stopPropagation(); onEditClick() }}
title={t('dataset.edit')}
className="ibtn edit absolute bottom-1.5 right-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
style={{ background: 'rgba(10,13,16,0.65)' }}
>
<FaPen size={9} />
</button>
</div>
)
}
+20 -2
View File
@@ -133,12 +133,30 @@
"editor": "Editor", "editor": "Editor",
"classDistribution": "Class Distribution", "classDistribution": "Class Distribution",
"objectsOnly": "Show with objects only", "objectsOnly": "Show with objects only",
"search": "Search...", "hideEmpty": "Hide empty frames",
"search": "Search annotation name…",
"validate": "Validate", "validate": "Validate",
"edit": "Edit",
"filters": "Filters",
"total": "Total",
"validatedCount": "Validated",
"range": "Range",
"flight": "Flight",
"showing": "Showing",
"liveSync": "Live sync",
"selected": "Selected",
"refreshThumbnails": "Refresh Thumbnails",
"ofSelected": "{{count}} of {{total}} selected",
"local": "Local",
"sort": "Sort",
"gridDensity": "Grid density",
"statusLabel": "Status",
"status": { "status": {
"created": "Created", "created": "Created",
"edited": "Edited", "edited": "Edited",
"validated": "Validated" "validated": "Validated",
"all": "All",
"none": "None"
} }
}, },
"admin": { "admin": {
+20 -2
View File
@@ -135,12 +135,30 @@
"editor": "Редактор", "editor": "Редактор",
"classDistribution": "Розподіл класів", "classDistribution": "Розподіл класів",
"objectsOnly": "Тільки з об'єктами", "objectsOnly": "Тільки з об'єктами",
"search": "Пошук...", "hideEmpty": "Приховати порожні кадри",
"search": "Пошук за назвою анотації…",
"validate": "Валідувати", "validate": "Валідувати",
"edit": "Редагувати",
"filters": "Фільтри",
"total": "Всього",
"validatedCount": "Валідовано",
"range": "Діапазон",
"flight": "Політ",
"showing": "Показано",
"liveSync": "Жива синхронізація",
"selected": "Вибрано",
"refreshThumbnails": "Оновити мініатюри",
"ofSelected": "{{count}} з {{total}} вибрано",
"local": "Локально",
"sort": "Сортування",
"gridDensity": "Щільність сітки",
"statusLabel": "Статус",
"status": { "status": {
"created": "Створено", "created": "Створено",
"edited": "Відредаговано", "edited": "Відредаговано",
"validated": "Валідовано" "validated": "Валідовано",
"all": "Всі",
"none": "Жоден"
} }
}, },
"admin": { "admin": {
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/sse.ts","./src/auth/authcontext.tsx","./src/auth/protectedroute.tsx","./src/components/confirmdialog.tsx","./src/components/detectionclasses.tsx","./src/components/flightcontext.tsx","./src/components/header.tsx","./src/components/helpmodal.tsx","./src/components/savedannotationscontext.tsx","./src/features/admin/adminpage.tsx","./src/features/annotations/annotationspage.tsx","./src/features/annotations/annotationssidebar.tsx","./src/features/annotations/canvaseditor.tsx","./src/features/annotations/medialist.tsx","./src/features/annotations/videoplayer.tsx","./src/features/annotations/classcolors.ts","./src/features/annotations/thumbnail.ts","./src/features/dataset/datasetpage.tsx","./src/features/flights/altitudechart.tsx","./src/features/flights/altitudedialog.tsx","./src/features/flights/drawcontrol.tsx","./src/features/flights/flightlistsidebar.tsx","./src/features/flights/flightmap.tsx","./src/features/flights/flightparamspanel.tsx","./src/features/flights/flightspage.tsx","./src/features/flights/jsoneditordialog.tsx","./src/features/flights/mappoint.tsx","./src/features/flights/minimap.tsx","./src/features/flights/waypointlist.tsx","./src/features/flights/windeffect.tsx","./src/features/flights/flightplanutils.ts","./src/features/flights/mapicons.ts","./src/features/flights/types.ts","./src/features/login/loginpage.tsx","./src/features/settings/settingspage.tsx","./src/hooks/usedebounce.ts","./src/hooks/useresizablepanel.ts","./src/i18n/i18n.ts","./src/types/index.ts"],"version":"5.7.3"} {"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/api/index.ts","./src/api/sse.ts","./src/auth/authcontext.tsx","./src/auth/protectedroute.tsx","./src/auth/index.ts","./src/class-colors/classcolors.ts","./src/class-colors/index.ts","./src/components/confirmdialog.tsx","./src/components/detectionclasses.tsx","./src/components/flightcontext.tsx","./src/components/header.tsx","./src/components/helpmodal.tsx","./src/components/savedannotationscontext.tsx","./src/components/index.ts","./src/features/admin/adminpage.tsx","./src/features/admin/classeditrow.tsx","./src/features/admin/modal.tsx","./src/features/admin/numberstepper.tsx","./src/features/admin/index.ts","./src/features/admin/useaisettings.ts","./src/features/admin/usegpssettings.ts","./src/features/annotations/annotationspage.tsx","./src/features/annotations/annotationssidebar.tsx","./src/features/annotations/canvaseditor.tsx","./src/features/annotations/medialist.tsx","./src/features/annotations/scrubber.tsx","./src/features/annotations/videoplayer.tsx","./src/features/annotations/index.ts","./src/features/annotations/thumbnail.ts","./src/features/annotations/time.ts","./src/features/dataset/datasetleftpanel.tsx","./src/features/dataset/datasetpage.tsx","./src/features/dataset/index.ts","./src/features/flights/altitudechart.tsx","./src/features/flights/altitudedialog.tsx","./src/features/flights/drawcontrol.tsx","./src/features/flights/flightlistsidebar.tsx","./src/features/flights/flightmap.tsx","./src/features/flights/flightparamspanel.tsx","./src/features/flights/flightspage.tsx","./src/features/flights/jsoneditordialog.tsx","./src/features/flights/mappoint.tsx","./src/features/flights/minimap.tsx","./src/features/flights/waypointlist.tsx","./src/features/flights/windeffect.tsx","./src/features/flights/flightplanutils.ts","./src/features/flights/index.ts","./src/features/flights/mapicons.ts","./src/features/flights/types.ts","./src/features/login/loginpage.tsx","./src/features/login/index.ts","./src/features/settings/settingspage.tsx","./src/features/settings/index.ts","./src/hooks/index.ts","./src/hooks/usedebounce.ts","./src/hooks/useresizablepanel.ts","./src/i18n/i18n.ts","./src/i18n/index.ts","./src/types/index.ts"],"version":"5.7.3"}