Keep all burst images, use last timestamp as passage time
Build & Deploy / build-and-deploy (push) Failing after 2m41s
Build & Deploy / build-and-deploy (push) Failing after 2m41s
- passage_images table stores every image in a burst sequence
- Passage timestamp = last image (chronologically) in the burst
- Review UI: image slider to browse all burst images, slider ends
at the official passage time (rightmost = last image)
- API: GET /api/passages/{id}/images
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,64 @@ import { api } from '../api.js'
|
||||
|
||||
function fmtTs(ts) {
|
||||
if (!ts) return '—'
|
||||
return new Date(ts).toLocaleString('no-NO', { timeZone: 'UTC' })
|
||||
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 }) {
|
||||
@@ -33,28 +90,17 @@ function ReviewCard({ passage, athletes, onResolved, onDeleted }) {
|
||||
onDeleted()
|
||||
}
|
||||
|
||||
// Bygg bildesti relativt til /api/images/
|
||||
const imgSrc = passage.source_image
|
||||
? `/api/images/${passage.source_image.replace(/^\/processed\//, '')}`
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.5rem' }}>
|
||||
<div>
|
||||
{imgSrc ? (
|
||||
<img src={imgSrc} alt="Passeringsbilde" className="image-preview" />
|
||||
) : (
|
||||
<div className="empty-state" style={{ border: '1px dashed #ddd', borderRadius: 6 }}>
|
||||
Bilde ikke tilgjengelig
|
||||
</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>Tidspunkt</strong></td><td>{fmtTs(passage.timestamp_utc)}</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>
|
||||
@@ -71,7 +117,7 @@ function ReviewCard({ passage, athletes, onResolved, onDeleted }) {
|
||||
/>
|
||||
{matchedAthlete && (
|
||||
<span style={{ fontSize: '0.8rem', color: '#2ecc71', marginTop: 2 }}>
|
||||
{matchedAthlete.name} {matchedAthlete.club ? `(${matchedAthlete.club})` : ''}
|
||||
{matchedAthlete.name}{matchedAthlete.club ? ` (${matchedAthlete.club})` : ''}
|
||||
</span>
|
||||
)}
|
||||
{bib && !matchedAthlete && (
|
||||
@@ -92,16 +138,10 @@ function ReviewCard({ passage, athletes, onResolved, onDeleted }) {
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button
|
||||
className="btn-success"
|
||||
onClick={handleResolve}
|
||||
disabled={saving}
|
||||
>
|
||||
<button className="btn-success" onClick={handleResolve} disabled={saving}>
|
||||
{saving ? 'Lagrer...' : 'Bekreft'}
|
||||
</button>
|
||||
<button className="btn-danger" onClick={handleDelete}>
|
||||
Slett
|
||||
</button>
|
||||
<button className="btn-danger" onClick={handleDelete}>Slett</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,20 +164,15 @@ export default function ReviewPage() {
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const removeFromQueue = (passageId) => {
|
||||
setQueue(q => q.filter(p => p.passage_id !== passageId))
|
||||
}
|
||||
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="card"><p className="empty-state">Ingen passeringer venter på gjennomgang.</p></div>
|
||||
) : (
|
||||
<>
|
||||
<div className="alert alert-info" style={{ marginBottom: '1rem' }}>
|
||||
|
||||
Reference in New Issue
Block a user