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}
|
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")
|
@app.post("/api/passages/{passage_id}/reanalyze")
|
||||||
async def reanalyze_passage(passage_id: str, db=Depends(get_connection)):
|
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' }),
|
deletePassage: (id) => request(`/passages/${id}`, { method: 'DELETE' }),
|
||||||
reanalyzePassage: (id) => request(`/passages/${id}/reanalyze`, { method: 'POST' }),
|
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
|
// Resultater
|
||||||
getResults: (raceId) => request(`/results${qs({ race_id: raceId })}`),
|
getResults: (raceId) => request(`/results${qs({ race_id: raceId })}`),
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { api } from '../api.js'
|
import { api } from '../api.js'
|
||||||
|
|
||||||
function ZoomOverlay({ src, onClose }) {
|
function ZoomOverlay({ src, initialBibs = [], onClose, onConfirmBibs }) {
|
||||||
const [scale, setScale] = useState(1)
|
const [scale, setScale] = useState(1)
|
||||||
const [offset, setOffset] = useState({ x: 0, y: 0 })
|
const [offset, setOffset] = useState({ x: 0, y: 0 })
|
||||||
const [dragging, setDragging] = useState(false)
|
const [dragging, setDragging] = useState(false)
|
||||||
|
const [bibs, setBibs] = useState(initialBibs)
|
||||||
|
const [input, setInput] = useState('')
|
||||||
const dragStart = useRef(null)
|
const dragStart = useRef(null)
|
||||||
|
const inputRef = useRef(null)
|
||||||
|
|
||||||
const handleWheel = (e) => {
|
const handleWheel = (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -13,6 +16,7 @@ function ZoomOverlay({ src, onClose }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleMouseDown = (e) => {
|
const handleMouseDown = (e) => {
|
||||||
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON') return
|
||||||
setDragging(true)
|
setDragging(true)
|
||||||
dragStart.current = { x: e.clientX - offset.x, y: e.clientY - offset.y }
|
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 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClose}
|
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)',
|
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)',
|
||||||
zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
zIndex: 1000, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Bilde */}
|
||||||
<div
|
<div
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
onWheel={handleWheel}
|
onWheel={handleWheel}
|
||||||
@@ -39,7 +64,7 @@ function ZoomOverlay({ src, onClose }) {
|
|||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
onMouseLeave={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
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
@@ -48,16 +73,87 @@ function ZoomOverlay({ src, onClose }) {
|
|||||||
transform: `scale(${scale}) translate(${offset.x / scale}px, ${offset.y / scale}px)`,
|
transform: `scale(${scale}) translate(${offset.x / scale}px, ${offset.y / scale}px)`,
|
||||||
transformOrigin: 'center',
|
transformOrigin: 'center',
|
||||||
display: 'block',
|
display: 'block',
|
||||||
maxWidth: '90vw',
|
maxWidth: '95vw',
|
||||||
maxHeight: '90vh',
|
maxHeight: '80vh',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
transition: dragging ? 'none' : 'transform 0.1s',
|
transition: dragging ? 'none' : 'transform 0.1s',
|
||||||
}}
|
}}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -73,7 +169,7 @@ function imgSrc(path) {
|
|||||||
return `/api/images/${path.replace(/^\/processed\//, '')}`
|
return `/api/images/${path.replace(/^\/processed\//, '')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImageSlider({ passageId, currentImage }) {
|
function ImageSlider({ passageId, currentImage, knownBibs, onConfirmBibs }) {
|
||||||
const [images, setImages] = useState([])
|
const [images, setImages] = useState([])
|
||||||
const [index, setIndex] = useState(0)
|
const [index, setIndex] = useState(0)
|
||||||
const [zoomSrc, setZoomSrc] = useState(null)
|
const [zoomSrc, setZoomSrc] = useState(null)
|
||||||
@@ -85,19 +181,17 @@ function ImageSlider({ passageId, currentImage }) {
|
|||||||
})
|
})
|
||||||
}, [passageId])
|
}, [passageId])
|
||||||
|
|
||||||
|
const openZoom = (src) => setZoomSrc(src)
|
||||||
|
const closeZoom = () => setZoomSrc(null)
|
||||||
|
const confirmBibs = (bibs) => { setZoomSrc(null); onConfirmBibs(bibs) }
|
||||||
|
|
||||||
if (images.length === 0) {
|
if (images.length === 0) {
|
||||||
const src = imgSrc(currentImage)
|
const src = imgSrc(currentImage)
|
||||||
return src ? (
|
return src ? (
|
||||||
<>
|
<>
|
||||||
<img
|
<img src={src} alt="Passeringsbilde" className="image-preview"
|
||||||
src={src}
|
onClick={() => openZoom(src)} style={{ cursor: 'zoom-in' }} title="Klikk for å zoome" />
|
||||||
alt="Passeringsbilde"
|
{zoomSrc && <ZoomOverlay src={zoomSrc} initialBibs={knownBibs} onClose={closeZoom} onConfirmBibs={confirmBibs} />}
|
||||||
className="image-preview"
|
|
||||||
onClick={() => setZoomSrc(src)}
|
|
||||||
style={{ cursor: 'zoom-in' }}
|
|
||||||
title="Klikk for å zoome"
|
|
||||||
/>
|
|
||||||
{zoomSrc && <ZoomOverlay src={zoomSrc} onClose={() => setZoomSrc(null)} />}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="empty-state" style={{ border: '1px dashed #ddd', borderRadius: 6 }}>Bilde ikke tilgjengelig</div>
|
<div className="empty-state" style={{ border: '1px dashed #ddd', borderRadius: 6 }}>Bilde ikke tilgjengelig</div>
|
||||||
@@ -109,30 +203,17 @@ function ImageSlider({ passageId, currentImage }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<img
|
<img src={src} alt={`Bilde ${index + 1} av ${images.length}`} className="image-preview"
|
||||||
src={src}
|
onClick={() => openZoom(src)} style={{ cursor: 'zoom-in' }} title="Klikk for å zoome" />
|
||||||
alt={`Bilde ${index + 1} av ${images.length}`}
|
{zoomSrc && <ZoomOverlay src={zoomSrc} initialBibs={knownBibs} onClose={closeZoom} onConfirmBibs={confirmBibs} />}
|
||||||
className="image-preview"
|
|
||||||
onClick={() => setZoomSrc(src)}
|
|
||||||
style={{ cursor: 'zoom-in' }}
|
|
||||||
title="Klikk for å zoome"
|
|
||||||
/>
|
|
||||||
{zoomSrc && <ZoomOverlay src={zoomSrc} onClose={() => setZoomSrc(null)} />}
|
|
||||||
{images.length > 1 && (
|
{images.length > 1 && (
|
||||||
<div style={{ marginTop: '0.5rem' }}>
|
<div style={{ marginTop: '0.5rem' }}>
|
||||||
<input
|
<input type="range" min={0} max={images.length - 1} value={index}
|
||||||
type="range"
|
onChange={e => setIndex(Number(e.target.value))} style={{ width: '100%' }} />
|
||||||
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' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.75rem', color: '#888', marginTop: '0.2rem' }}>
|
||||||
<span>{fmtTs(images[0].timestamp_utc)}</span>
|
<span>{fmtTs(images[0].timestamp_utc)}</span>
|
||||||
<span style={{ color: index === images.length - 1 ? '#2ecc71' : '#888', fontWeight: index === images.length - 1 ? 600 : 400 }}>
|
<span style={{ color: index === images.length - 1 ? '#2ecc71' : '#888', fontWeight: index === images.length - 1 ? 600 : 400 }}>
|
||||||
{index + 1} / {images.length}
|
{index + 1} / {images.length}{index === images.length - 1 && ' — passeringstid'}
|
||||||
{index === images.length - 1 && ' — passeringstid'}
|
|
||||||
</span>
|
</span>
|
||||||
<span>{fmtTs(images[images.length - 1].timestamp_utc)}</span>
|
<span>{fmtTs(images[images.length - 1].timestamp_utc)}</span>
|
||||||
</div>
|
</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 () => {
|
const handleDelete = async () => {
|
||||||
if (!confirm('Slett passering?')) return
|
if (!confirm('Slett passering?')) return
|
||||||
await api.deletePassage(passage.passage_id)
|
await api.deletePassage(passage.passage_id)
|
||||||
@@ -191,7 +293,12 @@ function ReviewCard({ passage, athletes, onResolved, onDeleted, onReanalyzed })
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.5rem' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.5rem' }}>
|
||||||
<div>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<table style={{ marginBottom: '1rem' }}>
|
<table style={{ marginBottom: '1rem' }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user