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 (