Bib-registrering direkte i zoom-overlay med Enter-støtte
Build & Deploy / build-and-deploy (push) Successful in 37s
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:
@@ -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)):
|
||||
"""
|
||||
|
||||
@@ -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 })}`),
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
Reference in New Issue
Block a user