mirror of
https://github.com/azaion/ui.git
synced 2026-06-22 20:41:10 +00:00
8a461a2051
Single source of truth for every /api/<service>/... URL the UI talks to: src/api/endpoints.ts (25 typed builders) re-exported via the F4 barrel. Migrates 13 production callsites in admin / annotations / flights / settings / dataset / auth / api-client / FlightContext / DetectionClasses to endpoints.* . Adds the STC-ARCH-02 static gate (--mode=api-literals in scripts/check-arch-imports.mjs, wired into scripts/run-tests.sh) that fails any new hardcoded /api/<service>/ literal in src/ outside endpoints.ts and *.test.tsx? files. Tests: +36 contract assertions in src/api/endpoints.test.ts (every builder, character-identical), +6 STC-ARCH-02 architecture cases in tests/architecture_imports.test.ts (single / double / template literal fail paths, *.test.* exemption, line-comment skip, migrated codebase pass). Fast profile 167 -> 209 PASS / 13 SKIP / 0 FAIL, +42 new, 0 regressions. Static profile 31 / 31 PASS. Closes architecture baseline finding F7. Cycle 1 of Phase B closed. Co-authored-by: Cursor <cursoragent@cursor.com>
115 lines
4.6 KiB
TypeScript
115 lines
4.6 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { FaDownload } from 'react-icons/fa'
|
|
import { api, createSSE, endpoints } from '../../api'
|
|
import { getClassColor } from './classColors'
|
|
import type { Media, AnnotationListItem, PaginatedResponse } from '../../types'
|
|
|
|
interface Props {
|
|
media: Media | null
|
|
annotations: AnnotationListItem[]
|
|
selectedAnnotation: AnnotationListItem | null
|
|
onSelect: (ann: AnnotationListItem) => void
|
|
onAnnotationsUpdate: (anns: AnnotationListItem[]) => void
|
|
onDownload?: (ann: AnnotationListItem) => void
|
|
}
|
|
|
|
export default function AnnotationsSidebar({ media, annotations, selectedAnnotation, onSelect, onAnnotationsUpdate, onDownload }: Props) {
|
|
const { t } = useTranslation()
|
|
const [detecting, setDetecting] = useState(false)
|
|
const [detectLog, setDetectLog] = useState<string[]>([])
|
|
|
|
useEffect(() => {
|
|
if (!media) return
|
|
return createSSE<{ annotationId: string; mediaId: string; status: number }>(endpoints.annotations.annotationEvents(), (event) => {
|
|
if (event.mediaId === media.id) {
|
|
api.get<PaginatedResponse<AnnotationListItem>>(
|
|
endpoints.annotations.annotationsByMedia(media.id),
|
|
).then(res => onAnnotationsUpdate(res.items)).catch(() => {})
|
|
}
|
|
})
|
|
}, [media, onAnnotationsUpdate])
|
|
|
|
const handleDetect = async () => {
|
|
if (!media) return
|
|
setDetecting(true)
|
|
setDetectLog(['Starting AI detection...'])
|
|
try {
|
|
await api.post(endpoints.detect.media(media.id))
|
|
setDetectLog(prev => [...prev, 'Detection complete.'])
|
|
} catch (e: any) {
|
|
setDetectLog(prev => [...prev, `Error: ${e.message}`])
|
|
}
|
|
}
|
|
|
|
const getRowGradient = (ann: AnnotationListItem) => {
|
|
if (ann.detections.length === 0) return 'rgba(221,221,221,0.25)'
|
|
const stops = ann.detections.map((d, i) => {
|
|
const pct = (i / Math.max(ann.detections.length - 1, 1)) * 100
|
|
const alpha = Math.min(1, d.confidence)
|
|
return `${getClassColor(d.classNum)}${Math.round(alpha * 40).toString(16).padStart(2, '0')} ${pct}%`
|
|
})
|
|
return `linear-gradient(to right, ${stops.join(', ')})`
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
<div className="p-2 border-b border-az-border flex items-center justify-between gap-1">
|
|
<span className="text-xs font-semibold text-az-muted">{t('annotations.title')}</span>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={handleDetect}
|
|
disabled={!media}
|
|
className="text-xs bg-az-blue text-white px-2 py-0.5 rounded disabled:opacity-50"
|
|
>
|
|
{t('annotations.detect')}
|
|
</button>
|
|
<button
|
|
onClick={() => selectedAnnotation && onDownload?.(selectedAnnotation)}
|
|
disabled={!selectedAnnotation}
|
|
title="Download annotation"
|
|
className="text-xs bg-az-orange text-white p-1 rounded disabled:opacity-50"
|
|
>
|
|
<FaDownload size={12} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto">
|
|
{annotations.map(ann => (
|
|
<div
|
|
key={ann.id}
|
|
onClick={() => onSelect(ann)}
|
|
className={`px-2 py-1 cursor-pointer border-b border-az-border text-xs ${
|
|
selectedAnnotation?.id === ann.id ? 'ring-1 ring-az-orange ring-inset' : ''
|
|
}`}
|
|
style={{ background: getRowGradient(ann) }}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-az-text font-mono">{ann.time || '—'}</span>
|
|
<span className="text-az-muted">{ann.detections.length > 0 ? ann.detections[0].label : '—'}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{annotations.length === 0 && (
|
|
<div className="p-2 text-az-muted text-xs text-center">{t('common.noData')}</div>
|
|
)}
|
|
</div>
|
|
|
|
{detecting && (
|
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[100]">
|
|
<div className="bg-az-panel border border-az-border rounded-lg p-4 w-96 max-h-80 flex flex-col">
|
|
<h3 className="text-white font-semibold mb-2">{t('annotations.detect')}</h3>
|
|
<div className="flex-1 overflow-y-auto bg-az-bg rounded p-2 text-xs text-az-text font-mono space-y-0.5 mb-2">
|
|
{detectLog.map((line, i) => <div key={i}>{line}</div>)}
|
|
</div>
|
|
<button onClick={() => setDetecting(false)} className="self-end text-xs bg-az-border text-az-text px-3 py-1 rounded">
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|