5393e85a74
Build & Deploy / build-and-deploy (push) Successful in 2m18s
- races table: name, date, description, is_active
- stations table: ordered checkpoints with GPS per race
- New /api/races and /api/races/{id}/stations endpoints
- Upload now requires race + station selection; uses station GPS
so images without GPS EXIF are accepted
- passages filtered by active race throughout
- RacePage: create races, manage stations (add/edit/delete checkpoints)
- Navbar shows active race name
- Start and finish stations created automatically per race
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
198 lines
6.4 KiB
React
198 lines
6.4 KiB
React
import { useEffect, useState } from 'react'
|
|
import { api } from '../api.js'
|
|
|
|
function fmtTs(ts) {
|
|
if (!ts) return '—'
|
|
return new Date(ts).toLocaleString('no-NO', { timeZone: 'UTC', hour12: false })
|
|
}
|
|
|
|
function imgSrc(path) {
|
|
if (!path) return null
|
|
return `/api/images/${path.replace(/^\/processed\//, '')}`
|
|
}
|
|
|
|
function ImageSlider({ passageId, currentImage }) {
|
|
const [images, setImages] = useState([])
|
|
const [index, setIndex] = useState(0)
|
|
|
|
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>
|
|
}
|
|
|
|
const current = images[index]
|
|
|
|
return (
|
|
<div>
|
|
<img
|
|
src={imgSrc(current.image_path)}
|
|
alt={`Bilde ${index + 1} av ${images.length}`}
|
|
className="image-preview"
|
|
/>
|
|
{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%' }}
|
|
/>
|
|
<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'}
|
|
</span>
|
|
<span>{fmtTs(images[images.length - 1].timestamp_utc)}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ReviewCard({ passage, athletes, onResolved, onDeleted }) {
|
|
const [bib, setBib] = useState(passage.bib_number || '')
|
|
const [note, setNote] = useState('')
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
const matchedAthlete = athletes.find(a => a.bib_number === bib)
|
|
|
|
const handleResolve = async () => {
|
|
setSaving(true)
|
|
try {
|
|
await api.resolvePassage(passage.passage_id, {
|
|
bib_number: bib || null,
|
|
profile_id: matchedAthlete?.profile_id || null,
|
|
review_note: note || null,
|
|
})
|
|
onResolved()
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleDelete = async () => {
|
|
if (!confirm('Slett passering?')) return
|
|
await api.deletePassage(passage.passage_id)
|
|
onDeleted()
|
|
}
|
|
|
|
return (
|
|
<div className="card">
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.5rem' }}>
|
|
<div>
|
|
<ImageSlider passageId={passage.passage_id} currentImage={passage.source_image} />
|
|
</div>
|
|
<div>
|
|
<table style={{ marginBottom: '1rem' }}>
|
|
<tbody>
|
|
<tr><td><strong>Stasjon</strong></td><td>{passage.station}</td></tr>
|
|
<tr><td><strong>Passeringstid</strong></td><td>{fmtTs(passage.timestamp_utc)}</td></tr>
|
|
<tr><td><strong>OCR-resultat</strong></td><td>{passage.bib_number || 'ingen'}</td></tr>
|
|
<tr><td><strong>Konfidens</strong></td><td>{passage.confidence != null ? (passage.confidence * 100).toFixed(0) + '%' : '—'}</td></tr>
|
|
<tr><td><strong>Merknad</strong></td><td>{passage.review_note || '—'}</td></tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
|
|
<label>Startnummer</label>
|
|
<input
|
|
type="text"
|
|
value={bib}
|
|
onChange={e => setBib(e.target.value)}
|
|
placeholder="Skriv inn riktig startnummer"
|
|
/>
|
|
{matchedAthlete && (
|
|
<span style={{ fontSize: '0.8rem', color: '#2ecc71', marginTop: 2 }}>
|
|
{matchedAthlete.name}{matchedAthlete.club ? ` (${matchedAthlete.club})` : ''}
|
|
</span>
|
|
)}
|
|
{bib && !matchedAthlete && (
|
|
<span style={{ fontSize: '0.8rem', color: '#e67e22', marginTop: 2 }}>
|
|
Startnummer ikke i startliste
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="form-group" style={{ marginBottom: '1rem' }}>
|
|
<label>Notat (valgfritt)</label>
|
|
<input
|
|
type="text"
|
|
value={note}
|
|
onChange={e => setNote(e.target.value)}
|
|
placeholder="f.eks. startnummer skjult av arm"
|
|
/>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
|
<button className="btn-success" onClick={handleResolve} disabled={saving}>
|
|
{saving ? 'Lagrer...' : 'Bekreft'}
|
|
</button>
|
|
<button className="btn-danger" onClick={handleDelete}>Slett</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function ReviewPage({ activeRace }) {
|
|
const [queue, setQueue] = useState([])
|
|
const [athletes, setAthletes] = useState([])
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
const load = async () => {
|
|
setLoading(true)
|
|
const [q, a] = await Promise.all([
|
|
api.getReviewQueue(activeRace?.race_id),
|
|
api.getAthletes(),
|
|
])
|
|
setQueue(q)
|
|
setAthletes(a)
|
|
setLoading(false)
|
|
}
|
|
|
|
useEffect(() => { load() }, [])
|
|
|
|
const removeFromQueue = (passageId) => setQueue(q => q.filter(p => p.passage_id !== passageId))
|
|
|
|
return (
|
|
<>
|
|
<h1>Manuell gjennomgang</h1>
|
|
{loading ? (
|
|
<p>Laster...</p>
|
|
) : queue.length === 0 ? (
|
|
<div className="card"><p className="empty-state">Ingen passeringer venter på gjennomgang.</p></div>
|
|
) : (
|
|
<>
|
|
<div className="alert alert-info" style={{ marginBottom: '1rem' }}>
|
|
{queue.length} passering(er) venter på manuell behandling.
|
|
</div>
|
|
{queue.map(p => (
|
|
<ReviewCard
|
|
key={p.passage_id}
|
|
passage={p}
|
|
athletes={athletes}
|
|
onResolved={() => removeFromQueue(p.passage_id)}
|
|
onDeleted={() => removeFromQueue(p.passage_id)}
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
</>
|
|
)
|
|
}
|