sync strava data from web ui

This commit is contained in:
Davide Scaini
2026-04-06 12:38:41 +02:00
parent ad2710e759
commit 17f36889f3
5 changed files with 605 additions and 34 deletions
+37
View File
@@ -1,5 +1,42 @@
# Changelog # Changelog
## [Unreleased] — 2026-04-06
### New feature — Strava sync from UI
- **`bincio/extract/strava_api.py`** (new) — Strava OAuth + activity API integration:
OAuth URL generation, authorization code exchange, token refresh, paged activity list
fetching, stream fetching (time, latlng, altitude, HR, cadence, power, velocity), and
conversion of the API response directly to `ParsedActivity` (no file download needed).
Token stored in `<data-dir>/strava_token.json`; `last_sync_at` tracks incremental syncs.
- **`bincio/edit/server.py`** — three new endpoints:
- `GET /api/strava/status` — returns `{configured, connected, last_sync}` for the UI
- `GET /api/strava/auth-url` — returns the OAuth URL for the popup window
- `GET /api/strava/callback` — exchanges auth code, saves token, redirects to site with `?strava=connected`
- `POST /api/strava/sync` — fetches activities since `last_sync_at`, runs extract pipeline,
updates `index.json`, runs `merge_all()`, and updates `last_sync_at` in the token file
- **`bincio/edit/cli.py`** — `--strava-client-id` and `--strava-client-secret` flags added
(also read from `STRAVA_CLIENT_ID` / `STRAVA_CLIENT_SECRET` env vars). Strava sync is
disabled (endpoints return 400) when credentials are not provided.
- **`site/src/layouts/Base.astro`** — upload modal redesigned with a "choose source" screen:
two buttons — "Upload file" (existing drag-and-drop) and "Sync from Strava". Strava button
shows "Not configured" when the server lacks credentials, or opens an OAuth popup window.
After connecting, a "Sync now" button triggers the sync and reloads the feed on import.
**Setup:** register `http://localhost:4041/api/strava/callback` as an allowed redirect URI
in your Strava app settings, then run:
```
bincio edit --strava-client-id YOUR_ID --strava-client-secret YOUR_SECRET
# or via env vars: STRAVA_CLIENT_ID=... STRAVA_CLIENT_SECRET=... bincio edit
```
**Note on the upload button:** the button is visible whenever `PUBLIC_EDIT_URL` is set in
`site/.env`, regardless of whether the edit server is running. This is intentional — the env
var is the "edit mode enabled" flag. Remove it from `.env` to hide the button.
## [Unreleased] — 2026-04-01 ## [Unreleased] — 2026-04-01
### Security fixes (second-pass audit) ### Security fixes (second-pass audit)
+30 -3
View File
@@ -20,11 +20,17 @@ console = Console()
help="URL of the Astro dev server (for the Back link).") help="URL of the Astro dev server (for the Back link).")
@click.option("--config", "config_path", default=None, @click.option("--config", "config_path", default=None,
help="Path to extract_config.yaml (reads output.dir from it).") help="Path to extract_config.yaml (reads output.dir from it).")
@click.option("--strava-client-id", default=None, envvar="STRAVA_CLIENT_ID",
help="Strava API client ID (enables Strava sync in the UI). Also reads STRAVA_CLIENT_ID env var.")
@click.option("--strava-client-secret", default=None, envvar="STRAVA_CLIENT_SECRET",
help="Strava API client secret. Also reads STRAVA_CLIENT_SECRET env var.")
def edit( def edit(
data_dir: Optional[str], data_dir: Optional[str],
port: int, port: int,
site_url: str, site_url: str,
config_path: Optional[str], config_path: Optional[str],
strava_client_id: Optional[str],
strava_client_secret: Optional[str],
) -> None: ) -> None:
"""Start a local web UI for editing activity sidecar files. """Start a local web UI for editing activity sidecar files.
@@ -46,6 +52,13 @@ def edit(
) )
data = _resolve_data_dir(data_dir, config_path) data = _resolve_data_dir(data_dir, config_path)
# Fall back to extract_config.yaml for Strava credentials
if not strava_client_id or not strava_client_secret:
cfg_strava = _load_config(config_path).get("import", {}).get("strava", {})
strava_client_id = strava_client_id or str(cfg_strava.get("client_id") or "")
strava_client_secret = strava_client_secret or str(cfg_strava.get("client_secret") or "")
console.print(f"Data dir: [cyan]{data}[/cyan]") console.print(f"Data dir: [cyan]{data}[/cyan]")
console.print(f"Edit UI: [cyan]http://localhost:{port}/edit/<activity-id>[/cyan]") console.print(f"Edit UI: [cyan]http://localhost:{port}/edit/<activity-id>[/cyan]")
console.print(f"Site URL: [cyan]{site_url}[/cyan]") console.print(f"Site URL: [cyan]{site_url}[/cyan]")
@@ -54,17 +67,31 @@ def edit(
import bincio.edit.server as srv import bincio.edit.server as srv
srv.data_dir = data srv.data_dir = data
srv.site_url = site_url srv.site_url = site_url
srv.strava_client_id = strava_client_id or ""
srv.strava_client_secret = strava_client_secret or ""
if strava_client_id:
console.print(f"Strava sync: [green]enabled[/green] (client {strava_client_id})")
else:
console.print("Strava sync: [yellow]disabled[/yellow] (pass --strava-client-id to enable)")
uvicorn.run(srv.app, host="127.0.0.1", port=port, log_level="warning") uvicorn.run(srv.app, host="127.0.0.1", port=port, log_level="warning")
def _load_config(config_path: Optional[str]) -> dict:
"""Load extract_config.yaml — explicit path first, then cwd auto-discovery."""
import yaml
for cfg in filter(None, [config_path and Path(config_path), Path("extract_config.yaml")]):
if Path(cfg).exists():
return yaml.safe_load(Path(cfg).read_text()) or {}
return {}
def _resolve_data_dir(explicit: Optional[str], config_path: Optional[str]) -> Path: def _resolve_data_dir(explicit: Optional[str], config_path: Optional[str]) -> Path:
if explicit: if explicit:
return Path(explicit).expanduser().resolve() return Path(explicit).expanduser().resolve()
if config_path and Path(config_path).exists(): raw = _load_config(config_path)
import yaml
raw = yaml.safe_load(Path(config_path).read_text()) or {}
out = raw.get("output", {}).get("dir") out = raw.get("output", {}).get("dir")
if out: if out:
return Path(out).expanduser().resolve() return Path(out).expanduser().resolve()
+116 -1
View File
@@ -5,16 +5,19 @@ from __future__ import annotations
import json import json
import re import re
import shutil import shutil
import time
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from fastapi import FastAPI, File, HTTPException, UploadFile from fastapi import FastAPI, File, HTTPException, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
# Populated by the CLI before uvicorn starts # Populated by the CLI before uvicorn starts
data_dir: Path | None = None data_dir: Path | None = None
site_url: str = "http://localhost:4321" site_url: str = "http://localhost:4321"
strava_client_id: str = ""
strava_client_secret: str = ""
app = FastAPI(title="BincioActivity Edit Server", docs_url=None, redoc_url=None) app = FastAPI(title="BincioActivity Edit Server", docs_url=None, redoc_url=None)
@@ -618,3 +621,115 @@ async def delete_image(activity_id: str, filename: str) -> JSONResponse:
if not any(target.parent.iterdir()): if not any(target.parent.iterdir()):
shutil.rmtree(target.parent) shutil.rmtree(target.parent)
return JSONResponse({"ok": True}) return JSONResponse({"ok": True})
# ── Strava sync ───────────────────────────────────────────────────────────────
@app.get("/api/strava/status")
async def strava_status() -> JSONResponse:
"""Return whether Strava is configured and whether a token is stored."""
dd = _get_data_dir()
from bincio.extract.strava_api import load_token
token = load_token(dd)
return JSONResponse({
"configured": bool(strava_client_id),
"connected": token is not None,
"last_sync": token.get("last_sync_at") if token else None,
})
@app.get("/api/strava/auth-url")
async def strava_auth_url(request: Request) -> JSONResponse:
"""Return the Strava OAuth URL the browser should open."""
if not strava_client_id:
raise HTTPException(400, "Strava client ID not configured. Pass --strava-client-id to bincio edit.")
redirect_uri = str(request.url_for("strava_callback"))
from bincio.extract.strava_api import auth_url
return JSONResponse({"url": auth_url(strava_client_id, redirect_uri)})
@app.get("/api/strava/callback", name="strava_callback")
async def strava_callback(code: str = "", error: str = "") -> RedirectResponse:
"""Strava OAuth callback — exchange code for token then redirect to the site."""
if error or not code:
return RedirectResponse(f"{site_url}?strava=error")
if not strava_client_id or not strava_client_secret:
return RedirectResponse(f"{site_url}?strava=error")
dd = _get_data_dir()
from bincio.extract.strava_api import StravaError, exchange_code, save_token
try:
token = exchange_code(strava_client_id, strava_client_secret, code)
except StravaError:
return RedirectResponse(f"{site_url}?strava=error")
# Stamp last_sync_at at connect time so the first sync only fetches new activities
token.setdefault("last_sync_at", int(time.time()))
save_token(dd, token)
return RedirectResponse(f"{site_url}?strava=connected")
@app.post("/api/strava/sync")
async def strava_sync() -> JSONResponse:
"""Fetch new Strava activities since last sync and add them to the data store."""
if not strava_client_id or not strava_client_secret:
raise HTTPException(400, "Strava not configured. Pass --strava-client-id and --strava-client-secret to bincio edit.")
dd = _get_data_dir()
from bincio.extract.strava_api import (
StravaError, ensure_fresh, fetch_activities, fetch_streams,
save_token, strava_to_parsed,
)
try:
token = ensure_fresh(dd, strava_client_id, strava_client_secret)
except StravaError as e:
raise HTTPException(502, str(e))
after: int | None = token.get("last_sync_at")
try:
activities = fetch_activities(token["access_token"], after=after)
except StravaError as e:
raise HTTPException(502, str(e))
from bincio.extract.metrics import compute
from bincio.extract.writer import build_summary, make_activity_id, write_activity, write_index
from bincio.extract.strava_api import strava_meta_to_partial
from bincio.render.merge import merge_all
# Load existing index once
index_path = dd / "index.json"
if index_path.exists():
index_data = json.loads(index_path.read_text(encoding="utf-8"))
else:
index_data = {"owner": {"handle": "unknown"}, "activities": []}
owner = index_data.get("owner", {})
summaries: dict[str, dict] = {s["id"]: s for s in index_data.get("activities", [])}
imported = 0
skipped = 0
errors: list[str] = []
for meta in activities:
try:
# Compute ID from meta alone (no API call) to skip already-known activities
activity_id = make_activity_id(strava_meta_to_partial(meta))
if (dd / "activities" / f"{activity_id}.json").exists():
skipped += 1
continue
# Only fetch streams for genuinely new activities
streams = fetch_streams(token["access_token"], meta["id"])
parsed = strava_to_parsed(meta, streams)
metrics = compute(parsed)
write_activity(parsed, metrics, dd, privacy="public", rdp_epsilon=0.0001)
summaries[activity_id] = build_summary(parsed, metrics, activity_id, "public")
imported += 1
except Exception as exc:
errors.append(f"{meta.get('id')}: {type(exc).__name__}")
if imported:
write_index(list(summaries.values()), dd, owner)
merge_all(dd)
token["last_sync_at"] = int(time.time())
save_token(dd, token)
return JSONResponse({"ok": True, "imported": imported, "skipped": skipped, "errors": errors[:5]})
+211
View File
@@ -0,0 +1,211 @@
"""Strava OAuth + activity API sync.
Token is stored in <data_dir>/strava_token.json:
{access_token, refresh_token, expires_at, last_sync_at?}
Usage:
1. Build an auth URL and redirect the user to it.
2. Exchange the returned code for a token (exchange_code).
3. On subsequent syncs, call ensure_fresh() then fetch_activities() + fetch_streams().
4. Convert each result to ParsedActivity with strava_to_parsed().
"""
from __future__ import annotations
import hashlib
import json
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from bincio.extract.models import DataPoint, LapData, ParsedActivity
from bincio.extract.sport import normalise_sport
_TOKEN_FILE = "strava_token.json"
_AUTH_URL = "https://www.strava.com/oauth/authorize"
_TOKEN_URL = "https://www.strava.com/oauth/token"
_API_BASE = "https://www.strava.com/api/v3"
class StravaError(Exception):
pass
# ── OAuth helpers ──────────────────────────────────────────────────────────────
def auth_url(client_id: str, redirect_uri: str) -> str:
"""Return the Strava OAuth authorization URL."""
params = urllib.parse.urlencode({
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": "activity:read_all",
"approval_prompt": "auto",
})
return f"{_AUTH_URL}?{params}"
def exchange_code(client_id: str, client_secret: str, code: str) -> dict:
"""Exchange an authorization code for access + refresh tokens."""
data = urllib.parse.urlencode({
"client_id": client_id,
"client_secret": client_secret,
"code": code,
"grant_type": "authorization_code",
}).encode()
req = urllib.request.Request(_TOKEN_URL, data=data, method="POST")
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
raise StravaError(f"Token exchange failed: {e.code} {e.read().decode()[:200]}")
def _refresh(client_id: str, client_secret: str, refresh_token: str) -> dict:
data = urllib.parse.urlencode({
"client_id": client_id,
"client_secret": client_secret,
"refresh_token": refresh_token,
"grant_type": "refresh_token",
}).encode()
req = urllib.request.Request(_TOKEN_URL, data=data, method="POST")
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
raise StravaError(f"Token refresh failed: {e.code} {e.read().decode()[:200]}")
# ── Token storage ──────────────────────────────────────────────────────────────
def load_token(data_dir: Path) -> Optional[dict]:
p = data_dir / _TOKEN_FILE
if not p.exists():
return None
try:
return json.loads(p.read_text())
except Exception:
return None
def save_token(data_dir: Path, token: dict) -> None:
(data_dir / _TOKEN_FILE).write_text(json.dumps(token, indent=2))
def ensure_fresh(data_dir: Path, client_id: str, client_secret: str) -> dict:
"""Load the stored token, refresh if expiring soon, persist and return it."""
token = load_token(data_dir)
if token is None:
raise StravaError("Not connected to Strava")
if time.time() > token.get("expires_at", 0) - 60:
refreshed = _refresh(client_id, client_secret, token["refresh_token"])
token.update(refreshed)
save_token(data_dir, token)
return token
# ── API calls ──────────────────────────────────────────────────────────────────
def _api_get(url: str, access_token: str) -> dict | list:
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {access_token}"})
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
raise StravaError(f"Strava API {e.code}: {e.read().decode()[:200]}")
def fetch_activities(access_token: str, after: Optional[int] = None) -> list[dict]:
"""Fetch all activity summaries, paged, optionally after a Unix timestamp."""
results: list[dict] = []
page = 1
while True:
params: dict = {"per_page": 200, "page": page}
if after:
params["after"] = after
qs = urllib.parse.urlencode(params)
batch = _api_get(f"{_API_BASE}/athlete/activities?{qs}", access_token)
if not isinstance(batch, list) or not batch:
break
results.extend(batch)
if len(batch) < 200:
break
page += 1
return results
def fetch_streams(access_token: str, activity_id: int) -> dict:
"""Fetch time-series streams for a single activity."""
keys = "time,latlng,altitude,heartrate,cadence,watts,velocity_smooth"
result = _api_get(
f"{_API_BASE}/activities/{activity_id}/streams?keys={keys}&key_by_type=true",
access_token,
)
return result if isinstance(result, dict) else {}
# ── Model conversion ───────────────────────────────────────────────────────────
def strava_meta_to_partial(meta: dict) -> ParsedActivity:
"""Build a minimal ParsedActivity from activity meta (no streams) — enough to compute the ID."""
started_at = datetime.fromisoformat(meta["start_date"].replace("Z", "+00:00"))
return ParsedActivity(
points=[],
sport=normalise_sport(meta.get("sport_type") or meta.get("type") or ""),
started_at=started_at,
source_file=f"strava:{meta['id']}",
source_hash="",
title=meta.get("name") or None,
)
def strava_to_parsed(meta: dict, streams: dict) -> ParsedActivity:
"""Convert a Strava activity summary + streams dict to ParsedActivity."""
started_at = datetime.fromisoformat(meta["start_date"].replace("Z", "+00:00"))
start_ts = started_at.timestamp()
time_data = streams.get("time", {}).get("data", [])
latlng_data = streams.get("latlng", {}).get("data", [])
alt_data = streams.get("altitude", {}).get("data", [])
hr_data = streams.get("heartrate", {}).get("data", [])
cad_data = streams.get("cadence", {}).get("data", [])
pwr_data = streams.get("watts", {}).get("data", [])
vel_data = streams.get("velocity_smooth", {}).get("data", [])
def _get(lst: list, i: int):
return lst[i] if i < len(lst) else None
points: list[DataPoint] = []
for i, t_offset in enumerate(time_data):
ll = _get(latlng_data, i)
lat, lon = (ll[0], ll[1]) if ll else (None, None)
vel = _get(vel_data, i)
points.append(DataPoint(
timestamp=datetime.fromtimestamp(start_ts + t_offset, tz=timezone.utc),
lat=lat,
lon=lon,
elevation_m=_get(alt_data, i),
hr_bpm=_get(hr_data, i),
cadence_rpm=_get(cad_data, i),
power_w=_get(pwr_data, i),
speed_kmh=(vel * 3.6) if vel is not None else None,
))
# Deterministic source hash based on the Strava activity ID
source = f"strava:{meta['id']}"
source_hash = "sha256:" + hashlib.sha256(source.encode()).hexdigest()
return ParsedActivity(
points=points,
sport=normalise_sport(meta.get("sport_type") or meta.get("type") or ""),
started_at=started_at,
source_file=source,
source_hash=source_hash,
title=meta.get("name") or None,
description=meta.get("description") or None,
strava_id=str(meta["id"]),
)
+197 -16
View File
@@ -124,20 +124,50 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
</nav> </nav>
{editUrl && ( {editUrl && (
<!-- Upload modal --> <!-- Add activity modal -->
<div <div
id="upload-modal" id="upload-modal"
style="display:none" style="display:none"
class="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center" class="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="Upload activity" aria-label="Add activity"
> >
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-6 w-full max-w-sm mx-4 shadow-2xl"> <div class="bg-zinc-900 border border-zinc-800 rounded-xl p-6 w-full max-w-sm mx-4 shadow-2xl">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h2 class="font-semibold text-white text-sm">Upload activity</h2> <h2 id="upload-title" class="font-semibold text-white text-sm">Add activity</h2>
<button id="upload-close" class="text-zinc-500 hover:text-white transition-colors text-xl leading-none" aria-label="Close">×</button> <button id="upload-close" class="text-zinc-500 hover:text-white transition-colors text-xl leading-none" aria-label="Close">×</button>
</div> </div>
<!-- View: choose source -->
<div id="upload-view-choose">
<div class="flex flex-col gap-3">
<button
id="upload-choose-file"
class="flex items-center gap-3 p-4 rounded-lg border border-zinc-700 hover:border-zinc-500 hover:bg-zinc-800 transition-colors text-left"
>
<span class="text-2xl">📁</span>
<div>
<p class="text-sm font-medium text-white">Upload file</p>
<p class="text-xs text-zinc-500">FIT, GPX, or TCX</p>
</div>
</button>
<button
id="upload-choose-strava"
class="flex items-center gap-3 p-4 rounded-lg border border-zinc-700 hover:border-zinc-500 hover:bg-zinc-800 transition-colors text-left"
>
<span class="text-2xl">🟠</span>
<div>
<p class="text-sm font-medium text-white">Sync from Strava</p>
<p id="strava-choose-sub" class="text-xs text-zinc-500">Checking…</p>
</div>
</button>
</div>
</div>
<!-- View: file upload -->
<div id="upload-view-file" style="display:none">
<button id="upload-back-file" class="text-xs text-zinc-500 hover:text-white mb-3 transition-colors">← Back</button>
<div <div
id="upload-drop" id="upload-drop"
class="border-2 border-dashed border-zinc-700 rounded-lg p-8 text-center text-zinc-500 text-sm cursor-pointer hover:border-zinc-500 hover:text-zinc-300 transition-colors" class="border-2 border-dashed border-zinc-700 rounded-lg p-8 text-center text-zinc-500 text-sm cursor-pointer hover:border-zinc-500 hover:text-zinc-300 transition-colors"
@@ -147,6 +177,28 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
</div> </div>
<p id="upload-status" class="mt-3 text-xs text-center" style="color: var(--text-5); min-height: 1.25rem"></p> <p id="upload-status" class="mt-3 text-xs text-center" style="color: var(--text-5); min-height: 1.25rem"></p>
</div> </div>
<!-- View: Strava sync -->
<div id="upload-view-strava" style="display:none">
<button id="upload-back-strava" class="text-xs text-zinc-500 hover:text-white mb-3 transition-colors">← Back</button>
<div id="strava-connect-area" style="display:none">
<p class="text-sm text-zinc-400 mb-4">Connect your Strava account to sync activities automatically.</p>
<button
id="strava-connect-btn"
class="w-full py-2 px-4 rounded-lg font-medium text-sm transition-colors"
style="background:#fc4c02; color:white;"
>Connect Strava</button>
</div>
<div id="strava-sync-area" style="display:none">
<p class="text-xs text-zinc-500 mb-1">Last sync: <span id="strava-last-sync">never</span></p>
<button
id="strava-sync-btn"
class="w-full py-2 px-4 rounded-lg font-medium text-sm bg-zinc-700 hover:bg-zinc-600 text-white transition-colors mt-2"
>Sync now</button>
</div>
<p id="strava-status" class="mt-3 text-xs text-center" style="min-height: 1.25rem"></p>
</div>
</div>
</div> </div>
)} )}
@@ -177,21 +229,55 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
{editUrl && ( {editUrl && (
<script define:vars={{ editUrl, baseUrl }}> <script define:vars={{ editUrl, baseUrl }}>
const modal = document.getElementById('upload-modal'); const modal = document.getElementById('upload-modal');
const openBtn = document.getElementById('upload-btn');
const closeBtn = document.getElementById('upload-close');
const viewChoose = document.getElementById('upload-view-choose');
const viewFile = document.getElementById('upload-view-file');
const viewStrava = document.getElementById('upload-view-strava');
const chooseFile = document.getElementById('upload-choose-file');
const chooseStrava = document.getElementById('upload-choose-strava');
const backFile = document.getElementById('upload-back-file');
const backStrava = document.getElementById('upload-back-strava');
const drop = document.getElementById('upload-drop'); const drop = document.getElementById('upload-drop');
const input = document.getElementById('upload-input'); const input = document.getElementById('upload-input');
const label = document.getElementById('upload-label'); const label = document.getElementById('upload-label');
const status = document.getElementById('upload-status'); const fileStatus = document.getElementById('upload-status');
const openBtn = document.getElementById('upload-btn'); const stravaStatus = document.getElementById('strava-status');
const closeBtn = document.getElementById('upload-close'); const stravaConnect = document.getElementById('strava-connect-area');
const stravaSync = document.getElementById('strava-sync-area');
const stravaConnBtn = document.getElementById('strava-connect-btn');
const stravaSyncBtn = document.getElementById('strava-sync-btn');
const stravaLastSync = document.getElementById('strava-last-sync');
const stravaChooseSub = document.getElementById('strava-choose-sub');
function openModal() { modal.style.display = 'flex'; } // ── view helpers ──────────────────────────────────────────────────────
function closeModal() { modal.style.display = 'none'; label.innerHTML = 'Drop a FIT, GPX, or TCX file<br>or click to browse'; status.textContent = ''; status.style.color = ''; } function showView(name) {
viewChoose.style.display = name === 'choose' ? '' : 'none';
viewFile.style.display = name === 'file' ? '' : 'none';
viewStrava.style.display = name === 'strava' ? '' : 'none';
}
function openModal() {
showView('choose');
fileStatus.textContent = '';
label.innerHTML = 'Drop a FIT, GPX, or TCX file<br>or click to browse';
modal.style.display = 'flex';
}
function closeModal() {
modal.style.display = 'none';
}
openBtn.addEventListener('click', openModal); openBtn.addEventListener('click', openModal);
closeBtn.addEventListener('click', closeModal); closeBtn.addEventListener('click', closeModal);
modal.addEventListener('click', e => { if (e.target === modal) closeModal(); }); modal.addEventListener('click', e => { if (e.target === modal) closeModal(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape' && modal.style.display !== 'none') closeModal(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape' && modal.style.display !== 'none') closeModal(); });
chooseFile.addEventListener('click', () => showView('file'));
backFile.addEventListener('click', () => showView('choose'));
backStrava.addEventListener('click', () => showView('choose'));
// ── file upload ───────────────────────────────────────────────────────
drop.addEventListener('click', () => input.click()); drop.addEventListener('click', () => input.click());
drop.addEventListener('dragover', e => { e.preventDefault(); drop.style.borderColor = 'var(--accent)'; drop.style.color = 'var(--text-primary)'; }); drop.addEventListener('dragover', e => { e.preventDefault(); drop.style.borderColor = 'var(--accent)'; drop.style.color = 'var(--text-primary)'; });
drop.addEventListener('dragleave', () => { drop.style.borderColor = ''; drop.style.color = ''; }); drop.addEventListener('dragleave', () => { drop.style.borderColor = ''; drop.style.color = ''; });
@@ -205,27 +291,122 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
async function doUpload(file) { async function doUpload(file) {
label.textContent = file.name; label.textContent = file.name;
status.textContent = 'Uploading…'; fileStatus.textContent = 'Uploading…';
status.style.color = 'var(--text-4)'; fileStatus.style.color = 'var(--text-4)';
drop.style.pointerEvents = 'none'; drop.style.pointerEvents = 'none';
const fd = new FormData(); const fd = new FormData();
fd.append('file', file); fd.append('file', file);
try { try {
const r = await fetch(`${editUrl}/api/upload`, { method: 'POST', body: fd }); const r = await fetch(`${editUrl}/api/upload`, { method: 'POST', body: fd });
if (!r.ok) throw new Error(await r.text()); if (!r.ok) throw new Error(await r.text());
const d = await r.json(); const d = await r.json();
status.textContent = 'Done! Opening activity…'; fileStatus.textContent = 'Done! Opening activity…';
status.style.color = '#4ade80'; fileStatus.style.color = '#4ade80';
setTimeout(() => { window.location.href = `${baseUrl}activity/${d.id}/`; }, 600); setTimeout(() => { window.location.href = `${baseUrl}activity/${d.id}/`; }, 600);
} catch (e) { } catch (e) {
status.textContent = 'Error: ' + e.message; fileStatus.textContent = 'Error: ' + e.message;
status.style.color = '#f87171'; fileStatus.style.color = '#f87171';
drop.style.pointerEvents = ''; drop.style.pointerEvents = '';
input.value = ''; input.value = '';
} }
} }
// ── Strava ────────────────────────────────────────────────────────────
let stravaConfigured = false;
async function loadStravaStatus() {
try {
const r = await fetch(`${editUrl}/api/strava/status`);
if (!r.ok) return;
const d = await r.json();
stravaConfigured = d.configured;
if (!d.configured) {
stravaChooseSub.textContent = 'Not configured';
chooseStrava.disabled = true;
chooseStrava.classList.add('opacity-40', 'cursor-not-allowed');
return;
}
stravaChooseSub.textContent = d.connected
? (d.last_sync ? 'Connected · tap to sync' : 'Connected · never synced')
: 'Tap to connect';
stravaConnect.style.display = d.connected ? 'none' : '';
stravaSync.style.display = d.connected ? '' : 'none';
if (d.last_sync) {
stravaLastSync.textContent = new Date(d.last_sync * 1000).toLocaleString();
}
} catch (_) {}
}
loadStravaStatus();
chooseStrava.addEventListener('click', () => {
if (!stravaConfigured) return;
showView('strava');
});
stravaConnBtn.addEventListener('click', async () => {
stravaStatus.textContent = 'Opening Strava…';
stravaStatus.style.color = 'var(--text-4)';
try {
const r = await fetch(`${editUrl}/api/strava/auth-url`);
if (!r.ok) throw new Error(await r.text());
const { url } = await r.json();
const popup = window.open(url, 'strava-auth', 'width=600,height=700,left=200,top=100');
stravaStatus.textContent = 'Waiting for Strava authorisation…';
// Listen for the callback redirect closing the popup
const poll = setInterval(() => {
try {
if (popup && popup.location.href.includes('strava=connected')) {
clearInterval(poll);
popup.close();
stravaStatus.textContent = 'Connected!';
stravaStatus.style.color = '#4ade80';
stravaConnect.style.display = 'none';
stravaSync.style.display = '';
stravaLastSync.textContent = 'never';
} else if (popup && popup.location.href.includes('strava=error')) {
clearInterval(poll);
popup.close();
stravaStatus.textContent = 'Authorisation failed.';
stravaStatus.style.color = '#f87171';
}
} catch (_) {}
if (popup && popup.closed) clearInterval(poll);
}, 500);
} catch (e) {
stravaStatus.textContent = 'Error: ' + e.message;
stravaStatus.style.color = '#f87171';
}
});
stravaSyncBtn.addEventListener('click', async () => {
stravaSyncBtn.disabled = true;
stravaSyncBtn.textContent = 'Syncing…';
stravaStatus.textContent = '';
try {
const r = await fetch(`${editUrl}/api/strava/sync`, { method: 'POST' });
if (!r.ok) throw new Error(await r.text());
const d = await r.json();
stravaLastSync.textContent = new Date().toLocaleString();
stravaStatus.textContent = `Done — ${d.imported} imported, ${d.skipped} already up to date.`;
stravaStatus.style.color = '#4ade80';
if (d.imported > 0) setTimeout(() => window.location.reload(), 1500);
} catch (e) {
stravaStatus.textContent = 'Error: ' + e.message;
stravaStatus.style.color = '#f87171';
} finally {
stravaSyncBtn.disabled = false;
stravaSyncBtn.textContent = 'Sync now';
}
});
// Handle ?strava= param set by the callback redirect (popup scenario)
const sp = new URLSearchParams(window.location.search);
if (sp.has('strava')) {
history.replaceState(null, '', window.location.pathname);
}
</script> </script>
)} )}
</body> </body>