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.
This commit is contained in:
Armen Rohalov
2026-05-29 02:05:24 +03:00
parent f754afff46
commit 60d77d0f29
9 changed files with 875 additions and 213 deletions
+102
View File
@@ -0,0 +1,102 @@
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'
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
}
export default function DatasetClassList({ selectedClassNum, onSelect }: DatasetClassListProps) {
const { t } = useTranslation()
const [classes, setClasses] = useState<DetectionClass[]>([])
const [counts, setCounts] = useState<Record<number, number>>({})
useEffect(() => {
api.get<DetectionClass[]>(endpoints.annotations.classes())
.then(list => setClasses(list?.length ? list : 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])
useEffect(() => {
const handler = (e: KeyboardEvent) => {
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.none'),
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>
)
}
+102
View File
@@ -0,0 +1,102 @@
import { useTranslation } from 'react-i18next'
import DatasetClassList from './DatasetClassList'
interface DatasetLeftPanelProps {
selectedClassNum: number
onSelectClass: (n: number) => void
objectsOnly: boolean
onObjectsOnlyChange: (v: boolean) => void
search: string
onSearchChange: (v: string) => void
totalCount: number
validatedCount: number
}
export default function DatasetLeftPanel({
selectedClassNum,
onSelectClass,
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} />
<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>
)
}
+223 -208
View File
@@ -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 {
@@ -210,100 +202,121 @@ export default function DatasetPage() {
const maxDistCount = Math.max(...distribution.map(d => d.count), 1)
const totalPages = Math.ceil(totalCount / pageSize)
const grandTotal = totalCount + savedAnnotations.length
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}
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 +329,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>
)
}
+69
View File
@@ -0,0 +1,69 @@
import { useTranslation } from 'react-i18next'
interface DatasetStatusBarProps {
selectedCount: number
totalShown: number
firstSelectedName: string | null
canValidate: boolean
onValidate: () => void
onRefreshThumbnails?: () => void
}
export default function DatasetStatusBar({
selectedCount,
totalShown,
firstSelectedName,
canValidate,
onValidate,
onRefreshThumbnails,
}: 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>
<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">
<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>
)
}
+151
View File
@@ -0,0 +1,151 @@
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
}
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()
} 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
? 'border-2 border-accent-amber'
: card.isSeed
? 'border border-accent-red'
: 'border border-border-hair hover:border-accent-amber'
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 */}
{formatTileDate(card.createdDate) && (
<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)}
</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",
"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
View File
@@ -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": {