mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 05:51:11 +00:00
- 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:
@@ -2,7 +2,7 @@ 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, ClassDistributionItem } from '../../types'
|
||||
import type { DetectionClass } from '../../types'
|
||||
|
||||
const FALLBACK_CLASSES: DetectionClass[] = FALLBACK_CLASS_NAMES.map((name, i) => ({
|
||||
id: i + 1,
|
||||
@@ -16,12 +16,12 @@ const FALLBACK_CLASSES: DetectionClass[] = FALLBACK_CLASS_NAMES.map((name, i) =>
|
||||
interface DatasetClassListProps {
|
||||
selectedClassNum: number
|
||||
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 [classes, setClasses] = useState<DetectionClass[]>([])
|
||||
const [counts, setCounts] = useState<Record<number, number>>({})
|
||||
|
||||
useEffect(() => {
|
||||
api.get<DetectionClass[]>(endpoints.annotations.classes())
|
||||
@@ -29,20 +29,12 @@ export default function DatasetClassList({ selectedClassNum, onSelect }: Dataset
|
||||
.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])
|
||||
|
||||
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]
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function DatasetFilterBar({
|
||||
const STATUS_OPTIONS = [
|
||||
{
|
||||
value: null,
|
||||
label: t('dataset.status.none'),
|
||||
label: t('dataset.status.all'),
|
||||
tone: 'muted' as const,
|
||||
dot: 'var(--text-muted)',
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import DatasetClassList from './DatasetClassList'
|
||||
interface DatasetLeftPanelProps {
|
||||
selectedClassNum: number
|
||||
onSelectClass: (n: number) => void
|
||||
classCounts: Record<number, number>
|
||||
objectsOnly: boolean
|
||||
onObjectsOnlyChange: (v: boolean) => void
|
||||
search: string
|
||||
@@ -15,6 +16,7 @@ interface DatasetLeftPanelProps {
|
||||
export default function DatasetLeftPanel({
|
||||
selectedClassNum,
|
||||
onSelectClass,
|
||||
classCounts,
|
||||
objectsOnly,
|
||||
onObjectsOnlyChange,
|
||||
search,
|
||||
@@ -28,7 +30,11 @@ export default function DatasetLeftPanel({
|
||||
<aside className="bracket panel flex flex-col shrink-0" style={{ width: 250 }}>
|
||||
<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">
|
||||
<span className="micro">{t('dataset.filters')}</span>
|
||||
|
||||
@@ -188,21 +188,32 @@ 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 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(
|
||||
() => cards.filter(c => c.status === AnnotationStatus.Validated).length,
|
||||
[cards],
|
||||
@@ -224,6 +235,7 @@ export default function DatasetPage() {
|
||||
<DatasetLeftPanel
|
||||
selectedClassNum={selectedClassNum}
|
||||
onSelectClass={setSelectedClassNum}
|
||||
classCounts={classCounts}
|
||||
objectsOnly={objectsOnly}
|
||||
onObjectsOnlyChange={setObjectsOnly}
|
||||
search={search}
|
||||
|
||||
@@ -6,7 +6,6 @@ interface DatasetStatusBarProps {
|
||||
firstSelectedName: string | null
|
||||
canValidate: boolean
|
||||
onValidate: () => void
|
||||
onRefreshThumbnails?: () => void
|
||||
}
|
||||
|
||||
export default function DatasetStatusBar({
|
||||
@@ -15,7 +14,6 @@ export default function DatasetStatusBar({
|
||||
firstSelectedName,
|
||||
canValidate,
|
||||
onValidate,
|
||||
onRefreshThumbnails,
|
||||
}: DatasetStatusBarProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -35,21 +33,6 @@ export default function DatasetStatusBar({
|
||||
{t('dataset.validate')} ({selectedCount})
|
||||
</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" />
|
||||
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
|
||||
@@ -27,13 +27,13 @@ interface DatasetTileProps {
|
||||
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 new Intl.DateTimeFormat('en', { day: '2-digit', month: 'short' })
|
||||
.format(d)
|
||||
.toUpperCase()
|
||||
return TILE_DATE_FMT.format(d).toUpperCase()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
@@ -59,11 +59,15 @@ export default function DatasetTile({
|
||||
: { cls: 'pill-muted', label: t('dataset.status.none') }
|
||||
|
||||
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
|
||||
? 'border border-accent-red'
|
||||
: 'border border-border-hair hover:border-accent-amber'
|
||||
|
||||
const tileDate = formatTileDate(card.createdDate)
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
@@ -96,12 +100,12 @@ export default function DatasetTile({
|
||||
/>
|
||||
|
||||
{/* corner-tag top-right */}
|
||||
{formatTileDate(card.createdDate) && (
|
||||
{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' }}
|
||||
>
|
||||
{formatTileDate(card.createdDate)}
|
||||
{tileDate}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user