- 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>
This commit is contained in:
+2
-1
@@ -6,7 +6,8 @@ WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libglib2.0-0 \
|
||||
libgl1 \
|
||||
libgomp1 \
|
||||
tesseract-ocr \
|
||||
tesseract-ocr-eng \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
+148
-38
@@ -4,14 +4,15 @@ FastAPI REST API for timing-systemet.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import aiosqlite
|
||||
from fastapi import Depends, FastAPI, File, HTTPException, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
@@ -23,7 +24,10 @@ from profile_db import (
|
||||
get_db,
|
||||
import_startlist_csv,
|
||||
list_athletes,
|
||||
upsert_athlete,
|
||||
)
|
||||
from race_db import (
|
||||
create_race, delete_race, get_active_race, get_race, get_station,
|
||||
list_races, list_stations, set_active_race, update_race, upsert_station, delete_station,
|
||||
)
|
||||
from results import get_results
|
||||
|
||||
@@ -32,9 +36,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Behandle bilder som lå i depot fra før oppstart
|
||||
await process_existing()
|
||||
# Start fil-overvåkning i bakgrunnen
|
||||
asyncio.create_task(watch_depot())
|
||||
yield
|
||||
|
||||
@@ -49,8 +51,6 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
|
||||
# --- DB-avhengighet ---
|
||||
|
||||
async def get_connection():
|
||||
db = await get_db()
|
||||
try:
|
||||
@@ -60,7 +60,101 @@ async def get_connection():
|
||||
|
||||
|
||||
# =====================
|
||||
# Startliste-endepunkter
|
||||
# Løp (races)
|
||||
# =====================
|
||||
|
||||
class RaceRequest(BaseModel):
|
||||
name: str
|
||||
date: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
@app.get("/api/races")
|
||||
async def list_races_endpoint(db=Depends(get_connection)):
|
||||
return await list_races(db)
|
||||
|
||||
|
||||
@app.get("/api/races/active")
|
||||
async def active_race(db=Depends(get_connection)):
|
||||
race = await get_active_race(db)
|
||||
return race or {}
|
||||
|
||||
|
||||
@app.post("/api/races")
|
||||
async def create_race_endpoint(body: RaceRequest, db=Depends(get_connection)):
|
||||
return await create_race(db, body.name, body.date, body.description)
|
||||
|
||||
|
||||
@app.put("/api/races/{race_id}")
|
||||
async def update_race_endpoint(race_id: str, body: RaceRequest, db=Depends(get_connection)):
|
||||
ok = await update_race(db, race_id, body.name, body.date, body.description)
|
||||
if not ok:
|
||||
raise HTTPException(404, "Løp ikke funnet")
|
||||
return await get_race(db, race_id)
|
||||
|
||||
|
||||
@app.post("/api/races/{race_id}/activate")
|
||||
async def activate_race(race_id: str, db=Depends(get_connection)):
|
||||
ok = await set_active_race(db, race_id)
|
||||
if not ok:
|
||||
raise HTTPException(404, "Løp ikke funnet")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.delete("/api/races/{race_id}")
|
||||
async def delete_race_endpoint(race_id: str, db=Depends(get_connection)):
|
||||
ok = await delete_race(db, race_id)
|
||||
if not ok:
|
||||
raise HTTPException(404, "Løp ikke funnet")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# =====================
|
||||
# Stasjoner
|
||||
# =====================
|
||||
|
||||
class StationRequest(BaseModel):
|
||||
name: str
|
||||
display_name: str
|
||||
station_order: int
|
||||
gps_lat: Optional[float] = None
|
||||
gps_lon: Optional[float] = None
|
||||
gps_alt: Optional[float] = None
|
||||
|
||||
|
||||
@app.get("/api/races/{race_id}/stations")
|
||||
async def list_stations_endpoint(race_id: str, db=Depends(get_connection)):
|
||||
return await list_stations(db, race_id)
|
||||
|
||||
|
||||
@app.post("/api/races/{race_id}/stations")
|
||||
async def create_station_endpoint(race_id: str, body: StationRequest, db=Depends(get_connection)):
|
||||
return await upsert_station(
|
||||
db, race_id, body.name, body.display_name, body.station_order,
|
||||
body.gps_lat, body.gps_lon, body.gps_alt,
|
||||
)
|
||||
|
||||
|
||||
@app.put("/api/races/{race_id}/stations/{station_id}")
|
||||
async def update_station_endpoint(
|
||||
race_id: str, station_id: str, body: StationRequest, db=Depends(get_connection)
|
||||
):
|
||||
return await upsert_station(
|
||||
db, race_id, body.name, body.display_name, body.station_order,
|
||||
body.gps_lat, body.gps_lon, body.gps_alt, station_id=station_id,
|
||||
)
|
||||
|
||||
|
||||
@app.delete("/api/races/{race_id}/stations/{station_id}")
|
||||
async def delete_station_endpoint(race_id: str, station_id: str, db=Depends(get_connection)):
|
||||
ok = await delete_station(db, station_id)
|
||||
if not ok:
|
||||
raise HTTPException(400, "Kan ikke slette start eller mål")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# =====================
|
||||
# Startliste
|
||||
# =====================
|
||||
|
||||
@app.get("/api/athletes")
|
||||
@@ -69,15 +163,11 @@ async def list_athletes_endpoint(db=Depends(get_connection)):
|
||||
|
||||
|
||||
@app.post("/api/athletes/import", summary="Last opp startliste som CSV")
|
||||
async def import_csv(
|
||||
file: UploadFile = File(...),
|
||||
db=Depends(get_connection),
|
||||
):
|
||||
async def import_csv(file: UploadFile = File(...), db=Depends(get_connection)):
|
||||
if not file.filename.endswith(".csv"):
|
||||
raise HTTPException(400, "Kun CSV-filer støttes")
|
||||
content = (await file.read()).decode("utf-8-sig") # utf-8-sig håndterer BOM
|
||||
result = await import_startlist_csv(db, content)
|
||||
return result
|
||||
content = (await file.read()).decode("utf-8-sig")
|
||||
return await import_startlist_csv(db, content)
|
||||
|
||||
|
||||
@app.delete("/api/athletes/all")
|
||||
@@ -100,17 +190,19 @@ async def remove_athlete(profile_id: str, db=Depends(get_connection)):
|
||||
|
||||
@app.get("/api/passages")
|
||||
async def list_passages(
|
||||
race_id: Optional[str] = None,
|
||||
station: Optional[str] = None,
|
||||
needs_review: Optional[bool] = None,
|
||||
profile_id: Optional[str] = None,
|
||||
db=Depends(get_connection),
|
||||
):
|
||||
return await get_passages(db, profile_id=profile_id, station=station, needs_review=needs_review)
|
||||
return await get_passages(db, race_id=race_id, profile_id=profile_id,
|
||||
station=station, needs_review=needs_review)
|
||||
|
||||
|
||||
@app.get("/api/passages/review", summary="Hent passeringer som trenger manuell gjennomgang")
|
||||
async def review_queue(db=Depends(get_connection)):
|
||||
return await get_passages(db, needs_review=True)
|
||||
@app.get("/api/passages/review")
|
||||
async def review_queue(race_id: Optional[str] = None, db=Depends(get_connection)):
|
||||
return await get_passages(db, race_id=race_id, needs_review=True)
|
||||
|
||||
|
||||
class ResolveRequest(BaseModel):
|
||||
@@ -121,13 +213,10 @@ class ResolveRequest(BaseModel):
|
||||
|
||||
@app.post("/api/passages/{passage_id}/resolve")
|
||||
async def resolve(passage_id: str, body: ResolveRequest, db=Depends(get_connection)):
|
||||
ok = await resolve_passage(
|
||||
db,
|
||||
passage_id,
|
||||
profile_id=body.profile_id,
|
||||
bib_number=body.bib_number,
|
||||
review_note=body.review_note,
|
||||
)
|
||||
ok = await resolve_passage(db, passage_id,
|
||||
profile_id=body.profile_id,
|
||||
bib_number=body.bib_number,
|
||||
review_note=body.review_note)
|
||||
if not ok:
|
||||
raise HTTPException(404, "Passering ikke funnet")
|
||||
return {"ok": True}
|
||||
@@ -135,7 +224,6 @@ async def resolve(passage_id: str, body: ResolveRequest, db=Depends(get_connecti
|
||||
|
||||
@app.get("/api/passages/{passage_id}/images")
|
||||
async def passage_images(passage_id: str, db=Depends(get_connection)):
|
||||
"""Hent alle bilder for en passering, kronologisk sortert."""
|
||||
return await get_passage_images(db, passage_id)
|
||||
|
||||
|
||||
@@ -152,24 +240,35 @@ async def remove_passage(passage_id: str, db=Depends(get_connection)):
|
||||
# =====================
|
||||
|
||||
@app.get("/api/results")
|
||||
async def get_results_endpoint(db=Depends(get_connection)):
|
||||
return await get_results(db)
|
||||
async def get_results_endpoint(race_id: Optional[str] = None, db=Depends(get_connection)):
|
||||
return await get_results(db, race_id=race_id)
|
||||
|
||||
|
||||
# =====================
|
||||
# Bildeopplasting
|
||||
# =====================
|
||||
|
||||
VALID_IMAGE_TYPES = {"image/jpeg", "image/png"}
|
||||
VALID_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png"}
|
||||
|
||||
|
||||
@app.post("/api/upload", summary="Last opp ett eller flere bilder til depot")
|
||||
async def upload_images(files: list[UploadFile] = File(...)):
|
||||
@app.post("/api/upload")
|
||||
async def upload_images(
|
||||
files: list[UploadFile] = File(...),
|
||||
race_id: Optional[str] = None,
|
||||
station_id: Optional[str] = None,
|
||||
db=Depends(get_connection),
|
||||
):
|
||||
"""
|
||||
Lagrer opplastede bilder i /depot/ slik at ingest-prosessen plukker dem opp.
|
||||
Returnerer status per fil.
|
||||
Last opp bilder til depot. Med race_id + station_id behandles bildene
|
||||
direkte (ingen EXIF GPS-krav) og passeringen logges umiddelbart.
|
||||
"""
|
||||
# Hent stasjon for å få GPS og stasjonsnavn
|
||||
station = None
|
||||
if station_id:
|
||||
station = await get_station(db, station_id)
|
||||
if not station:
|
||||
raise HTTPException(404, "Stasjon ikke funnet")
|
||||
|
||||
results = []
|
||||
for file in files:
|
||||
suffix = Path(file.filename).suffix.lower()
|
||||
@@ -178,16 +277,29 @@ async def upload_images(files: list[UploadFile] = File(...)):
|
||||
continue
|
||||
|
||||
dest = Path("/depot") / file.filename
|
||||
# Unngå overskrivning ved navnekollisjon
|
||||
if dest.exists():
|
||||
import uuid as _uuid
|
||||
dest = Path("/depot") / f"{_uuid.uuid4().hex}_{file.filename}"
|
||||
dest = Path("/depot") / f"{uuid.uuid4().hex}_{file.filename}"
|
||||
|
||||
try:
|
||||
content = await file.read()
|
||||
dest.write_bytes(content)
|
||||
|
||||
if station:
|
||||
# Direktebehandling — flytt til processed og logg passering
|
||||
from ingest import process_image_with_override
|
||||
await process_image_with_override(
|
||||
dest,
|
||||
race_id=race_id,
|
||||
station_name=station["name"],
|
||||
gps_lat=station["gps_lat"],
|
||||
gps_lon=station["gps_lon"],
|
||||
gps_alt=station["gps_alt"],
|
||||
db=db,
|
||||
)
|
||||
|
||||
results.append({"filename": file.filename, "ok": True, "saved_as": dest.name})
|
||||
except Exception as e:
|
||||
logger.exception("Feil ved opplasting av %s", file.filename)
|
||||
results.append({"filename": file.filename, "ok": False, "error": str(e)})
|
||||
|
||||
return {"uploaded": sum(1 for r in results if r["ok"]), "results": results}
|
||||
@@ -199,11 +311,9 @@ async def upload_images(files: list[UploadFile] = File(...)):
|
||||
|
||||
@app.get("/api/images/{path:path}")
|
||||
async def serve_image(path: str):
|
||||
"""Lever behandlede bilder til frontend."""
|
||||
full_path = Path("/processed") / path
|
||||
if not full_path.exists() or not full_path.is_file():
|
||||
raise HTTPException(404, "Bilde ikke funnet")
|
||||
# Sjekk at stien er innenfor /processed (path traversal)
|
||||
try:
|
||||
full_path.resolve().relative_to(Path("/processed").resolve())
|
||||
except ValueError:
|
||||
|
||||
@@ -14,6 +14,7 @@ import logging
|
||||
import shutil
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import aiosqlite
|
||||
from watchdog.events import FileSystemEventHandler, FileCreatedEvent
|
||||
@@ -125,6 +126,69 @@ async def process_image(path: Path) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def process_image_with_override(
|
||||
path: Path,
|
||||
*,
|
||||
race_id: Optional[str],
|
||||
station_name: str,
|
||||
gps_lat: Optional[float],
|
||||
gps_lon: Optional[float],
|
||||
gps_alt: Optional[float],
|
||||
db,
|
||||
) -> None:
|
||||
"""
|
||||
Behandle bilde med manuelt oppgitt stasjon og GPS (fra web-opplasting).
|
||||
EXIF-tid brukes hvis tilgjengelig, ellers nåværende tidspunkt.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from profile_db import get_athlete_by_bib
|
||||
|
||||
logger.info("Web-opplasting: %s → stasjon=%s", path.name, station_name)
|
||||
|
||||
# Forsøk EXIF for tidsstempel, fallback til nå
|
||||
try:
|
||||
meta = parse_image(path)
|
||||
timestamp = meta.timestamp_utc
|
||||
except ExifError:
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
|
||||
ocr = read_bib(path)
|
||||
dest = _destination_path(path, timestamp)
|
||||
shutil.move(str(path), str(dest))
|
||||
|
||||
confidence = ocr.confidence
|
||||
needs_review = ocr.digits is None or confidence < MIN_AUTO_CONFIDENCE
|
||||
id_method = "bib_ocr" if not needs_review else "bib_ocr_uncertain"
|
||||
review_note = None if not needs_review else (
|
||||
"number_unreadable" if ocr.digits is None else "low_confidence"
|
||||
)
|
||||
|
||||
profile_id = None
|
||||
if ocr.digits and not needs_review:
|
||||
athlete = await get_athlete_by_bib(db, ocr.digits)
|
||||
if athlete:
|
||||
profile_id = athlete["profile_id"]
|
||||
|
||||
await log_passage(
|
||||
db,
|
||||
race_id=race_id,
|
||||
profile_id=profile_id,
|
||||
bib_number=ocr.digits,
|
||||
station=station_name,
|
||||
timestamp_utc=timestamp,
|
||||
gps_lat=gps_lat or 0.0,
|
||||
gps_lon=gps_lon or 0.0,
|
||||
gps_alt=gps_alt,
|
||||
confidence=confidence,
|
||||
proximity_score=ocr.proximity_score,
|
||||
id_method=id_method,
|
||||
source_image=str(dest),
|
||||
needs_review=needs_review,
|
||||
review_note=review_note,
|
||||
)
|
||||
logger.info("Passering logget: bib=%s station=%s", ocr.digits, station_name)
|
||||
|
||||
|
||||
async def process_existing() -> None:
|
||||
"""Behandle bilder som allerede ligger i depot/ ved oppstart."""
|
||||
for path in sorted(DEPOT_DIR.glob("*")):
|
||||
|
||||
@@ -50,6 +50,7 @@ async def _find_active_passage(
|
||||
async def log_passage(
|
||||
db: aiosqlite.Connection,
|
||||
*,
|
||||
race_id: Optional[str] = None,
|
||||
profile_id: Optional[str],
|
||||
bib_number: Optional[str],
|
||||
station: str,
|
||||
@@ -112,14 +113,14 @@ async def log_passage(
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO passages (
|
||||
passage_id, profile_id, bib_number, station,
|
||||
passage_id, race_id, profile_id, bib_number, station,
|
||||
timestamp_utc, gps_lat, gps_lon, gps_alt,
|
||||
confidence, id_method, source_image,
|
||||
needs_review, review_note
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
passage_id, profile_id, bib_number, station,
|
||||
passage_id, race_id, profile_id, bib_number, station,
|
||||
timestamp_utc.isoformat(), gps_lat, gps_lon, gps_alt,
|
||||
confidence, id_method, source_image,
|
||||
int(needs_review), review_note,
|
||||
@@ -153,12 +154,16 @@ async def get_passage_images(db: aiosqlite.Connection, passage_id: str) -> list[
|
||||
|
||||
async def get_passages(
|
||||
db: aiosqlite.Connection,
|
||||
race_id: Optional[str] = None,
|
||||
profile_id: Optional[str] = None,
|
||||
station: Optional[str] = None,
|
||||
needs_review: Optional[bool] = None,
|
||||
) -> list[dict]:
|
||||
clauses = []
|
||||
params = []
|
||||
if race_id is not None:
|
||||
clauses.append("p.race_id = ?")
|
||||
params.append(race_id)
|
||||
if profile_id is not None:
|
||||
clauses.append("p.profile_id = ?")
|
||||
params.append(profile_id)
|
||||
|
||||
@@ -25,6 +25,7 @@ async def init_db(db: aiosqlite.Connection) -> None:
|
||||
|
||||
CREATE TABLE IF NOT EXISTS passages (
|
||||
passage_id TEXT PRIMARY KEY,
|
||||
race_id TEXT REFERENCES races(race_id),
|
||||
profile_id TEXT REFERENCES athletes(profile_id),
|
||||
bib_number TEXT,
|
||||
station TEXT NOT NULL,
|
||||
@@ -50,12 +51,16 @@ async def init_db(db: aiosqlite.Connection) -> None:
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_passages_profile ON passages(profile_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_passages_race ON passages(race_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_passages_station ON passages(station);
|
||||
CREATE INDEX IF NOT EXISTS idx_passages_needs_review ON passages(needs_review);
|
||||
CREATE INDEX IF NOT EXISTS idx_passage_images_passage ON passage_images(passage_id);
|
||||
""")
|
||||
await db.commit()
|
||||
|
||||
from race_db import init_race_tables
|
||||
await init_race_tables(db)
|
||||
|
||||
|
||||
async def get_db() -> aiosqlite.Connection:
|
||||
db = await aiosqlite.connect(DB_PATH)
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
CRUD for løp (races) og stasjoner (stations).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
import aiosqlite
|
||||
|
||||
|
||||
async def init_race_tables(db: aiosqlite.Connection) -> None:
|
||||
await db.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS races (
|
||||
race_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
date TEXT,
|
||||
description TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stations (
|
||||
station_id TEXT PRIMARY KEY,
|
||||
race_id TEXT NOT NULL REFERENCES races(race_id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
station_order INTEGER NOT NULL DEFAULT 0,
|
||||
gps_lat REAL,
|
||||
gps_lon REAL,
|
||||
gps_alt REAL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(race_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_stations_race ON stations(race_id, station_order);
|
||||
""")
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Løp ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def create_race(
|
||||
db: aiosqlite.Connection,
|
||||
name: str,
|
||||
date: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
) -> dict:
|
||||
race_id = str(uuid.uuid4())
|
||||
await db.execute(
|
||||
"INSERT INTO races (race_id, name, date, description) VALUES (?, ?, ?, ?)",
|
||||
(race_id, name, date, description),
|
||||
)
|
||||
# Sett som aktivt hvis det er første løpet
|
||||
async with db.execute("SELECT COUNT(*) FROM races") as cur:
|
||||
count = (await cur.fetchone())[0]
|
||||
if count == 1:
|
||||
await db.execute("UPDATE races SET is_active = 1 WHERE race_id = ?", (race_id,))
|
||||
|
||||
# Opprett standard start- og målstasjon
|
||||
for order, (sname, display) in enumerate([("start", "Start"), ("finish", "Mål")]):
|
||||
await db.execute(
|
||||
"""INSERT INTO stations (station_id, race_id, name, display_name, station_order)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(str(uuid.uuid4()), race_id, sname, display, order * 1000),
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return await get_race(db, race_id)
|
||||
|
||||
|
||||
async def get_race(db: aiosqlite.Connection, race_id: str) -> Optional[dict]:
|
||||
async with db.execute("SELECT * FROM races WHERE race_id = ?", (race_id,)) as cur:
|
||||
row = await cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def list_races(db: aiosqlite.Connection) -> list[dict]:
|
||||
async with db.execute("SELECT * FROM races ORDER BY date DESC, created_at DESC") as cur:
|
||||
rows = await cur.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
async def update_race(
|
||||
db: aiosqlite.Connection,
|
||||
race_id: str,
|
||||
name: str,
|
||||
date: Optional[str],
|
||||
description: Optional[str],
|
||||
) -> bool:
|
||||
cur = await db.execute(
|
||||
"UPDATE races SET name = ?, date = ?, description = ? WHERE race_id = ?",
|
||||
(name, date, description, race_id),
|
||||
)
|
||||
await db.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
async def delete_race(db: aiosqlite.Connection, race_id: str) -> bool:
|
||||
cur = await db.execute("DELETE FROM races WHERE race_id = ?", (race_id,))
|
||||
await db.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
async def set_active_race(db: aiosqlite.Connection, race_id: str) -> bool:
|
||||
await db.execute("UPDATE races SET is_active = 0")
|
||||
cur = await db.execute(
|
||||
"UPDATE races SET is_active = 1 WHERE race_id = ?", (race_id,)
|
||||
)
|
||||
await db.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
async def get_active_race(db: aiosqlite.Connection) -> Optional[dict]:
|
||||
async with db.execute("SELECT * FROM races WHERE is_active = 1 LIMIT 1") as cur:
|
||||
row = await cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
# ── Stasjoner ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async def list_stations(db: aiosqlite.Connection, race_id: str) -> list[dict]:
|
||||
async with db.execute(
|
||||
"SELECT * FROM stations WHERE race_id = ? ORDER BY station_order",
|
||||
(race_id,),
|
||||
) as cur:
|
||||
rows = await cur.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
async def get_station(db: aiosqlite.Connection, station_id: str) -> Optional[dict]:
|
||||
async with db.execute(
|
||||
"SELECT * FROM stations WHERE station_id = ?", (station_id,)
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def create_station(
|
||||
db: aiosqlite.Connection,
|
||||
race_id: str,
|
||||
name: str,
|
||||
display_name: str,
|
||||
station_order: int,
|
||||
gps_lat: Optional[float] = None,
|
||||
gps_lon: Optional[float] = None,
|
||||
gps_alt: Optional[float] = None,
|
||||
) -> dict:
|
||||
station_id = str(uuid.uuid4())
|
||||
await db.execute(
|
||||
"""INSERT INTO stations
|
||||
(station_id, race_id, name, display_name, station_order, gps_lat, gps_lon, gps_alt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(station_id, race_id, name, display_name, station_order, gps_lat, gps_lon, gps_alt),
|
||||
)
|
||||
await db.commit()
|
||||
return dict((await db.execute(
|
||||
"SELECT * FROM stations WHERE station_id = ?", (station_id,)
|
||||
)).get_cursor() or {})
|
||||
|
||||
|
||||
async def upsert_station(
|
||||
db: aiosqlite.Connection,
|
||||
race_id: str,
|
||||
name: str,
|
||||
display_name: str,
|
||||
station_order: int,
|
||||
gps_lat: Optional[float] = None,
|
||||
gps_lon: Optional[float] = None,
|
||||
gps_alt: Optional[float] = None,
|
||||
station_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
if station_id:
|
||||
await db.execute(
|
||||
"""UPDATE stations SET name=?, display_name=?, station_order=?,
|
||||
gps_lat=?, gps_lon=?, gps_alt=? WHERE station_id=?""",
|
||||
(name, display_name, station_order, gps_lat, gps_lon, gps_alt, station_id),
|
||||
)
|
||||
else:
|
||||
station_id = str(uuid.uuid4())
|
||||
await db.execute(
|
||||
"""INSERT INTO stations
|
||||
(station_id, race_id, name, display_name, station_order, gps_lat, gps_lon, gps_alt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(station_id, race_id, name, display_name, station_order, gps_lat, gps_lon, gps_alt),
|
||||
)
|
||||
await db.commit()
|
||||
async with db.execute("SELECT * FROM stations WHERE station_id = ?", (station_id,)) as cur:
|
||||
row = await cur.fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
async def delete_station(db: aiosqlite.Connection, station_id: str) -> bool:
|
||||
# Ikke tillat sletting av start/finish
|
||||
async with db.execute(
|
||||
"SELECT name FROM stations WHERE station_id = ?", (station_id,)
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
if row and row["name"] in ("start", "finish"):
|
||||
return False
|
||||
cur = await db.execute("DELETE FROM stations WHERE station_id = ?", (station_id,))
|
||||
await db.commit()
|
||||
return cur.rowcount > 0
|
||||
@@ -2,7 +2,7 @@ fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
pillow==11.1.0
|
||||
piexif==1.1.3
|
||||
easyocr==1.7.2
|
||||
pytesseract==0.3.13
|
||||
opencv-python-headless==4.11.0.86
|
||||
python-multipart==0.0.20
|
||||
watchdog==6.0.0
|
||||
|
||||
+7
-6
@@ -7,21 +7,22 @@ from typing import Optional
|
||||
|
||||
import aiosqlite
|
||||
|
||||
|
||||
async def get_results(db: aiosqlite.Connection) -> list[dict]:
|
||||
async def get_results(db: aiosqlite.Connection, race_id: Optional[str] = None) -> list[dict]:
|
||||
"""
|
||||
Hent totalresultat for alle utøvere som har passert start og mål.
|
||||
Returnerer sortert liste med split-tider.
|
||||
"""
|
||||
# Hent alle bekreftede passeringer gruppert per utøver
|
||||
async with db.execute("""
|
||||
race_filter = "AND p.race_id = ?" if race_id else ""
|
||||
params = [race_id] if race_id else []
|
||||
|
||||
async with db.execute(f"""
|
||||
SELECT p.profile_id, p.bib_number, a.name, a.club,
|
||||
p.station, p.timestamp_utc
|
||||
FROM passages p
|
||||
LEFT JOIN athletes a ON a.profile_id = p.profile_id
|
||||
WHERE p.needs_review = 0 AND p.profile_id IS NOT NULL
|
||||
WHERE p.needs_review = 0 AND p.profile_id IS NOT NULL {race_filter}
|
||||
ORDER BY p.profile_id, p.timestamp_utc
|
||||
""") as cur:
|
||||
""", params) as cur:
|
||||
rows = await cur.fetchall()
|
||||
|
||||
# Grupper per utøver
|
||||
|
||||
+25
-9
@@ -1,36 +1,52 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { NavLink, Route, Routes } from 'react-router-dom'
|
||||
import { api } from './api.js'
|
||||
import StartlistPage from './pages/StartlistPage.jsx'
|
||||
import ReviewPage from './pages/ReviewPage.jsx'
|
||||
import ResultsPage from './pages/ResultsPage.jsx'
|
||||
import PassagesPage from './pages/PassagesPage.jsx'
|
||||
import UploadPage from './pages/UploadPage.jsx'
|
||||
import RacePage from './pages/RacePage.jsx'
|
||||
import './App.css'
|
||||
|
||||
function Nav() {
|
||||
function Nav({ activeRace }) {
|
||||
const linkClass = ({ isActive }) => (isActive ? 'nav-link active' : 'nav-link')
|
||||
return (
|
||||
<nav className="navbar">
|
||||
<span className="navbar-brand">Timing Admin</span>
|
||||
<NavLink to="/" className={linkClass} end>Startliste</NavLink>
|
||||
<span className="navbar-brand">Timing</span>
|
||||
<NavLink to="/" className={linkClass} end>Løp</NavLink>
|
||||
<NavLink to="/startlist" className={linkClass}>Startliste</NavLink>
|
||||
<NavLink to="/upload" className={linkClass}>Last opp</NavLink>
|
||||
<NavLink to="/passages" className={linkClass}>Passeringer</NavLink>
|
||||
<NavLink to="/review" className={linkClass}>Gjennomgang</NavLink>
|
||||
<NavLink to="/results" className={linkClass}>Resultater</NavLink>
|
||||
<NavLink to="/upload" className={linkClass}>Last opp</NavLink>
|
||||
{activeRace && (
|
||||
<span style={{ marginLeft: 'auto', fontSize: '0.8rem', color: '#2ecc71', fontWeight: 600 }}>
|
||||
★ {activeRace.name}
|
||||
</span>
|
||||
)}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [activeRace, setActiveRace] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.getActiveRace().then(r => setActiveRace(r?.race_id ? r : null))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<Nav activeRace={activeRace} />
|
||||
<main className="container">
|
||||
<Routes>
|
||||
<Route path="/" element={<StartlistPage />} />
|
||||
<Route path="/passages" element={<PassagesPage />} />
|
||||
<Route path="/review" element={<ReviewPage />} />
|
||||
<Route path="/results" element={<ResultsPage />} />
|
||||
<Route path="/" element={<RacePage onRaceChange={() => api.getActiveRace().then(r => setActiveRace(r?.race_id ? r : null))} />} />
|
||||
<Route path="/startlist" element={<StartlistPage />} />
|
||||
<Route path="/upload" element={<UploadPage />} />
|
||||
<Route path="/passages" element={<PassagesPage activeRace={activeRace} />} />
|
||||
<Route path="/review" element={<ReviewPage activeRace={activeRace} />} />
|
||||
<Route path="/results" element={<ResultsPage activeRace={activeRace} />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</>
|
||||
|
||||
+38
-14
@@ -9,7 +9,28 @@ async function request(path, options = {}) {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
function qs(params) {
|
||||
const s = new URLSearchParams(
|
||||
Object.fromEntries(Object.entries(params).filter(([, v]) => v != null))
|
||||
).toString()
|
||||
return s ? '?' + s : ''
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// Løp
|
||||
getRaces: () => request('/races'),
|
||||
getActiveRace: () => request('/races/active'),
|
||||
createRace: (body) => request('/races', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }),
|
||||
updateRace: (id, body) => request(`/races/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }),
|
||||
activateRace: (id) => request(`/races/${id}/activate`, { method: 'POST' }),
|
||||
deleteRace: (id) => request(`/races/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// Stasjoner
|
||||
getStations: (raceId) => request(`/races/${raceId}/stations`),
|
||||
createStation: (raceId, body) => request(`/races/${raceId}/stations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }),
|
||||
updateStation: (raceId, stationId, body) => request(`/races/${raceId}/stations/${stationId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }),
|
||||
deleteStation: (raceId, stationId) => request(`/races/${raceId}/stations/${stationId}`, { method: 'DELETE' }),
|
||||
|
||||
// Utøvere
|
||||
getAthletes: () => request('/athletes'),
|
||||
importCsv: (file) => {
|
||||
@@ -21,22 +42,25 @@ export const api = {
|
||||
clearAthletes: () => request('/athletes/all', { method: 'DELETE' }),
|
||||
|
||||
// Passeringer
|
||||
getPassages: (params = {}) => {
|
||||
const qs = new URLSearchParams(
|
||||
Object.fromEntries(Object.entries(params).filter(([, v]) => v != null))
|
||||
).toString()
|
||||
return request(`/passages${qs ? '?' + qs : ''}`)
|
||||
},
|
||||
getReviewQueue: () => request('/passages/review'),
|
||||
getPassages: (params = {}) => request(`/passages${qs(params)}`),
|
||||
getReviewQueue: (raceId) => request(`/passages/review${qs({ race_id: raceId })}`),
|
||||
getPassageImages: (id) => request(`/passages/${id}/images`),
|
||||
resolvePassage: (id, body) =>
|
||||
request(`/passages/${id}/resolve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
resolvePassage: (id, body) => request(`/passages/${id}/resolve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
deletePassage: (id) => request(`/passages/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// Resultater
|
||||
getResults: () => request('/results'),
|
||||
getResults: (raceId) => request(`/results${qs({ race_id: raceId })}`),
|
||||
|
||||
// Opplasting
|
||||
uploadImages: (files, raceId, stationId) => {
|
||||
const form = new FormData()
|
||||
files.forEach(f => form.append('files', f))
|
||||
return request(`/upload${qs({ race_id: raceId, station_id: stationId })}`, {
|
||||
method: 'POST', body: form,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -6,19 +6,20 @@ function fmtTs(ts) {
|
||||
return new Date(ts).toLocaleString('no-NO', { timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
export default function PassagesPage() {
|
||||
export default function PassagesPage({ activeRace }) {
|
||||
const [passages, setPassages] = useState([])
|
||||
const [filter, setFilter] = useState({ station: '', needs_review: '' })
|
||||
|
||||
const load = async () => {
|
||||
const params = {}
|
||||
if (activeRace?.race_id) params.race_id = activeRace.race_id
|
||||
if (filter.station) params.station = filter.station
|
||||
if (filter.needs_review !== '') params.needs_review = filter.needs_review === 'true'
|
||||
const data = await api.getPassages(params)
|
||||
setPassages(data)
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [filter])
|
||||
useEffect(() => { load() }, [filter, activeRace])
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('Slett passering?')) return
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api.js'
|
||||
|
||||
function StationRow({ station, raceId, onUpdated, onDeleted }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [form, setForm] = useState({
|
||||
display_name: station.display_name,
|
||||
gps_lat: station.gps_lat ?? '',
|
||||
gps_lon: station.gps_lon ?? '',
|
||||
gps_alt: station.gps_alt ?? '',
|
||||
})
|
||||
|
||||
const isFixed = station.name === 'start' || station.name === 'finish'
|
||||
|
||||
const handleSave = async () => {
|
||||
await api.updateStation(raceId, station.station_id, {
|
||||
name: station.name,
|
||||
display_name: form.display_name,
|
||||
station_order: station.station_order,
|
||||
gps_lat: form.gps_lat !== '' ? Number(form.gps_lat) : null,
|
||||
gps_lon: form.gps_lon !== '' ? Number(form.gps_lon) : null,
|
||||
gps_alt: form.gps_alt !== '' ? Number(form.gps_alt) : null,
|
||||
})
|
||||
setEditing(false)
|
||||
onUpdated()
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<tr>
|
||||
<td>{station.name}</td>
|
||||
<td><input type="text" value={form.display_name} onChange={e => setForm(f => ({ ...f, display_name: e.target.value }))} style={{ width: 130 }} /></td>
|
||||
<td><input type="number" step="any" placeholder="Lat" value={form.gps_lat} onChange={e => setForm(f => ({ ...f, gps_lat: e.target.value }))} style={{ width: 100 }} /></td>
|
||||
<td><input type="number" step="any" placeholder="Lon" value={form.gps_lon} onChange={e => setForm(f => ({ ...f, gps_lon: e.target.value }))} style={{ width: 100 }} /></td>
|
||||
<td><input type="number" step="any" placeholder="Alt" value={form.gps_alt} onChange={e => setForm(f => ({ ...f, gps_alt: e.target.value }))} style={{ width: 70 }} /></td>
|
||||
<td style={{ whiteSpace: 'nowrap' }}>
|
||||
<button className="btn-success btn-sm" onClick={handleSave} style={{ marginRight: 4 }}>Lagre</button>
|
||||
<button className="btn-sm" style={{ background: '#eee' }} onClick={() => setEditing(false)}>Avbryt</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td><code>{station.name}</code></td>
|
||||
<td>{station.display_name}</td>
|
||||
<td>{station.gps_lat ?? '—'}</td>
|
||||
<td>{station.gps_lon ?? '—'}</td>
|
||||
<td>{station.gps_alt ?? '—'}</td>
|
||||
<td style={{ whiteSpace: 'nowrap' }}>
|
||||
<button className="btn-sm btn-primary" onClick={() => setEditing(true)} style={{ marginRight: 4 }}>Rediger</button>
|
||||
{!isFixed && (
|
||||
<button className="btn-sm btn-danger" onClick={onDeleted}>Slett</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function RaceCard({ race, isActive, onActivated, onDeleted, onUpdated }) {
|
||||
const [stations, setStations] = useState([])
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [newStation, setNewStation] = useState({ display_name: '', name: '' })
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
const loadStations = async () => {
|
||||
const s = await api.getStations(race.race_id)
|
||||
setStations(s)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (expanded) loadStations()
|
||||
}, [expanded])
|
||||
|
||||
const handleAddStation = async () => {
|
||||
if (!newStation.name || !newStation.display_name) return
|
||||
const maxOrder = stations.reduce((m, s) => Math.max(m, s.station_order), 0)
|
||||
await api.createStation(race.race_id, {
|
||||
name: newStation.name,
|
||||
display_name: newStation.display_name,
|
||||
station_order: maxOrder + 100,
|
||||
})
|
||||
setNewStation({ display_name: '', name: '' })
|
||||
setAdding(false)
|
||||
loadStations()
|
||||
}
|
||||
|
||||
const handleDeleteStation = async (stationId) => {
|
||||
await api.deleteStation(race.race_id, stationId)
|
||||
loadStations()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ borderLeft: isActive ? '4px solid #2ecc71' : '4px solid transparent' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<strong style={{ fontSize: '1.05rem' }}>{race.name}</strong>
|
||||
{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>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{!isActive && (
|
||||
<button className="btn-success btn-sm" onClick={onActivated}>Velg</button>
|
||||
)}
|
||||
<button className="btn-sm btn-primary" onClick={() => setExpanded(e => !e)}>
|
||||
Stasjoner {expanded ? '▲' : '▼'}
|
||||
</button>
|
||||
<button className="btn-danger btn-sm" onClick={onDeleted}>Slett</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Navn</th>
|
||||
<th>Lat</th>
|
||||
<th>Lon</th>
|
||||
<th>Alt (m)</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stations.map(s => (
|
||||
<StationRow
|
||||
key={s.station_id}
|
||||
station={s}
|
||||
raceId={race.race_id}
|
||||
onUpdated={loadStations}
|
||||
onDeleted={() => handleDeleteStation(s.station_id)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{adding ? (
|
||||
<div className="form-row" style={{ marginTop: '0.75rem' }}>
|
||||
<div className="form-group">
|
||||
<label>ID (f.eks. cp1)</label>
|
||||
<input type="text" style={{ width: 100 }} value={newStation.name}
|
||||
onChange={e => setNewStation(s => ({ ...s, name: e.target.value }))} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Visningsnavn</label>
|
||||
<input type="text" style={{ width: 150 }} value={newStation.display_name}
|
||||
onChange={e => setNewStation(s => ({ ...s, display_name: e.target.value }))} />
|
||||
</div>
|
||||
<button className="btn-success btn-sm" onClick={handleAddStation}>Legg til</button>
|
||||
<button className="btn-sm" style={{ background: '#eee' }} onClick={() => setAdding(false)}>Avbryt</button>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn-sm btn-primary" style={{ marginTop: '0.75rem' }} onClick={() => setAdding(true)}>
|
||||
+ Ny mellomtid
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RacePage() {
|
||||
const [races, setRaces] = useState([])
|
||||
const [activeId, setActiveId] = useState(null)
|
||||
const [form, setForm] = useState({ name: '', date: '', description: '' })
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const load = async () => {
|
||||
const [r, a] = await Promise.all([api.getRaces(), api.getActiveRace()])
|
||||
setRaces(r)
|
||||
setActiveId(a?.race_id || null)
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const handleCreate = async (e) => {
|
||||
e.preventDefault()
|
||||
await api.createRace({ name: form.name, date: form.date || null, description: form.description || null })
|
||||
setForm({ name: '', date: '', description: '' })
|
||||
setCreating(false)
|
||||
load()
|
||||
}
|
||||
|
||||
const handleActivate = async (id) => {
|
||||
await api.activateRace(id)
|
||||
setActiveId(id)
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('Slette løpet og alle tilknyttede stasjoner?')) return
|
||||
await api.deleteRace(id)
|
||||
load()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Løp</h1>
|
||||
|
||||
<div className="card">
|
||||
{creating ? (
|
||||
<form onSubmit={handleCreate}>
|
||||
<h2>Nytt løp</h2>
|
||||
<div className="form-row">
|
||||
<div className="form-group" style={{ flex: 2 }}>
|
||||
<label>Navn *</label>
|
||||
<input type="text" required value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} placeholder="f.eks. Skogsløpet 2026" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Dato</label>
|
||||
<input type="date" value={form.date} onChange={e => setForm(f => ({ ...f, date: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
|
||||
<label>Beskrivelse</label>
|
||||
<input type="text" value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} placeholder="Valgfri beskrivelse" />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="submit" className="btn-primary">Opprett</button>
|
||||
<button type="button" className="btn-sm" style={{ background: '#eee' }} onClick={() => setCreating(false)}>Avbryt</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<button className="btn-primary" onClick={() => setCreating(true)}>+ Nytt løp</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{races.length === 0 ? (
|
||||
<div className="card"><p className="empty-state">Ingen løp registrert ennå.</p></div>
|
||||
) : (
|
||||
races.map(r => (
|
||||
<RaceCard
|
||||
key={r.race_id}
|
||||
race={r}
|
||||
isActive={r.race_id === activeId}
|
||||
onActivated={() => handleActivate(r.race_id)}
|
||||
onDeleted={() => handleDelete(r.race_id)}
|
||||
onUpdated={load}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -6,17 +6,17 @@ function fmtTs(ts) {
|
||||
return new Date(ts).toLocaleTimeString('no-NO', { timeZone: 'UTC', hour12: false })
|
||||
}
|
||||
|
||||
export default function ResultsPage() {
|
||||
export default function ResultsPage({ activeRace }) {
|
||||
const [results, setResults] = useState([])
|
||||
const [expanded, setExpanded] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
api.getResults().then(data => {
|
||||
api.getResults(activeRace?.race_id).then(data => {
|
||||
setResults(data)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
}, [activeRace])
|
||||
|
||||
const toggleExpand = (id) => setExpanded(e => e === id ? null : id)
|
||||
|
||||
|
||||
@@ -149,14 +149,17 @@ function ReviewCard({ passage, athletes, onResolved, onDeleted }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function ReviewPage() {
|
||||
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(), api.getAthletes()])
|
||||
const [q, a] = await Promise.all([
|
||||
api.getReviewQueue(activeRace?.race_id),
|
||||
api.getAthletes(),
|
||||
])
|
||||
setQueue(q)
|
||||
setAthletes(a)
|
||||
setLoading(false)
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
const ACCEPTED = '.jpg,.jpeg,.png'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../api.js'
|
||||
|
||||
function FileRow({ file, result }) {
|
||||
const status = result
|
||||
? result.ok
|
||||
? <span className="badge badge-success">OK — {result.saved_as}</span>
|
||||
? <span className="badge badge-success">OK</span>
|
||||
: <span className="badge badge-danger">Feil: {result.error}</span>
|
||||
: <span className="badge badge-warning">Venter...</span>
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td>{file.name}</td>
|
||||
@@ -19,6 +17,10 @@ function FileRow({ file, result }) {
|
||||
}
|
||||
|
||||
export default function UploadPage() {
|
||||
const [races, setRaces] = useState([])
|
||||
const [stations, setStations] = useState([])
|
||||
const [selectedRace, setSelectedRace] = useState('')
|
||||
const [selectedStation, setSelectedStation] = useState('')
|
||||
const [files, setFiles] = useState([])
|
||||
const [results, setResults] = useState({})
|
||||
const [uploading, setUploading] = useState(false)
|
||||
@@ -26,6 +28,24 @@ export default function UploadPage() {
|
||||
const inputRef = useRef()
|
||||
const dropRef = useRef()
|
||||
|
||||
useEffect(() => {
|
||||
api.getRaces().then(r => {
|
||||
setRaces(r)
|
||||
const active = r.find(x => x.is_active)
|
||||
if (active) setSelectedRace(active.race_id)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedRace) { setStations([]); return }
|
||||
api.getStations(selectedRace).then(s => {
|
||||
setStations(s)
|
||||
// Velg start som default
|
||||
const start = s.find(x => x.name === 'start')
|
||||
setSelectedStation(start?.station_id || s[0]?.station_id || '')
|
||||
})
|
||||
}, [selectedRace])
|
||||
|
||||
const addFiles = (newFiles) => {
|
||||
const valid = Array.from(newFiles).filter(f =>
|
||||
f.type === 'image/jpeg' || f.type === 'image/png'
|
||||
@@ -48,21 +68,18 @@ export default function UploadPage() {
|
||||
setResults({})
|
||||
setSummary(null)
|
||||
|
||||
// Send i bolker på 10 for å unngå for store requests
|
||||
const BATCH = 10
|
||||
const allResults = {}
|
||||
|
||||
for (let i = 0; i < files.length; i += BATCH) {
|
||||
const batch = files.slice(i, i + BATCH)
|
||||
const form = new FormData()
|
||||
batch.forEach(f => form.append('files', f))
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/upload', { method: 'POST', body: form })
|
||||
const data = await res.json()
|
||||
data.results.forEach((r, j) => {
|
||||
allResults[batch[j].name] = r
|
||||
})
|
||||
const data = await api.uploadImages(
|
||||
batch,
|
||||
selectedRace || null,
|
||||
selectedStation || null,
|
||||
)
|
||||
data.results.forEach((r, j) => { allResults[batch[j].name] = r })
|
||||
setResults({ ...allResults })
|
||||
} catch (err) {
|
||||
batch.forEach(f => { allResults[f.name] = { ok: false, error: err.message } })
|
||||
@@ -82,17 +99,45 @@ export default function UploadPage() {
|
||||
inputRef.current.value = ''
|
||||
}
|
||||
|
||||
const selectedStationObj = stations.find(s => s.station_id === selectedStation)
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Last opp bilder</h1>
|
||||
|
||||
<div className="card">
|
||||
<h2>Velg bilder</h2>
|
||||
<p style={{ fontSize: '0.85rem', color: '#666', marginBottom: '0.75rem' }}>
|
||||
JPEG og PNG støttes. Bildene legges i depot og behandles automatisk (EXIF valideres, OCR kjøres).
|
||||
</p>
|
||||
<h2>Løp og stasjon</h2>
|
||||
<div className="form-row">
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label>Løp</label>
|
||||
<select value={selectedRace} onChange={e => setSelectedRace(e.target.value)}>
|
||||
<option value="">— Velg løp —</option>
|
||||
{races.map(r => (
|
||||
<option key={r.race_id} value={r.race_id}>
|
||||
{r.name}{r.date ? ` (${r.date})` : ''}{r.is_active ? ' ★' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label>Stasjon</label>
|
||||
<select value={selectedStation} onChange={e => setSelectedStation(e.target.value)} disabled={!selectedRace}>
|
||||
<option value="">— Velg stasjon —</option>
|
||||
{stations.map(s => (
|
||||
<option key={s.station_id} value={s.station_id}>{s.display_name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{selectedStationObj && (selectedStationObj.gps_lat == null) && (
|
||||
<div className="alert alert-info" style={{ marginTop: 0 }}>
|
||||
Ingen GPS satt for denne stasjonen — du kan sette det under Løp → Stasjoner.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dra-og-slipp-sone */}
|
||||
<div className="card">
|
||||
<h2>Velg bilder</h2>
|
||||
<div
|
||||
ref={dropRef}
|
||||
onDrop={handleDrop}
|
||||
@@ -100,47 +145,40 @@ export default function UploadPage() {
|
||||
onDragLeave={() => dropRef.current.classList.remove('drag-over')}
|
||||
onClick={() => inputRef.current.click()}
|
||||
style={{
|
||||
border: '2px dashed #ddd',
|
||||
borderRadius: 8,
|
||||
padding: '2rem',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
color: '#aaa',
|
||||
marginBottom: '0.75rem',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
border: '2px dashed #ddd', borderRadius: 8, padding: '2rem',
|
||||
textAlign: 'center', cursor: 'pointer', color: '#aaa',
|
||||
marginBottom: '0.75rem', transition: 'border-color 0.15s, background 0.15s',
|
||||
}}
|
||||
>
|
||||
Dra og slipp bilder hit, eller klikk for å velge
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED}
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={e => addFiles(e.target.files)}
|
||||
/>
|
||||
<input ref={inputRef} type="file" accept=".jpg,.jpeg,.png" multiple
|
||||
style={{ display: 'none' }} onChange={e => addFiles(e.target.files)} />
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="form-row" style={{ marginTop: '0.5rem' }}>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={handleUpload}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? `Laster opp... (${Object.keys(results).length}/${files.length})` : `Last opp ${files.length} bilde(r)`}
|
||||
<button className="btn-primary" onClick={handleUpload}
|
||||
disabled={uploading || !selectedRace || !selectedStation}>
|
||||
{uploading
|
||||
? `Laster opp... (${Object.keys(results).length}/${files.length})`
|
||||
: `Last opp ${files.length} bilde(r)`}
|
||||
</button>
|
||||
<button className="btn-danger btn-sm" onClick={handleClear} disabled={uploading}>
|
||||
Nullstill
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{files.length > 0 && (!selectedRace || !selectedStation) && (
|
||||
<p style={{ color: '#e67e22', fontSize: '0.85rem', marginTop: 4 }}>
|
||||
Velg løp og stasjon for å laste opp.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{summary && (
|
||||
<div className={`alert alert-${summary.ok === summary.total ? 'success' : 'info'}`}>
|
||||
{summary.ok} av {summary.total} bilder lastet opp. Behandles nå i bakgrunnen.
|
||||
{summary.ok} av {summary.total} bilder lastet opp og behandles nå.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -148,28 +186,13 @@ export default function UploadPage() {
|
||||
<div className="card">
|
||||
<h2>Filer ({files.length})</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Filnavn</th>
|
||||
<th>Størrelse</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{files.map(f => (
|
||||
<FileRow key={f.name} file={f} result={results[f.name]} />
|
||||
))}
|
||||
</tbody>
|
||||
<thead><tr><th>Filnavn</th><th>Størrelse</th><th>Status</th></tr></thead>
|
||||
<tbody>{files.map(f => <FileRow key={f.name} file={f} result={results[f.name]} />)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.drag-over {
|
||||
border-color: #1a1a2e !important;
|
||||
background: #f0f0f8;
|
||||
}
|
||||
`}</style>
|
||||
<style>{`.drag-over { border-color: #1a1a2e !important; background: #f0f0f8; }`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user