mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 07:01: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 { 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]
|
||||||
|
|||||||
@@ -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)',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user