Støtte for flere bibs per bilde, EXIF-metadata og zoom i gjennomgang
Build & Deploy / build-and-deploy (push) Successful in 46s
Build & Deploy / build-and-deploy (push) Successful in 46s
- OCR: ny read_all_bibs() returnerer alle unike startnumre (≥2 sifre) per bilde
- Ingest: oppretter én passering per bib (ikke bare beste), ingen bib → needs_review
- image_tagger.py: skriv/les bib-metadata som JSON i EXIF UserComment (piexif)
- Ingest + resolve: tagger bildefilen med bibs automatisk og ved manuell bekreftelse
- API: POST /api/passages/{id}/reanalyze — re-kjør OCR på eksisterende bilde
- API: POST /api/passages/{id}/resolve oppdaterer nå EXIF med bekreftet bib
- races: ny kolonne bib_filter_enabled (med automatisk migrering) + per-løp toggle
- ReviewPage: Re-analyser-knapp og klikk-for-zoom med scroll/drag
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -51,6 +51,7 @@ export const api = {
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
deletePassage: (id) => request(`/passages/${id}`, { method: 'DELETE' }),
|
||||
reanalyzePassage: (id) => request(`/passages/${id}/reanalyze`, { method: 'POST' }),
|
||||
|
||||
// Resultater
|
||||
getResults: (raceId) => request(`/results${qs({ race_id: raceId })}`),
|
||||
|
||||
@@ -63,6 +63,18 @@ function RaceCard({ race, isActive, onActivated, onDeleted, onUpdated }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [newStation, setNewStation] = useState({ display_name: '', name: '' })
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [bibFilter, setBibFilter] = useState(!!race.bib_filter_enabled)
|
||||
|
||||
const handleBibFilterToggle = async (enabled) => {
|
||||
setBibFilter(enabled)
|
||||
await api.updateRace(race.race_id, {
|
||||
name: race.name,
|
||||
date: race.date || null,
|
||||
description: race.description || null,
|
||||
bib_filter_enabled: enabled,
|
||||
})
|
||||
onUpdated()
|
||||
}
|
||||
|
||||
const loadStations = async () => {
|
||||
const s = await api.getStations(race.race_id)
|
||||
@@ -99,6 +111,10 @@ function RaceCard({ race, isActive, onActivated, onDeleted, onUpdated }) {
|
||||
{race.date && <span style={{ marginLeft: 8, color: '#888', fontSize: '0.85rem' }}>{race.date}</span>}
|
||||
{isActive && <span className="badge badge-success" style={{ marginLeft: 8 }}>Aktivt</span>}
|
||||
{race.description && <p style={{ fontSize: '0.85rem', color: '#666', marginTop: 4 }}>{race.description}</p>}
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, marginTop: 4, fontSize: '0.82rem', color: '#555', cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={bibFilter} onChange={e => handleBibFilterToggle(e.target.checked)} />
|
||||
Filtrer OCR mot startliste
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{!isActive && (
|
||||
|
||||
@@ -1,6 +1,68 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../api.js'
|
||||
|
||||
function ZoomOverlay({ src, onClose }) {
|
||||
const [scale, setScale] = useState(1)
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 })
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const dragStart = useRef(null)
|
||||
|
||||
const handleWheel = (e) => {
|
||||
e.preventDefault()
|
||||
setScale(s => Math.min(8, Math.max(1, s - e.deltaY * 0.005)))
|
||||
}
|
||||
|
||||
const handleMouseDown = (e) => {
|
||||
setDragging(true)
|
||||
dragStart.current = { x: e.clientX - offset.x, y: e.clientY - offset.y }
|
||||
}
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
if (!dragging || !dragStart.current) return
|
||||
setOffset({ x: e.clientX - dragStart.current.x, y: e.clientY - dragStart.current.y })
|
||||
}
|
||||
|
||||
const handleMouseUp = () => setDragging(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)',
|
||||
zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
style={{ overflow: 'hidden', cursor: dragging ? 'grabbing' : 'grab', maxWidth: '95vw', maxHeight: '95vh' }}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt="Zoomet bilde"
|
||||
style={{
|
||||
transform: `scale(${scale}) translate(${offset.x / scale}px, ${offset.y / scale}px)`,
|
||||
transformOrigin: 'center',
|
||||
display: 'block',
|
||||
maxWidth: '90vw',
|
||||
maxHeight: '90vh',
|
||||
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
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function fmtTs(ts) {
|
||||
if (!ts) return '—'
|
||||
return new Date(ts).toLocaleString('no-NO', { timeZone: 'UTC', hour12: false })
|
||||
@@ -14,31 +76,48 @@ function imgSrc(path) {
|
||||
function ImageSlider({ passageId, currentImage }) {
|
||||
const [images, setImages] = useState([])
|
||||
const [index, setIndex] = useState(0)
|
||||
const [zoomSrc, setZoomSrc] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.getPassageImages(passageId).then(imgs => {
|
||||
setImages(imgs)
|
||||
// Start på siste bilde (passeringstidspunktet)
|
||||
setIndex(imgs.length > 0 ? imgs.length - 1 : 0)
|
||||
})
|
||||
}, [passageId])
|
||||
|
||||
if (images.length === 0) {
|
||||
const src = imgSrc(currentImage)
|
||||
return src
|
||||
? <img src={src} alt="Passeringsbilde" className="image-preview" />
|
||||
: <div className="empty-state" style={{ border: '1px dashed #ddd', borderRadius: 6 }}>Bilde ikke tilgjengelig</div>
|
||||
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)} />}
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-state" style={{ border: '1px dashed #ddd', borderRadius: 6 }}>Bilde ikke tilgjengelig</div>
|
||||
)
|
||||
}
|
||||
|
||||
const current = images[index]
|
||||
const src = imgSrc(current.image_path)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<img
|
||||
src={imgSrc(current.image_path)}
|
||||
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)} />}
|
||||
{images.length > 1 && (
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
<input
|
||||
@@ -63,10 +142,12 @@ function ImageSlider({ passageId, currentImage }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ReviewCard({ passage, athletes, onResolved, onDeleted }) {
|
||||
function ReviewCard({ passage, athletes, onResolved, onDeleted, onReanalyzed }) {
|
||||
const [bib, setBib] = useState(passage.bib_number || '')
|
||||
const [note, setNote] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [reanalyzing, setReanalyzing] = useState(false)
|
||||
const [reanalyzeResult, setReanalyzeResult] = useState(null)
|
||||
|
||||
const matchedAthlete = athletes.find(a => a.bib_number === bib)
|
||||
|
||||
@@ -90,6 +171,22 @@ function ReviewCard({ passage, athletes, onResolved, onDeleted }) {
|
||||
onDeleted()
|
||||
}
|
||||
|
||||
const handleReanalyze = async () => {
|
||||
setReanalyzing(true)
|
||||
setReanalyzeResult(null)
|
||||
try {
|
||||
const result = await api.reanalyzePassage(passage.passage_id)
|
||||
setReanalyzeResult(result)
|
||||
if (result.new_passages.length > 0) {
|
||||
onReanalyzed()
|
||||
}
|
||||
} catch (e) {
|
||||
setReanalyzeResult({ error: e.message })
|
||||
} finally {
|
||||
setReanalyzing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.5rem' }}>
|
||||
@@ -137,12 +234,34 @@ function ReviewCard({ passage, athletes, onResolved, onDeleted }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<button className="btn-success" onClick={handleResolve} disabled={saving}>
|
||||
{saving ? 'Lagrer...' : 'Bekreft'}
|
||||
</button>
|
||||
<button onClick={handleReanalyze} disabled={reanalyzing} style={{ background: '#8e44ad', color: '#fff', border: 'none', borderRadius: 4, padding: '0.4rem 0.9rem', cursor: 'pointer' }}>
|
||||
{reanalyzing ? 'Analyserer...' : 'Re-analyser bilde'}
|
||||
</button>
|
||||
<button className="btn-danger" onClick={handleDelete}>Slett</button>
|
||||
</div>
|
||||
|
||||
{reanalyzeResult && (
|
||||
<div style={{ marginTop: '0.75rem', fontSize: '0.85rem', padding: '0.5rem 0.75rem', background: '#f5f5f5', borderRadius: 4 }}>
|
||||
{reanalyzeResult.error ? (
|
||||
<span style={{ color: '#e74c3c' }}>Feil: {reanalyzeResult.error}</span>
|
||||
) : (
|
||||
<>
|
||||
<div>Funne bibs: {reanalyzeResult.found_bibs.length > 0 ? reanalyzeResult.found_bibs.join(', ') : 'ingen'}</div>
|
||||
{reanalyzeResult.new_passages.length > 0 ? (
|
||||
<div style={{ color: '#27ae60', marginTop: 2 }}>
|
||||
{reanalyzeResult.new_passages.length} ny(e) passering(er) opprettet: {reanalyzeResult.new_passages.map(p => p.bib_number).join(', ')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: '#888', marginTop: 2 }}>Ingen nye bibs funnet.</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,6 +307,7 @@ export default function ReviewPage({ activeRace }) {
|
||||
athletes={athletes}
|
||||
onResolved={() => removeFromQueue(p.passage_id)}
|
||||
onDeleted={() => removeFromQueue(p.passage_id)}
|
||||
onReanalyzed={load}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user