Files
ui/src/components/DetectionClasses.tsx
T
Armen Rohalov f754afff46
ci/woodpecker/push/build-arm Pipeline failed
annotations v2: redesign
Reskin to v2 surface/accent tokens + JetBrains Mono headings to match
_docs/ui_design/v2/plugin/annotations.html. Add scrubber with class-colored
annotation marks, canvas top bar (zoom/cursor/dims), floating AI-detection
banner, multi-band gradient rows in the annotations sidebar, class-distribution
summary footer, and DOM-overlay bbox labels with affiliation icon + readiness
dot. Split VideoPlayer chrome out into the page-level controls row
(transport/frame-step/save/delete/AI-detect/mute/volume) and a new Scrubber
component; player events replace 200ms polling.

Other:
- Auth dev bypass via VITE_DEV_AUTH_BYPASS (gated on import.meta.env.DEV).
- Mount SavedAnnotationsProvider in App so AnnotationsPage doesn't crash.
- Extract hexToRgba to src/class-colors and time helpers to
  src/features/annotations/time.ts (dedup across CanvasEditor / Sidebar /
  AnnotationsPage).
- CanvasEditor: shallow-compare label chips before commit, NaN-guard
  annotation-time parser, cancel cursor RAF on unmount.
- AnnotationsPage: track AI-banner close timer, push initial volume to the
  <video> on media change, drop the duplicate parent muted state.
- Fixed sidebar widths (resize handles removed per design).
2026-05-28 02:28:10 +03:00

130 lines
4.7 KiB
TypeScript

import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { api, endpoints } from '../api'
// classColors lives under 06_annotations until F3 moves it to its own home.
// Importing through the 06_annotations barrel would create a cycle
// (DetectionClasses -> 06_annotations barrel -> AnnotationsPage -> DetectionClasses).
// STC-ARCH-01 exempts this single path as an F3-pending edge.
import { getClassColor, FALLBACK_CLASS_NAMES } from '../class-colors'
import type { DetectionClass } from '../types'
interface Props {
selectedClassNum: number
onSelect: (classNum: number) => void
photoMode: number
onPhotoModeChange: (mode: number) => void
}
const FALLBACK_CLASSES: DetectionClass[] = [0, 20, 40].flatMap(modeOffset =>
FALLBACK_CLASS_NAMES.map((name, i) => ({
id: i + modeOffset,
name,
shortName: name.slice(0, 3),
color: getClassColor(i),
maxSizeM: 10,
photoMode: modeOffset,
})),
)
export default function DetectionClasses({ selectedClassNum, onSelect, photoMode, onPhotoModeChange }: Props) {
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))
}, [])
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const num = parseInt(e.key)
if (num >= 1 && num <= 9) {
const idx = num - 1
const cls = classes[idx + photoMode]
if (cls) onSelect(cls.id)
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [classes, photoMode, onSelect])
// Auto-select first class of current photoMode when mode changes or classes load
useEffect(() => {
const modeClasses = classes.filter(c => c.photoMode === photoMode)
const currentIsInMode = modeClasses.some(c => c.id === selectedClassNum)
if (!currentIsInMode && modeClasses.length > 0) {
onSelect(modeClasses[0].id)
}
}, [classes, photoMode, selectedClassNum, onSelect])
const modeClasses = classes.filter(c => c.photoMode === photoMode)
const modes = [
{ value: 0, label: t('annotations.regular') },
{ value: 20, label: t('annotations.winter') },
{ value: 40, label: t('annotations.night') },
]
return (
<div className="border-t border-border-hair">
{/* Section header */}
<div className="flex items-center justify-between px-3 h-9 border-b border-border-hair">
<div className="flex items-center gap-2">
<span className="sect-head">{t('annotations.classes')}</span>
<span className="mono text-[10px] text-text-muted">{modeClasses.length.toString().padStart(2, '0')}</span>
</div>
</div>
{/* Column headers */}
<div className="grid grid-cols-[28px_1fr_auto] px-3 h-6 items-center border-b border-border-hair gap-2">
<span className="micro">{t('annotations.colNum')}</span>
<span className="micro">{t('annotations.colName')}</span>
<span className="micro">{t('annotations.colKey')}</span>
</div>
{/* Class rows */}
<div>
{modeClasses.map((c, i) => {
const isActive = selectedClassNum === c.id
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={`class-row${isActive ? ' active' : ''}`}
>
<span className="swatch" style={{ background: getClassColor(c.id) }} />
<span className={`truncate${isActive ? ' text-text-primary font-medium' : ' text-text-primary'}`}>
{c.name}
</span>
<span className="kbd">{i + 1}</span>
</div>
)
})}
</div>
{/* PhotoMode segmented control */}
<div className="p-3 border-t border-border-hair">
<div className="flex items-center justify-between mb-2">
<span className="micro">{t('annotations.photoMode')}</span>
</div>
<div className="seg" style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', width: '100%' }}>
{modes.map(m => (
<button
key={m.value}
type="button"
className={`seg-btn${photoMode === m.value ? ' active' : ''}`}
onClick={() => onPhotoModeChange(m.value)}
>
{m.label}
</button>
))}
</div>
</div>
</div>
)
}