Add race and station management
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>
This commit is contained in:
2026-03-21 09:44:45 +01:00
parent 3dcf979e6f
commit 5393e85a74
15 changed files with 841 additions and 139 deletions
+2 -1
View File
@@ -6,7 +6,8 @@ WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
libglib2.0-0 \ libglib2.0-0 \
libgl1 \ libgl1 \
libgomp1 \ tesseract-ocr \
tesseract-ocr-eng \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .
+148 -38
View File
@@ -4,14 +4,15 @@ FastAPI REST API for timing-systemet.
import asyncio import asyncio
import logging import logging
import uuid
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
import aiosqlite import aiosqlite
from fastapi import Depends, FastAPI, File, HTTPException, UploadFile from fastapi import Depends, FastAPI, File, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
@@ -23,7 +24,10 @@ from profile_db import (
get_db, get_db,
import_startlist_csv, import_startlist_csv,
list_athletes, 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 from results import get_results
@@ -32,9 +36,7 @@ logger = logging.getLogger(__name__)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Behandle bilder som lå i depot fra før oppstart
await process_existing() await process_existing()
# Start fil-overvåkning i bakgrunnen
asyncio.create_task(watch_depot()) asyncio.create_task(watch_depot())
yield yield
@@ -49,8 +51,6 @@ app.add_middleware(
) )
# --- DB-avhengighet ---
async def get_connection(): async def get_connection():
db = await get_db() db = await get_db()
try: 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") @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") @app.post("/api/athletes/import", summary="Last opp startliste som CSV")
async def import_csv( async def import_csv(file: UploadFile = File(...), db=Depends(get_connection)):
file: UploadFile = File(...),
db=Depends(get_connection),
):
if not file.filename.endswith(".csv"): if not file.filename.endswith(".csv"):
raise HTTPException(400, "Kun CSV-filer støttes") raise HTTPException(400, "Kun CSV-filer støttes")
content = (await file.read()).decode("utf-8-sig") # utf-8-sig håndterer BOM content = (await file.read()).decode("utf-8-sig")
result = await import_startlist_csv(db, content) return await import_startlist_csv(db, content)
return result
@app.delete("/api/athletes/all") @app.delete("/api/athletes/all")
@@ -100,17 +190,19 @@ async def remove_athlete(profile_id: str, db=Depends(get_connection)):
@app.get("/api/passages") @app.get("/api/passages")
async def list_passages( async def list_passages(
race_id: Optional[str] = None,
station: Optional[str] = None, station: Optional[str] = None,
needs_review: Optional[bool] = None, needs_review: Optional[bool] = None,
profile_id: Optional[str] = None, profile_id: Optional[str] = None,
db=Depends(get_connection), 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") @app.get("/api/passages/review")
async def review_queue(db=Depends(get_connection)): async def review_queue(race_id: Optional[str] = None, db=Depends(get_connection)):
return await get_passages(db, needs_review=True) return await get_passages(db, race_id=race_id, needs_review=True)
class ResolveRequest(BaseModel): class ResolveRequest(BaseModel):
@@ -121,13 +213,10 @@ class ResolveRequest(BaseModel):
@app.post("/api/passages/{passage_id}/resolve") @app.post("/api/passages/{passage_id}/resolve")
async def resolve(passage_id: str, body: ResolveRequest, db=Depends(get_connection)): async def resolve(passage_id: str, body: ResolveRequest, db=Depends(get_connection)):
ok = await resolve_passage( ok = await resolve_passage(db, passage_id,
db, profile_id=body.profile_id,
passage_id, bib_number=body.bib_number,
profile_id=body.profile_id, review_note=body.review_note)
bib_number=body.bib_number,
review_note=body.review_note,
)
if not ok: if not ok:
raise HTTPException(404, "Passering ikke funnet") raise HTTPException(404, "Passering ikke funnet")
return {"ok": True} 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") @app.get("/api/passages/{passage_id}/images")
async def passage_images(passage_id: str, db=Depends(get_connection)): 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) 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") @app.get("/api/results")
async def get_results_endpoint(db=Depends(get_connection)): async def get_results_endpoint(race_id: Optional[str] = None, db=Depends(get_connection)):
return await get_results(db) return await get_results(db, race_id=race_id)
# ===================== # =====================
# Bildeopplasting # Bildeopplasting
# ===================== # =====================
VALID_IMAGE_TYPES = {"image/jpeg", "image/png"}
VALID_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png"} VALID_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png"}
@app.post("/api/upload", summary="Last opp ett eller flere bilder til depot") @app.post("/api/upload")
async def upload_images(files: list[UploadFile] = File(...)): 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. Last opp bilder til depot. Med race_id + station_id behandles bildene
Returnerer status per fil. 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 = [] results = []
for file in files: for file in files:
suffix = Path(file.filename).suffix.lower() suffix = Path(file.filename).suffix.lower()
@@ -178,16 +277,29 @@ async def upload_images(files: list[UploadFile] = File(...)):
continue continue
dest = Path("/depot") / file.filename dest = Path("/depot") / file.filename
# Unngå overskrivning ved navnekollisjon
if dest.exists(): 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: try:
content = await file.read() content = await file.read()
dest.write_bytes(content) 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}) results.append({"filename": file.filename, "ok": True, "saved_as": dest.name})
except Exception as e: except Exception as e:
logger.exception("Feil ved opplasting av %s", file.filename)
results.append({"filename": file.filename, "ok": False, "error": str(e)}) results.append({"filename": file.filename, "ok": False, "error": str(e)})
return {"uploaded": sum(1 for r in results if r["ok"]), "results": results} 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}") @app.get("/api/images/{path:path}")
async def serve_image(path: str): async def serve_image(path: str):
"""Lever behandlede bilder til frontend."""
full_path = Path("/processed") / path full_path = Path("/processed") / path
if not full_path.exists() or not full_path.is_file(): if not full_path.exists() or not full_path.is_file():
raise HTTPException(404, "Bilde ikke funnet") raise HTTPException(404, "Bilde ikke funnet")
# Sjekk at stien er innenfor /processed (path traversal)
try: try:
full_path.resolve().relative_to(Path("/processed").resolve()) full_path.resolve().relative_to(Path("/processed").resolve())
except ValueError: except ValueError:
+64
View File
@@ -14,6 +14,7 @@ import logging
import shutil import shutil
import uuid import uuid
from pathlib import Path from pathlib import Path
from typing import Optional
import aiosqlite import aiosqlite
from watchdog.events import FileSystemEventHandler, FileCreatedEvent 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: async def process_existing() -> None:
"""Behandle bilder som allerede ligger i depot/ ved oppstart.""" """Behandle bilder som allerede ligger i depot/ ved oppstart."""
for path in sorted(DEPOT_DIR.glob("*")): for path in sorted(DEPOT_DIR.glob("*")):
+8 -3
View File
@@ -50,6 +50,7 @@ async def _find_active_passage(
async def log_passage( async def log_passage(
db: aiosqlite.Connection, db: aiosqlite.Connection,
*, *,
race_id: Optional[str] = None,
profile_id: Optional[str], profile_id: Optional[str],
bib_number: Optional[str], bib_number: Optional[str],
station: str, station: str,
@@ -112,14 +113,14 @@ async def log_passage(
await db.execute( await db.execute(
""" """
INSERT INTO passages ( 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, timestamp_utc, gps_lat, gps_lon, gps_alt,
confidence, id_method, source_image, confidence, id_method, source_image,
needs_review, review_note 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, timestamp_utc.isoformat(), gps_lat, gps_lon, gps_alt,
confidence, id_method, source_image, confidence, id_method, source_image,
int(needs_review), review_note, 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( async def get_passages(
db: aiosqlite.Connection, db: aiosqlite.Connection,
race_id: Optional[str] = None,
profile_id: Optional[str] = None, profile_id: Optional[str] = None,
station: Optional[str] = None, station: Optional[str] = None,
needs_review: Optional[bool] = None, needs_review: Optional[bool] = None,
) -> list[dict]: ) -> list[dict]:
clauses = [] clauses = []
params = [] params = []
if race_id is not None:
clauses.append("p.race_id = ?")
params.append(race_id)
if profile_id is not None: if profile_id is not None:
clauses.append("p.profile_id = ?") clauses.append("p.profile_id = ?")
params.append(profile_id) params.append(profile_id)
+5
View File
@@ -25,6 +25,7 @@ async def init_db(db: aiosqlite.Connection) -> None:
CREATE TABLE IF NOT EXISTS passages ( CREATE TABLE IF NOT EXISTS passages (
passage_id TEXT PRIMARY KEY, passage_id TEXT PRIMARY KEY,
race_id TEXT REFERENCES races(race_id),
profile_id TEXT REFERENCES athletes(profile_id), profile_id TEXT REFERENCES athletes(profile_id),
bib_number TEXT, bib_number TEXT,
station TEXT NOT NULL, 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_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_station ON passages(station);
CREATE INDEX IF NOT EXISTS idx_passages_needs_review ON passages(needs_review); 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); CREATE INDEX IF NOT EXISTS idx_passage_images_passage ON passage_images(passage_id);
""") """)
await db.commit() await db.commit()
from race_db import init_race_tables
await init_race_tables(db)
async def get_db() -> aiosqlite.Connection: async def get_db() -> aiosqlite.Connection:
db = await aiosqlite.connect(DB_PATH) db = await aiosqlite.connect(DB_PATH)
+202
View File
@@ -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
+1 -1
View File
@@ -2,7 +2,7 @@ fastapi==0.115.6
uvicorn[standard]==0.34.0 uvicorn[standard]==0.34.0
pillow==11.1.0 pillow==11.1.0
piexif==1.1.3 piexif==1.1.3
easyocr==1.7.2 pytesseract==0.3.13
opencv-python-headless==4.11.0.86 opencv-python-headless==4.11.0.86
python-multipart==0.0.20 python-multipart==0.0.20
watchdog==6.0.0 watchdog==6.0.0
+7 -6
View File
@@ -7,21 +7,22 @@ from typing import Optional
import aiosqlite import aiosqlite
async def get_results(db: aiosqlite.Connection, race_id: Optional[str] = None) -> list[dict]:
async def get_results(db: aiosqlite.Connection) -> list[dict]:
""" """
Hent totalresultat for alle utøvere som har passert start og mål. Hent totalresultat for alle utøvere som har passert start og mål.
Returnerer sortert liste med split-tider. Returnerer sortert liste med split-tider.
""" """
# Hent alle bekreftede passeringer gruppert per utøver race_filter = "AND p.race_id = ?" if race_id else ""
async with db.execute(""" params = [race_id] if race_id else []
async with db.execute(f"""
SELECT p.profile_id, p.bib_number, a.name, a.club, SELECT p.profile_id, p.bib_number, a.name, a.club,
p.station, p.timestamp_utc p.station, p.timestamp_utc
FROM passages p FROM passages p
LEFT JOIN athletes a ON a.profile_id = p.profile_id 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 ORDER BY p.profile_id, p.timestamp_utc
""") as cur: """, params) as cur:
rows = await cur.fetchall() rows = await cur.fetchall()
# Grupper per utøver # Grupper per utøver
+25 -9
View File
@@ -1,36 +1,52 @@
import { useEffect, useState } from 'react'
import { NavLink, Route, Routes } from 'react-router-dom' import { NavLink, Route, Routes } from 'react-router-dom'
import { api } from './api.js'
import StartlistPage from './pages/StartlistPage.jsx' import StartlistPage from './pages/StartlistPage.jsx'
import ReviewPage from './pages/ReviewPage.jsx' import ReviewPage from './pages/ReviewPage.jsx'
import ResultsPage from './pages/ResultsPage.jsx' import ResultsPage from './pages/ResultsPage.jsx'
import PassagesPage from './pages/PassagesPage.jsx' import PassagesPage from './pages/PassagesPage.jsx'
import UploadPage from './pages/UploadPage.jsx' import UploadPage from './pages/UploadPage.jsx'
import RacePage from './pages/RacePage.jsx'
import './App.css' import './App.css'
function Nav() { function Nav({ activeRace }) {
const linkClass = ({ isActive }) => (isActive ? 'nav-link active' : 'nav-link') const linkClass = ({ isActive }) => (isActive ? 'nav-link active' : 'nav-link')
return ( return (
<nav className="navbar"> <nav className="navbar">
<span className="navbar-brand">Timing Admin</span> <span className="navbar-brand">Timing</span>
<NavLink to="/" className={linkClass} end>Startliste</NavLink> <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="/passages" className={linkClass}>Passeringer</NavLink>
<NavLink to="/review" className={linkClass}>Gjennomgang</NavLink> <NavLink to="/review" className={linkClass}>Gjennomgang</NavLink>
<NavLink to="/results" className={linkClass}>Resultater</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> </nav>
) )
} }
export default function App() { export default function App() {
const [activeRace, setActiveRace] = useState(null)
useEffect(() => {
api.getActiveRace().then(r => setActiveRace(r?.race_id ? r : null))
}, [])
return ( return (
<> <>
<Nav /> <Nav activeRace={activeRace} />
<main className="container"> <main className="container">
<Routes> <Routes>
<Route path="/" element={<StartlistPage />} /> <Route path="/" element={<RacePage onRaceChange={() => api.getActiveRace().then(r => setActiveRace(r?.race_id ? r : null))} />} />
<Route path="/passages" element={<PassagesPage />} /> <Route path="/startlist" element={<StartlistPage />} />
<Route path="/review" element={<ReviewPage />} />
<Route path="/results" element={<ResultsPage />} />
<Route path="/upload" element={<UploadPage />} /> <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> </Routes>
</main> </main>
</> </>
+38 -14
View File
@@ -9,7 +9,28 @@ async function request(path, options = {}) {
return res.json() 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 = { 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 // Utøvere
getAthletes: () => request('/athletes'), getAthletes: () => request('/athletes'),
importCsv: (file) => { importCsv: (file) => {
@@ -21,22 +42,25 @@ export const api = {
clearAthletes: () => request('/athletes/all', { method: 'DELETE' }), clearAthletes: () => request('/athletes/all', { method: 'DELETE' }),
// Passeringer // Passeringer
getPassages: (params = {}) => { getPassages: (params = {}) => request(`/passages${qs(params)}`),
const qs = new URLSearchParams( getReviewQueue: (raceId) => request(`/passages/review${qs({ race_id: raceId })}`),
Object.fromEntries(Object.entries(params).filter(([, v]) => v != null))
).toString()
return request(`/passages${qs ? '?' + qs : ''}`)
},
getReviewQueue: () => request('/passages/review'),
getPassageImages: (id) => request(`/passages/${id}/images`), getPassageImages: (id) => request(`/passages/${id}/images`),
resolvePassage: (id, body) => resolvePassage: (id, body) => request(`/passages/${id}/resolve`, {
request(`/passages/${id}/resolve`, { method: 'POST',
method: 'POST', headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
body: JSON.stringify(body), }),
}),
deletePassage: (id) => request(`/passages/${id}`, { method: 'DELETE' }), deletePassage: (id) => request(`/passages/${id}`, { method: 'DELETE' }),
// Resultater // 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,
})
},
} }
+3 -2
View File
@@ -6,19 +6,20 @@ function fmtTs(ts) {
return new Date(ts).toLocaleString('no-NO', { timeZone: 'UTC' }) return new Date(ts).toLocaleString('no-NO', { timeZone: 'UTC' })
} }
export default function PassagesPage() { export default function PassagesPage({ activeRace }) {
const [passages, setPassages] = useState([]) const [passages, setPassages] = useState([])
const [filter, setFilter] = useState({ station: '', needs_review: '' }) const [filter, setFilter] = useState({ station: '', needs_review: '' })
const load = async () => { const load = async () => {
const params = {} const params = {}
if (activeRace?.race_id) params.race_id = activeRace.race_id
if (filter.station) params.station = filter.station if (filter.station) params.station = filter.station
if (filter.needs_review !== '') params.needs_review = filter.needs_review === 'true' if (filter.needs_review !== '') params.needs_review = filter.needs_review === 'true'
const data = await api.getPassages(params) const data = await api.getPassages(params)
setPassages(data) setPassages(data)
} }
useEffect(() => { load() }, [filter]) useEffect(() => { load() }, [filter, activeRace])
const handleDelete = async (id) => { const handleDelete = async (id) => {
if (!confirm('Slett passering?')) return if (!confirm('Slett passering?')) return
+247
View File
@@ -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}
/>
))
)}
</>
)
}
+3 -3
View File
@@ -6,17 +6,17 @@ function fmtTs(ts) {
return new Date(ts).toLocaleTimeString('no-NO', { timeZone: 'UTC', hour12: false }) 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 [results, setResults] = useState([])
const [expanded, setExpanded] = useState(null) const [expanded, setExpanded] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
api.getResults().then(data => { api.getResults(activeRace?.race_id).then(data => {
setResults(data) setResults(data)
setLoading(false) setLoading(false)
}) })
}, []) }, [activeRace])
const toggleExpand = (id) => setExpanded(e => e === id ? null : id) const toggleExpand = (id) => setExpanded(e => e === id ? null : id)
+5 -2
View File
@@ -149,14 +149,17 @@ function ReviewCard({ passage, athletes, onResolved, onDeleted }) {
) )
} }
export default function ReviewPage() { export default function ReviewPage({ activeRace }) {
const [queue, setQueue] = useState([]) const [queue, setQueue] = useState([])
const [athletes, setAthletes] = useState([]) const [athletes, setAthletes] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const load = async () => { const load = async () => {
setLoading(true) 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) setQueue(q)
setAthletes(a) setAthletes(a)
setLoading(false) setLoading(false)
+83 -60
View File
@@ -1,14 +1,12 @@
import { useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { api } from '../api.js'
const ACCEPTED = '.jpg,.jpeg,.png'
function FileRow({ file, result }) { function FileRow({ file, result }) {
const status = result const status = result
? result.ok ? 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-danger">Feil: {result.error}</span>
: <span className="badge badge-warning">Venter...</span> : <span className="badge badge-warning">Venter...</span>
return ( return (
<tr> <tr>
<td>{file.name}</td> <td>{file.name}</td>
@@ -19,6 +17,10 @@ function FileRow({ file, result }) {
} }
export default function UploadPage() { 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 [files, setFiles] = useState([])
const [results, setResults] = useState({}) const [results, setResults] = useState({})
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
@@ -26,6 +28,24 @@ export default function UploadPage() {
const inputRef = useRef() const inputRef = useRef()
const dropRef = 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 addFiles = (newFiles) => {
const valid = Array.from(newFiles).filter(f => const valid = Array.from(newFiles).filter(f =>
f.type === 'image/jpeg' || f.type === 'image/png' f.type === 'image/jpeg' || f.type === 'image/png'
@@ -48,21 +68,18 @@ export default function UploadPage() {
setResults({}) setResults({})
setSummary(null) setSummary(null)
// Send i bolker på 10 for å unngå for store requests
const BATCH = 10 const BATCH = 10
const allResults = {} const allResults = {}
for (let i = 0; i < files.length; i += BATCH) { for (let i = 0; i < files.length; i += BATCH) {
const batch = files.slice(i, i + BATCH) const batch = files.slice(i, i + BATCH)
const form = new FormData()
batch.forEach(f => form.append('files', f))
try { try {
const res = await fetch('/api/upload', { method: 'POST', body: form }) const data = await api.uploadImages(
const data = await res.json() batch,
data.results.forEach((r, j) => { selectedRace || null,
allResults[batch[j].name] = r selectedStation || null,
}) )
data.results.forEach((r, j) => { allResults[batch[j].name] = r })
setResults({ ...allResults }) setResults({ ...allResults })
} catch (err) { } catch (err) {
batch.forEach(f => { allResults[f.name] = { ok: false, error: err.message } }) batch.forEach(f => { allResults[f.name] = { ok: false, error: err.message } })
@@ -82,17 +99,45 @@ export default function UploadPage() {
inputRef.current.value = '' inputRef.current.value = ''
} }
const selectedStationObj = stations.find(s => s.station_id === selectedStation)
return ( return (
<> <>
<h1>Last opp bilder</h1> <h1>Last opp bilder</h1>
<div className="card"> <div className="card">
<h2>Velg bilder</h2> <h2>Løp og stasjon</h2>
<p style={{ fontSize: '0.85rem', color: '#666', marginBottom: '0.75rem' }}> <div className="form-row">
JPEG og PNG støttes. Bildene legges i depot og behandles automatisk (EXIF valideres, OCR kjøres). <div className="form-group" style={{ flex: 1 }}>
</p> <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 <div
ref={dropRef} ref={dropRef}
onDrop={handleDrop} onDrop={handleDrop}
@@ -100,47 +145,40 @@ export default function UploadPage() {
onDragLeave={() => dropRef.current.classList.remove('drag-over')} onDragLeave={() => dropRef.current.classList.remove('drag-over')}
onClick={() => inputRef.current.click()} onClick={() => inputRef.current.click()}
style={{ style={{
border: '2px dashed #ddd', border: '2px dashed #ddd', borderRadius: 8, padding: '2rem',
borderRadius: 8, textAlign: 'center', cursor: 'pointer', color: '#aaa',
padding: '2rem', marginBottom: '0.75rem', transition: 'border-color 0.15s, background 0.15s',
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 Dra og slipp bilder hit, eller klikk for å velge
</div> </div>
<input <input ref={inputRef} type="file" accept=".jpg,.jpeg,.png" multiple
ref={inputRef} style={{ display: 'none' }} onChange={e => addFiles(e.target.files)} />
type="file"
accept={ACCEPTED}
multiple
style={{ display: 'none' }}
onChange={e => addFiles(e.target.files)}
/>
{files.length > 0 && ( {files.length > 0 && (
<div className="form-row" style={{ marginTop: '0.5rem' }}> <div className="form-row" style={{ marginTop: '0.5rem' }}>
<button <button className="btn-primary" onClick={handleUpload}
className="btn-primary" disabled={uploading || !selectedRace || !selectedStation}>
onClick={handleUpload} {uploading
disabled={uploading} ? `Laster opp... (${Object.keys(results).length}/${files.length})`
> : `Last opp ${files.length} bilde(r)`}
{uploading ? `Laster opp... (${Object.keys(results).length}/${files.length})` : `Last opp ${files.length} bilde(r)`}
</button> </button>
<button className="btn-danger btn-sm" onClick={handleClear} disabled={uploading}> <button className="btn-danger btn-sm" onClick={handleClear} disabled={uploading}>
Nullstill Nullstill
</button> </button>
</div> </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> </div>
{summary && ( {summary && (
<div className={`alert alert-${summary.ok === summary.total ? 'success' : 'info'}`}> <div className={`alert alert-${summary.ok === summary.total ? 'success' : 'info'}`}>
{summary.ok} av {summary.total} bilder lastet opp. Behandles i bakgrunnen. {summary.ok} av {summary.total} bilder lastet opp og behandles .
</div> </div>
)} )}
@@ -148,28 +186,13 @@ export default function UploadPage() {
<div className="card"> <div className="card">
<h2>Filer ({files.length})</h2> <h2>Filer ({files.length})</h2>
<table> <table>
<thead> <thead><tr><th>Filnavn</th><th>Størrelse</th><th>Status</th></tr></thead>
<tr> <tbody>{files.map(f => <FileRow key={f.name} file={f} result={results[f.name]} />)}</tbody>
<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> </table>
</div> </div>
)} )}
<style>{` <style>{`.drag-over { border-color: #1a1a2e !important; background: #f0f0f8; }`}</style>
.drag-over {
border-color: #1a1a2e !important;
background: #f0f0f8;
}
`}</style>
</> </>
) )
} }