- 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 \
|
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
@@ -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:
|
||||||
|
|||||||
@@ -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("*")):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
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
@@ -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
@@ -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
@@ -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,
|
||||||
|
})
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 })
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 nå i bakgrunnen.
|
{summary.ok} av {summary.total} bilder lastet opp og behandles nå.
|
||||||
</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>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user