mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 15:21:11 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dfcdc26630 | |||
| 60d77d0f29 |
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +1,25 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FaPen } from 'react-icons/fa'
|
||||
import { api, endpoints } from '../../api'
|
||||
import { useDebounce, useResizablePanel } from '../../hooks'
|
||||
import { useFlight, DetectionClasses } from '../../components'
|
||||
import { useDebounce } from '../../hooks'
|
||||
import { useFlight } from '../../components'
|
||||
import { useSavedAnnotations } from '../../components/SavedAnnotationsContext'
|
||||
import CanvasEditor from '../annotations/CanvasEditor'
|
||||
import { recaptureThumbnails } from '../annotations/thumbnail'
|
||||
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'
|
||||
|
||||
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
|
||||
}
|
||||
import DatasetLeftPanel from './DatasetLeftPanel'
|
||||
import DatasetFilterBar from './DatasetFilterBar'
|
||||
import DatasetTile, { type DatasetCard } from './DatasetTile'
|
||||
import DatasetStatusBar from './DatasetStatusBar'
|
||||
|
||||
type Tab = 'annotations' | 'editor' | 'distribution'
|
||||
|
||||
@@ -32,7 +27,6 @@ export default function DatasetPage() {
|
||||
const { t } = useTranslation()
|
||||
const { selectedFlight } = useFlight()
|
||||
const { saved: savedAnnotations, removeSaved, replaceGroup, updateStatus } = useSavedAnnotations()
|
||||
const leftPanel = useResizablePanel(250, 200, 400)
|
||||
|
||||
const [items, setItems] = useState<DatasetItem[]>([])
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
@@ -45,12 +39,14 @@ export default function DatasetPage() {
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSearch = useDebounce(search, 400)
|
||||
const [selectedClassNum, setSelectedClassNum] = useState(0)
|
||||
const [photoMode, setPhotoMode] = useState(0)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [tab, setTab] = useState<Tab>('annotations')
|
||||
const [editorAnnotation, setEditorAnnotation] = useState<AnnotationListItem | null>(null)
|
||||
const [editorDetections, setEditorDetections] = useState<Detection[]>([])
|
||||
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 params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
||||
@@ -107,11 +103,7 @@ export default function DatasetPage() {
|
||||
}))
|
||||
|
||||
return [...localCards, ...remoteCards]
|
||||
}, [savedAnnotations, items, selectedFlight, statusFilter, objectsOnly, selectedClassNum, debouncedSearch, fromDate, toDate])
|
||||
|
||||
const [editorFullFrame, setEditorFullFrame] = useState<string>('')
|
||||
const [editorLocalGroupId, setEditorLocalGroupId] = useState<string | null>(null)
|
||||
const [editorSaving, setEditorSaving] = useState(false)
|
||||
}, [savedAnnotations, items, selectedFlight, statusFilter, selectedClassNum, debouncedSearch, fromDate, toDate])
|
||||
|
||||
const handleDoubleClick = async (card: DatasetCard) => {
|
||||
if (card.isLocal && card.detections && card.mediaId) {
|
||||
@@ -151,7 +143,7 @@ export default function DatasetPage() {
|
||||
const existing = savedAnnotations.find(s => s.annotationLocalId === editorLocalGroupId)
|
||||
const thumbs = await recaptureThumbnails(editorFullFrame, editorDetections)
|
||||
const now = new Date().toISOString()
|
||||
const items: SavedDetection[] = editorDetections.map((d, i) => ({
|
||||
const replacement: SavedDetection[] = editorDetections.map((d, i) => ({
|
||||
id: `${editorLocalGroupId}:${d.id ?? i}`,
|
||||
annotationLocalId: editorLocalGroupId,
|
||||
mediaId: editorAnnotation.mediaId,
|
||||
@@ -165,7 +157,7 @@ export default function DatasetPage() {
|
||||
time: editorAnnotation.time,
|
||||
flightId: existing?.flightId ?? null,
|
||||
}))
|
||||
replaceGroup(editorLocalGroupId, items)
|
||||
replaceGroup(editorLocalGroupId, replacement)
|
||||
}
|
||||
setTab('annotations')
|
||||
} finally {
|
||||
@@ -196,114 +188,147 @@ export default function DatasetPage() {
|
||||
updateStatus(localIds, AnnotationStatus.Validated)
|
||||
}
|
||||
setSelectedIds(new Set())
|
||||
setPage(1)
|
||||
fetchItems()
|
||||
}
|
||||
|
||||
const loadDistribution = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.get<ClassDistributionItem[]>(endpoints.annotations.datasetClassDistribution())
|
||||
setDistribution(data)
|
||||
} catch {}
|
||||
useEffect(() => {
|
||||
api.get<ClassDistributionItem[]>(endpoints.annotations.datasetClassDistribution())
|
||||
.then(setDistribution)
|
||||
.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 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 ? {
|
||||
id: editorAnnotation.mediaId, name: '', path: editorFullFrame, mediaType: 1, mediaStatus: 0,
|
||||
duration: null, annotationCount: 0, waypointId: null, userId: '',
|
||||
} : 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 (
|
||||
<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">
|
||||
<DetectionClasses
|
||||
selectedClassNum={selectedClassNum}
|
||||
onSelect={setSelectedClassNum}
|
||||
photoMode={photoMode}
|
||||
onPhotoModeChange={setPhotoMode}
|
||||
<div className="flex-1 flex overflow-hidden p-3 gap-3 h-full">
|
||||
<DatasetLeftPanel
|
||||
selectedClassNum={selectedClassNum}
|
||||
onSelectClass={setSelectedClassNum}
|
||||
classCounts={classCounts}
|
||||
objectsOnly={objectsOnly}
|
||||
onObjectsOnlyChange={setObjectsOnly}
|
||||
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="flex-1 min-w-0 min-h-0 flex flex-col overflow-hidden">
|
||||
{/* Filter bar */}
|
||||
<div className="flex items-center gap-2 p-2 border-b border-az-border bg-az-panel text-xs flex-wrap">
|
||||
<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" />
|
||||
<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 => (
|
||||
<div className="bracket panel relative flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<span className="br" />
|
||||
|
||||
{/* Tab strip */}
|
||||
<div className="flex items-center px-2 border-b border-border-hair shrink-0">
|
||||
<button
|
||||
key={String(sb.value)}
|
||||
onClick={() => { setStatusFilter(sb.value); setPage(1) }}
|
||||
className={`px-2 py-0.5 rounded ${statusFilter === sb.value ? 'bg-az-orange text-white' : 'bg-az-bg text-az-muted'}`}
|
||||
type="button"
|
||||
onClick={() => setTab('annotations')}
|
||||
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>
|
||||
))}
|
||||
<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
|
||||
key={tb}
|
||||
onClick={() => setTab(tb)}
|
||||
className={`px-3 py-1.5 text-xs ${tab === tb ? 'bg-az-bg text-white border-b-2 border-az-orange' : 'text-az-muted'}`}
|
||||
type="button"
|
||||
onClick={() => setTab('editor')}
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{tab === 'annotations' && (
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))' }}>
|
||||
{cards.map(card => {
|
||||
const statusPill =
|
||||
card.status === AnnotationStatus.Validated ? { cls: 'bg-az-green text-white', label: t('dataset.status.validated') } :
|
||||
card.status === AnnotationStatus.Edited ? { cls: 'bg-az-blue text-white', label: t('dataset.status.edited') } :
|
||||
{ cls: 'bg-az-orange text-white', label: t('dataset.status.created') }
|
||||
const isSelected = selectedIds.has(card.annotationId)
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
className="ml-auto flex items-center gap-2 px-2 micro"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
<span className="live-dot" />
|
||||
<span>{t('dataset.liveSync')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{tab === 'annotations' && (
|
||||
<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}
|
||||
card={card}
|
||||
isSelected={selectedIds.has(card.annotationId)}
|
||||
onClick={e => {
|
||||
if (e.ctrlKey) {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
setSelectedIds(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
|
||||
})
|
||||
} else {
|
||||
@@ -316,119 +341,121 @@ export default function DatasetPage() {
|
||||
e.preventDefault()
|
||||
removeSaved(card.annotationId)
|
||||
}}
|
||||
title={card.imageName}
|
||||
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' : ''}`}
|
||||
onEditClick={() => handleDoubleClick(card)}
|
||||
/>
|
||||
))}
|
||||
</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 ? (
|
||||
<img
|
||||
src={card.thumbnailUrl}
|
||||
alt={card.imageName}
|
||||
className="w-full h-full object-cover bg-az-bg"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-az-bg" />
|
||||
)}
|
||||
<span className={`absolute bottom-1.5 left-1.5 text-[10px] px-2 py-0.5 rounded-full ${statusPill.cls}`}>
|
||||
{statusPill.label}
|
||||
Prev
|
||||
</button>
|
||||
<span className="mono text-[12px] text-text-primary tabular-nums">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
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>
|
||||
{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>
|
||||
)
|
||||
})}
|
||||
{distribution.length === 0 && (
|
||||
<div className="text-center text-text-muted text-xs py-8">{t('common.noData')}</div>
|
||||
)}
|
||||
</div>
|
||||
{cards.length === 0 && (
|
||||
<div className="text-center text-az-muted text-xs py-8">{t('common.noData')}</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>
|
||||
)}
|
||||
)}
|
||||
</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-az-panel border-b border-az-border px-2 py-1 flex gap-2 items-center shrink-0">
|
||||
<button
|
||||
onClick={handleEditorSave}
|
||||
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"
|
||||
>
|
||||
{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>
|
||||
<DatasetStatusBar
|
||||
selectedCount={selectedIds.size}
|
||||
totalShown={cards.length}
|
||||
firstSelectedName={firstSelectedName}
|
||||
canValidate={selectedIds.size > 0}
|
||||
onValidate={handleValidate}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -133,12 +133,30 @@
|
||||
"editor": "Editor",
|
||||
"classDistribution": "Class Distribution",
|
||||
"objectsOnly": "Show with objects only",
|
||||
"search": "Search...",
|
||||
"hideEmpty": "Hide empty frames",
|
||||
"search": "Search annotation name…",
|
||||
"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": {
|
||||
"created": "Created",
|
||||
"edited": "Edited",
|
||||
"validated": "Validated"
|
||||
"validated": "Validated",
|
||||
"all": "All",
|
||||
"none": "None"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
|
||||
+20
-2
@@ -135,12 +135,30 @@
|
||||
"editor": "Редактор",
|
||||
"classDistribution": "Розподіл класів",
|
||||
"objectsOnly": "Тільки з об'єктами",
|
||||
"search": "Пошук...",
|
||||
"hideEmpty": "Приховати порожні кадри",
|
||||
"search": "Пошук за назвою анотації…",
|
||||
"validate": "Валідувати",
|
||||
"edit": "Редагувати",
|
||||
"filters": "Фільтри",
|
||||
"total": "Всього",
|
||||
"validatedCount": "Валідовано",
|
||||
"range": "Діапазон",
|
||||
"flight": "Політ",
|
||||
"showing": "Показано",
|
||||
"liveSync": "Жива синхронізація",
|
||||
"selected": "Вибрано",
|
||||
"refreshThumbnails": "Оновити мініатюри",
|
||||
"ofSelected": "{{count}} з {{total}} вибрано",
|
||||
"local": "Локально",
|
||||
"sort": "Сортування",
|
||||
"gridDensity": "Щільність сітки",
|
||||
"statusLabel": "Статус",
|
||||
"status": {
|
||||
"created": "Створено",
|
||||
"edited": "Відредаговано",
|
||||
"validated": "Валідовано"
|
||||
"validated": "Валідовано",
|
||||
"all": "Всі",
|
||||
"none": "Жоден"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user