diff --git a/backend/api.py b/backend/api.py index 4a06eec..6dd4ce7 100644 --- a/backend/api.py +++ b/backend/api.py @@ -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)): """ diff --git a/frontend/src/api.js b/frontend/src/api.js index 0bf04d0..1b3b638 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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 })}`), diff --git a/frontend/src/pages/ReviewPage.jsx b/frontend/src/pages/ReviewPage.jsx index 2f56c22..0d70dd7 100644 --- a/frontend/src/pages/ReviewPage.jsx +++ b/frontend/src/pages/ReviewPage.jsx @@ -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 (
+ {/* Bilde */}
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%' }} >
-
- Rull for zoom · Dra for å flytte · Klikk utenfor for å lukke + + {/* Bib-panel nederst */} +
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', + }} + > + Startnumre: + + {bibs.map(bib => ( + + {bib} + + + ))} + + 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, + }} + /> + + + +
+ + +
+ +
+ Rull for zoom · Dra for å flytte · Enter for å legge til nummer · Esc for å lukke +
) @@ -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 ? ( <> - Passeringsbilde setZoomSrc(src)} - style={{ cursor: 'zoom-in' }} - title="Klikk for å zoome" - /> - {zoomSrc && setZoomSrc(null)} />} + Passeringsbilde openZoom(src)} style={{ cursor: 'zoom-in' }} title="Klikk for å zoome" /> + {zoomSrc && } ) : (
Bilde ikke tilgjengelig
@@ -109,30 +203,17 @@ function ImageSlider({ passageId, currentImage }) { return (
- {`Bilde setZoomSrc(src)} - style={{ cursor: 'zoom-in' }} - title="Klikk for å zoome" - /> - {zoomSrc && setZoomSrc(null)} />} + {`Bilde openZoom(src)} style={{ cursor: 'zoom-in' }} title="Klikk for å zoome" /> + {zoomSrc && } {images.length > 1 && (
- setIndex(Number(e.target.value))} - style={{ width: '100%' }} - /> + setIndex(Number(e.target.value))} style={{ width: '100%' }} />
{fmtTs(images[0].timestamp_utc)} - {index + 1} / {images.length} - {index === images.length - 1 && ' — passeringstid'} + {index + 1} / {images.length}{index === images.length - 1 && ' — passeringstid'} {fmtTs(images[images.length - 1].timestamp_utc)}
@@ -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 })
- +