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
This commit is contained in:
Armen Rohalov
2026-05-29 02:15:23 +03:00
parent 60d77d0f29
commit dfcdc26630
6 changed files with 43 additions and 46 deletions
+5 -13
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { api, endpoints } from '../../api' import { api, endpoints } from '../../api'
import { getClassColor, FALLBACK_CLASS_NAMES } from '../../class-colors' import { getClassColor, FALLBACK_CLASS_NAMES } from '../../class-colors'
import type { DetectionClass, ClassDistributionItem } from '../../types' import type { DetectionClass } from '../../types'
const FALLBACK_CLASSES: DetectionClass[] = FALLBACK_CLASS_NAMES.map((name, i) => ({ const FALLBACK_CLASSES: DetectionClass[] = FALLBACK_CLASS_NAMES.map((name, i) => ({
id: i + 1, id: i + 1,
@@ -16,12 +16,12 @@ const FALLBACK_CLASSES: DetectionClass[] = FALLBACK_CLASS_NAMES.map((name, i) =>
interface DatasetClassListProps { interface DatasetClassListProps {
selectedClassNum: number selectedClassNum: number
onSelect: (classNum: number) => void onSelect: (classNum: number) => void
counts: Record<number, number>
} }
export default function DatasetClassList({ selectedClassNum, onSelect }: DatasetClassListProps) { export default function DatasetClassList({ selectedClassNum, onSelect, counts }: DatasetClassListProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [classes, setClasses] = useState<DetectionClass[]>([]) const [classes, setClasses] = useState<DetectionClass[]>([])
const [counts, setCounts] = useState<Record<number, number>>({})
useEffect(() => { useEffect(() => {
api.get<DetectionClass[]>(endpoints.annotations.classes()) api.get<DetectionClass[]>(endpoints.annotations.classes())
@@ -29,20 +29,12 @@ export default function DatasetClassList({ selectedClassNum, onSelect }: Dataset
.catch(() => setClasses(FALLBACK_CLASSES)) .catch(() => setClasses(FALLBACK_CLASSES))
}, []) }, [])
useEffect(() => {
api.get<ClassDistributionItem[]>(endpoints.annotations.datasetClassDistribution())
.then(data => {
const map: Record<number, number> = {}
for (const d of data) map[d.classNum] = d.count
setCounts(map)
})
.catch(() => {})
}, [])
const regularClasses = useMemo(() => classes.filter(c => c.photoMode === 0), [classes]) const regularClasses = useMemo(() => classes.filter(c => c.photoMode === 0), [classes])
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { 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) const num = parseInt(e.key)
if (num >= 1 && num <= 9) { if (num >= 1 && num <= 9) {
const cls = regularClasses[num - 1] const cls = regularClasses[num - 1]
+1 -1
View File
@@ -29,7 +29,7 @@ export default function DatasetFilterBar({
const STATUS_OPTIONS = [ const STATUS_OPTIONS = [
{ {
value: null, value: null,
label: t('dataset.status.none'), label: t('dataset.status.all'),
tone: 'muted' as const, tone: 'muted' as const,
dot: 'var(--text-muted)', dot: 'var(--text-muted)',
}, },
+7 -1
View File
@@ -4,6 +4,7 @@ import DatasetClassList from './DatasetClassList'
interface DatasetLeftPanelProps { interface DatasetLeftPanelProps {
selectedClassNum: number selectedClassNum: number
onSelectClass: (n: number) => void onSelectClass: (n: number) => void
classCounts: Record<number, number>
objectsOnly: boolean objectsOnly: boolean
onObjectsOnlyChange: (v: boolean) => void onObjectsOnlyChange: (v: boolean) => void
search: string search: string
@@ -15,6 +16,7 @@ interface DatasetLeftPanelProps {
export default function DatasetLeftPanel({ export default function DatasetLeftPanel({
selectedClassNum, selectedClassNum,
onSelectClass, onSelectClass,
classCounts,
objectsOnly, objectsOnly,
onObjectsOnlyChange, onObjectsOnlyChange,
search, search,
@@ -28,7 +30,11 @@ export default function DatasetLeftPanel({
<aside className="bracket panel flex flex-col shrink-0" style={{ width: 250 }}> <aside className="bracket panel flex flex-col shrink-0" style={{ width: 250 }}>
<span className="br" /> <span className="br" />
<DatasetClassList selectedClassNum={selectedClassNum} onSelect={onSelectClass} /> <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"> <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> <span className="micro">{t('dataset.filters')}</span>
+20 -8
View File
@@ -188,21 +188,32 @@ 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 grandTotal = totalCount + savedAnnotations.length 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( const validatedCount = useMemo(
() => cards.filter(c => c.status === AnnotationStatus.Validated).length, () => cards.filter(c => c.status === AnnotationStatus.Validated).length,
[cards], [cards],
@@ -224,6 +235,7 @@ export default function DatasetPage() {
<DatasetLeftPanel <DatasetLeftPanel
selectedClassNum={selectedClassNum} selectedClassNum={selectedClassNum}
onSelectClass={setSelectedClassNum} onSelectClass={setSelectedClassNum}
classCounts={classCounts}
objectsOnly={objectsOnly} objectsOnly={objectsOnly}
onObjectsOnlyChange={setObjectsOnly} onObjectsOnlyChange={setObjectsOnly}
search={search} search={search}
-17
View File
@@ -6,7 +6,6 @@ interface DatasetStatusBarProps {
firstSelectedName: string | null firstSelectedName: string | null
canValidate: boolean canValidate: boolean
onValidate: () => void onValidate: () => void
onRefreshThumbnails?: () => void
} }
export default function DatasetStatusBar({ export default function DatasetStatusBar({
@@ -15,7 +14,6 @@ export default function DatasetStatusBar({
firstSelectedName, firstSelectedName,
canValidate, canValidate,
onValidate, onValidate,
onRefreshThumbnails,
}: DatasetStatusBarProps) { }: DatasetStatusBarProps) {
const { t } = useTranslation() const { t } = useTranslation()
@@ -35,21 +33,6 @@ export default function DatasetStatusBar({
{t('dataset.validate')} ({selectedCount}) {t('dataset.validate')} ({selectedCount})
</button> </button>
<button
type="button"
className="btn btn-ghost"
onClick={onRefreshThumbnails}
disabled={!onRefreshThumbnails}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="23 4 23 10 17 10" />
<polyline points="1 20 1 14 7 14" />
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10" />
<path d="M20.49 15A9 9 0 0 1 5.64 18.36L1 14" />
</svg>
{t('dataset.refreshThumbnails')}
</button>
<span className="w-px h-5 bg-border-hair shrink-0" /> <span className="w-px h-5 bg-border-hair shrink-0" />
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
+10 -6
View File
@@ -27,13 +27,13 @@ interface DatasetTileProps {
onEditClick: () => void onEditClick: () => void
} }
const TILE_DATE_FMT = new Intl.DateTimeFormat('en', { day: '2-digit', month: 'short' })
export function formatTileDate(iso: string): string { export function formatTileDate(iso: string): string {
try { try {
const d = new Date(iso) const d = new Date(iso)
if (isNaN(d.getTime())) return '' if (isNaN(d.getTime())) return ''
return new Intl.DateTimeFormat('en', { day: '2-digit', month: 'short' }) return TILE_DATE_FMT.format(d).toUpperCase()
.format(d)
.toUpperCase()
} catch { } catch {
return '' return ''
} }
@@ -59,11 +59,15 @@ export default function DatasetTile({
: { cls: 'pill-muted', label: t('dataset.status.none') } : { cls: 'pill-muted', label: t('dataset.status.none') }
const borderCls = isSelected const borderCls = isSelected
? 'border-2 border-accent-amber' ? card.isSeed
? 'border-2 border-accent-amber ring-1 ring-accent-red'
: 'border-2 border-accent-amber'
: card.isSeed : card.isSeed
? 'border border-accent-red' ? 'border border-accent-red'
: 'border border-border-hair hover:border-accent-amber' : 'border border-border-hair hover:border-accent-amber'
const tileDate = formatTileDate(card.createdDate)
return ( return (
<div <div
onClick={onClick} onClick={onClick}
@@ -96,12 +100,12 @@ export default function DatasetTile({
/> />
{/* corner-tag top-right */} {/* corner-tag top-right */}
{formatTileDate(card.createdDate) && ( {tileDate && (
<div <div
className="absolute top-1.5 right-1.5 font-mono text-[9px] tracking-wider text-text-primary border border-border-hair rounded-[2px]" 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' }} style={{ background: 'rgba(10,13,16,0.65)', padding: '1px 5px' }}
> >
{formatTileDate(card.createdDate)} {tileDate}
</div> </div>
)} )}