Bib-registrering direkte i zoom-overlay med Enter-støtte
Build & Deploy / build-and-deploy (push) Successful in 37s

- ZoomOverlay: input for startnummer (Enter = legg til, Esc = lukk)
- Kjente bibs fra OCR forhåndsutfylles i overlay
- Bekreft-knapp (antall) løser current passering + oppretter nye for ekstra bibs
- Backend: POST /api/passages/{id}/add-bib oppretter manuell søsken-passering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 09:08:43 +01:00
parent 45f7a77171
commit b400061e99
3 changed files with 205 additions and 39 deletions
+54
View File
@@ -264,6 +264,60 @@ async def remove_passage(passage_id: str, db=Depends(get_connection)):
return {"ok": True}
class AddBibRequest(BaseModel):
bib_number: str
@app.post("/api/passages/{passage_id}/add-bib")
async def add_bib_to_passage(passage_id: str, body: AddBibRequest, db=Depends(get_connection)):
"""
Opprett en ny passering manuelt fra samme bilde som en eksisterende passering.
Brukes når man ser flere løpere i ett bilde under gjennomgang.
"""
from datetime import timezone as _tz
from profile_db import get_or_create_athlete
async with db.execute("SELECT * FROM passages WHERE passage_id = ?", (passage_id,)) as cur:
row = await cur.fetchone()
if not row:
raise HTTPException(404, "Passering ikke funnet")
p = dict(row)
ts = datetime.fromisoformat(p["timestamp_utc"])
if ts.tzinfo is None:
ts = ts.replace(tzinfo=_tz.utc)
profile_id = await get_or_create_athlete(db, body.bib_number)
new_id = await log_passage(
db,
race_id=p["race_id"],
profile_id=profile_id,
bib_number=body.bib_number,
station=p["station"],
timestamp_utc=ts,
gps_lat=p["gps_lat"],
gps_lon=p["gps_lon"],
gps_alt=p["gps_alt"],
confidence=1.0,
proximity_score=0.0,
id_method="manual",
source_image=p["source_image"],
needs_review=False,
review_note=None,
)
# Oppdater EXIF-metadata
from image_tagger import write_bib_tags, read_bib_tags
img_path = Path(p["source_image"])
if img_path.exists():
existing = read_bib_tags(img_path)
all_bibs = list(dict.fromkeys((existing or {}).get("bibs", []) + [body.bib_number]))
write_bib_tags(img_path, all_bibs, station=p["station"], race_id=p["race_id"], confirmed=True)
return {"passage_id": new_id, "bib_number": body.bib_number}
@app.post("/api/passages/{passage_id}/reanalyze")
async def reanalyze_passage(passage_id: str, db=Depends(get_connection)):
"""
+5
View File
@@ -52,6 +52,11 @@ export const api = {
}),
deletePassage: (id) => request(`/passages/${id}`, { method: 'DELETE' }),
reanalyzePassage: (id) => request(`/passages/${id}/reanalyze`, { method: 'POST' }),
addBibToPassage: (id, bib_number) => request(`/passages/${id}/add-bib`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bib_number }),
}),
// Resultater
getResults: (raceId) => request(`/results${qs({ race_id: raceId })}`),
+146 -39
View File
@@ -1,11 +1,14 @@
import { useEffect, useRef, useState } from 'react'
import { api } from '../api.js'
function ZoomOverlay({ src, onClose }) {
function ZoomOverlay({ src, initialBibs = [], onClose, onConfirmBibs }) {
const [scale, setScale] = useState(1)
const [offset, setOffset] = useState({ x: 0, y: 0 })
const [dragging, setDragging] = useState(false)
const [bibs, setBibs] = useState(initialBibs)
const [input, setInput] = useState('')
const dragStart = useRef(null)
const inputRef = useRef(null)
const handleWheel = (e) => {
e.preventDefault()
@@ -13,6 +16,7 @@ function ZoomOverlay({ src, onClose }) {
}
const handleMouseDown = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON') return
setDragging(true)
dragStart.current = { x: e.clientX - offset.x, y: e.clientY - offset.y }
}
@@ -24,14 +28,35 @@ function ZoomOverlay({ src, onClose }) {
const handleMouseUp = () => setDragging(false)
const addBib = () => {
const val = input.trim().replace(/\D/g, '')
if (val && !bibs.includes(val)) {
setBibs(b => [...b, val])
}
setInput('')
inputRef.current?.focus()
}
const removeBib = (bib) => setBibs(b => b.filter(x => x !== bib))
const handleKeyDown = (e) => {
if (e.key === 'Enter') { e.preventDefault(); addBib() }
if (e.key === 'Escape') { e.preventDefault(); onClose() }
}
const handleConfirm = () => {
if (bibs.length > 0) onConfirmBibs(bibs)
else onClose()
}
return (
<div
onClick={onClose}
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)',
zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center',
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)',
zIndex: 1000, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
}}
>
{/* Bilde */}
<div
onClick={e => e.stopPropagation()}
onWheel={handleWheel}
@@ -39,7 +64,7 @@ function ZoomOverlay({ src, onClose }) {
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
style={{ overflow: 'hidden', cursor: dragging ? 'grabbing' : 'grab', maxWidth: '95vw', maxHeight: '95vh' }}
style={{ overflow: 'hidden', cursor: dragging ? 'grabbing' : 'grab', flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%' }}
>
<img
src={src}
@@ -48,16 +73,87 @@ function ZoomOverlay({ src, onClose }) {
transform: `scale(${scale}) translate(${offset.x / scale}px, ${offset.y / scale}px)`,
transformOrigin: 'center',
display: 'block',
maxWidth: '90vw',
maxHeight: '90vh',
maxWidth: '95vw',
maxHeight: '80vh',
userSelect: 'none',
transition: dragging ? 'none' : 'transform 0.1s',
}}
draggable={false}
/>
</div>
<div style={{ position: 'absolute', top: 16, right: 20, color: '#fff', fontSize: '0.8rem', opacity: 0.7 }}>
Rull for zoom · Dra for å flytte · Klikk utenfor for å lukke
{/* Bib-panel nederst */}
<div
onClick={e => e.stopPropagation()}
style={{
background: 'rgba(20,20,20,0.97)', borderTop: '1px solid #333',
width: '100%', padding: '0.75rem 1.5rem',
display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap',
}}
>
<span style={{ color: '#aaa', fontSize: '0.85rem', whiteSpace: 'nowrap' }}>Startnumre:</span>
{bibs.map(bib => (
<span key={bib} style={{
background: '#2ecc71', color: '#fff', borderRadius: 4,
padding: '0.2rem 0.5rem', fontSize: '0.9rem', fontWeight: 600,
display: 'inline-flex', alignItems: 'center', gap: 4,
}}>
{bib}
<button
onClick={() => removeBib(bib)}
style={{ background: 'none', border: 'none', color: '#fff', cursor: 'pointer', padding: 0, lineHeight: 1, fontSize: '1rem' }}
>×</button>
</span>
))}
<input
ref={inputRef}
autoFocus
type="text"
inputMode="numeric"
value={input}
onChange={e => setInput(e.target.value.replace(/\D/g, ''))}
onKeyDown={handleKeyDown}
placeholder="Skriv nr + Enter"
style={{
background: '#1a1a1a', border: '1px solid #555', color: '#fff',
borderRadius: 4, padding: '0.3rem 0.6rem', fontSize: '1rem',
width: 140,
}}
/>
<button
onClick={addBib}
style={{ background: '#555', color: '#fff', border: 'none', borderRadius: 4, padding: '0.3rem 0.7rem', cursor: 'pointer' }}
>
+ Legg til
</button>
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem' }}>
<button
onClick={handleConfirm}
disabled={bibs.length === 0}
style={{
background: bibs.length > 0 ? '#2ecc71' : '#555',
color: '#fff', border: 'none', borderRadius: 4,
padding: '0.35rem 1rem', cursor: bibs.length > 0 ? 'pointer' : 'default',
fontWeight: 600,
}}
>
Bekreft {bibs.length > 0 ? `(${bibs.length})` : ''}
</button>
<button
onClick={onClose}
style={{ background: '#333', color: '#fff', border: 'none', borderRadius: 4, padding: '0.35rem 0.75rem', cursor: 'pointer' }}
>
Lukk
</button>
</div>
<div style={{ width: '100%', color: '#555', fontSize: '0.75rem', marginTop: '0.1rem' }}>
Rull for zoom · Dra for å flytte · Enter for å legge til nummer · Esc for å lukke
</div>
</div>
</div>
)
@@ -73,7 +169,7 @@ function imgSrc(path) {
return `/api/images/${path.replace(/^\/processed\//, '')}`
}
function ImageSlider({ passageId, currentImage }) {
function ImageSlider({ passageId, currentImage, knownBibs, onConfirmBibs }) {
const [images, setImages] = useState([])
const [index, setIndex] = useState(0)
const [zoomSrc, setZoomSrc] = useState(null)
@@ -85,19 +181,17 @@ function ImageSlider({ passageId, currentImage }) {
})
}, [passageId])
const openZoom = (src) => setZoomSrc(src)
const closeZoom = () => setZoomSrc(null)
const confirmBibs = (bibs) => { setZoomSrc(null); onConfirmBibs(bibs) }
if (images.length === 0) {
const src = imgSrc(currentImage)
return src ? (
<>
<img
src={src}
alt="Passeringsbilde"
className="image-preview"
onClick={() => setZoomSrc(src)}
style={{ cursor: 'zoom-in' }}
title="Klikk for å zoome"
/>
{zoomSrc && <ZoomOverlay src={zoomSrc} onClose={() => setZoomSrc(null)} />}
<img src={src} alt="Passeringsbilde" className="image-preview"
onClick={() => openZoom(src)} style={{ cursor: 'zoom-in' }} title="Klikk for å zoome" />
{zoomSrc && <ZoomOverlay src={zoomSrc} initialBibs={knownBibs} onClose={closeZoom} onConfirmBibs={confirmBibs} />}
</>
) : (
<div className="empty-state" style={{ border: '1px dashed #ddd', borderRadius: 6 }}>Bilde ikke tilgjengelig</div>
@@ -109,30 +203,17 @@ function ImageSlider({ passageId, currentImage }) {
return (
<div>
<img
src={src}
alt={`Bilde ${index + 1} av ${images.length}`}
className="image-preview"
onClick={() => setZoomSrc(src)}
style={{ cursor: 'zoom-in' }}
title="Klikk for å zoome"
/>
{zoomSrc && <ZoomOverlay src={zoomSrc} onClose={() => setZoomSrc(null)} />}
<img src={src} alt={`Bilde ${index + 1} av ${images.length}`} className="image-preview"
onClick={() => openZoom(src)} style={{ cursor: 'zoom-in' }} title="Klikk for å zoome" />
{zoomSrc && <ZoomOverlay src={zoomSrc} initialBibs={knownBibs} onClose={closeZoom} onConfirmBibs={confirmBibs} />}
{images.length > 1 && (
<div style={{ marginTop: '0.5rem' }}>
<input
type="range"
min={0}
max={images.length - 1}
value={index}
onChange={e => setIndex(Number(e.target.value))}
style={{ width: '100%' }}
/>
<input type="range" min={0} max={images.length - 1} value={index}
onChange={e => setIndex(Number(e.target.value))} style={{ width: '100%' }} />
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.75rem', color: '#888', marginTop: '0.2rem' }}>
<span>{fmtTs(images[0].timestamp_utc)}</span>
<span style={{ color: index === images.length - 1 ? '#2ecc71' : '#888', fontWeight: index === images.length - 1 ? 600 : 400 }}>
{index + 1} / {images.length}
{index === images.length - 1 && ' — passeringstid'}
{index + 1} / {images.length}{index === images.length - 1 && ' — passeringstid'}
</span>
<span>{fmtTs(images[images.length - 1].timestamp_utc)}</span>
</div>
@@ -165,6 +246,27 @@ function ReviewCard({ passage, athletes, onResolved, onDeleted, onReanalyzed })
}
}
// Kalles fra zoom-overlay med liste av bibs
const handleConfirmBibs = async (bibs) => {
if (bibs.length === 0) return
setSaving(true)
try {
// Løs denne passeringen med første bib
await api.resolvePassage(passage.passage_id, {
bib_number: bibs[0],
profile_id: null,
review_note: note || null,
})
// Opprett nye passeringer for resten
await Promise.all(
bibs.slice(1).map(b => api.addBibToPassage(passage.passage_id, b))
)
onResolved()
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
if (!confirm('Slett passering?')) return
await api.deletePassage(passage.passage_id)
@@ -191,7 +293,12 @@ function ReviewCard({ passage, athletes, onResolved, onDeleted, onReanalyzed })
<div className="card">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.5rem' }}>
<div>
<ImageSlider passageId={passage.passage_id} currentImage={passage.source_image} />
<ImageSlider
passageId={passage.passage_id}
currentImage={passage.source_image}
knownBibs={passage.bib_number ? [passage.bib_number] : []}
onConfirmBibs={handleConfirmBibs}
/>
</div>
<div>
<table style={{ marginBottom: '1rem' }}>