unify single user and multi user behaviour
This commit is contained in:
@@ -34,5 +34,8 @@ extract_config.yaml
|
||||
site/android/
|
||||
site/ios/
|
||||
|
||||
# Data
|
||||
data/*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
@@ -13,7 +13,8 @@ Anyone can publish their data as BAS JSON and others can include it.
|
||||
|
||||
## Key design decisions
|
||||
|
||||
- **No database, no server** — everything is static files in single-user mode; multi-user VPS mode adds SQLite auth only
|
||||
- **Unified data layout** — single-user and multi-user share the same structure: activities always live in `{data-root}/{handle}/`. The only difference is the presence of `instance.db` (auth). No mode switching, no migration.
|
||||
- **No database, no server** — everything is static files; multi-user VPS mode adds SQLite auth only
|
||||
- **Python with uv** for the extract stage
|
||||
- **Astro + Svelte + Tailwind + MapLibre GL + Observable Plot** for the site
|
||||
- **Haversine** (not geopy) for distance calculations (10x faster)
|
||||
@@ -100,24 +101,21 @@ site/ Astro project
|
||||
## How to run
|
||||
|
||||
```bash
|
||||
# Extract
|
||||
# Single-user (no login)
|
||||
cd ~/src/bincio_activity
|
||||
uv run bincio extract --input ~/your-activity-data/activities --output /tmp/bincio_test
|
||||
uv run bincio extract --output /tmp/bincio_test # → /tmp/bincio_test/{handle}/
|
||||
uv run bincio dev --data-dir /tmp/bincio_test
|
||||
# → http://localhost:4321/u/{handle}/
|
||||
|
||||
# Site dev server (single-user)
|
||||
uv run bincio render --data-dir /tmp/bincio_test --serve
|
||||
# → http://localhost:4321
|
||||
# Multi-user (with login)
|
||||
uv run bincio init --data-dir /tmp/bincio_test --handle dave
|
||||
uv run bincio extract --output /tmp/bincio_test # → /tmp/bincio_test/dave/
|
||||
uv run bincio dev --data-dir /tmp/bincio_test
|
||||
# → http://localhost:4321 (login required)
|
||||
|
||||
# Edit server (optional — enables edit drawer in the site)
|
||||
uv run bincio edit --data-dir /tmp/bincio_test
|
||||
# set PUBLIC_EDIT_URL=http://localhost:4041 in site/.env
|
||||
|
||||
# Multi-user local test
|
||||
uv run bincio init --data-dir /tmp/bincio_test --handle dave --password test
|
||||
uv run bincio render --data-dir /tmp/bincio_test --site-dir site --serve # terminal 1
|
||||
uv run bincio serve --data-dir /tmp/bincio_test # terminal 2
|
||||
# site/.env: BINCIO_DATA_DIR=/tmp/bincio_test, PUBLIC_EDIT_URL= (empty)
|
||||
# astro.config.mjs Vite proxy forwards /api/* → localhost:4041
|
||||
# bincio dev does everything: merges sidecars, writes manifest,
|
||||
# symlinks public/data, starts bincio serve (if instance.db exists),
|
||||
# starts astro dev. Ctrl+C stops all.
|
||||
|
||||
# Tests
|
||||
uv run pytest
|
||||
|
||||
@@ -17,6 +17,7 @@ from bincio.edit.cli import edit # noqa: E402
|
||||
from bincio.import_.cli import import_group # noqa: E402
|
||||
from bincio.serve.init_cmd import init # noqa: E402
|
||||
from bincio.serve.cli import serve # noqa: E402
|
||||
from bincio.dev import dev # noqa: E402
|
||||
|
||||
main.add_command(extract)
|
||||
main.add_command(render)
|
||||
@@ -24,3 +25,4 @@ main.add_command(edit)
|
||||
main.add_command(import_group)
|
||||
main.add_command(init)
|
||||
main.add_command(serve)
|
||||
main.add_command(dev)
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
"""bincio dev — start the full local development environment.
|
||||
|
||||
Runs bincio serve (API) in a background thread and astro dev in the
|
||||
foreground. One command replaces the two-terminal setup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def _find_site_dir(explicit: Optional[str]) -> Path:
|
||||
if explicit:
|
||||
p = Path(explicit).expanduser().resolve()
|
||||
if not (p / "package.json").exists():
|
||||
raise click.UsageError(f"No package.json in --site-dir {p}")
|
||||
return p
|
||||
for candidate in [Path.cwd() / "site", Path.cwd().parent / "site"]:
|
||||
if (candidate / "package.json").exists():
|
||||
return candidate
|
||||
raise click.UsageError(
|
||||
"Could not find the Astro site directory. Pass --site-dir."
|
||||
)
|
||||
|
||||
|
||||
def _find_data_dir(explicit: Optional[str]) -> Path:
|
||||
if explicit:
|
||||
return Path(explicit).expanduser().resolve()
|
||||
auto_config = Path.cwd() / "extract_config.yaml"
|
||||
if auto_config.exists():
|
||||
import yaml
|
||||
raw = yaml.safe_load(auto_config.read_text()) or {}
|
||||
out = raw.get("output", {}).get("dir")
|
||||
if out:
|
||||
return Path(out).expanduser().resolve()
|
||||
for candidate in [Path.cwd() / "bincio_data", Path.cwd().parent / "bincio_data"]:
|
||||
if candidate.exists() and _user_dirs(candidate):
|
||||
return candidate
|
||||
raise click.UsageError(
|
||||
"Could not find a data directory with user subdirectories. "
|
||||
"Run `bincio extract` first, or pass --data-dir."
|
||||
)
|
||||
|
||||
|
||||
def _ensure_npm(site: Path) -> None:
|
||||
if not (site / "node_modules").exists():
|
||||
console.print("Running [cyan]npm install[/cyan]…")
|
||||
subprocess.run(["npm", "install"], cwd=site, check=True)
|
||||
|
||||
|
||||
def _user_dirs(data: Path) -> list[Path]:
|
||||
return sorted(p for p in data.iterdir() if p.is_dir() and (p / "activities").exists())
|
||||
|
||||
|
||||
def _merge_all_users(data: Path) -> None:
|
||||
from bincio.render.cli import _merge_edits, _write_root_manifest
|
||||
_merge_edits(data)
|
||||
_write_root_manifest(data)
|
||||
|
||||
|
||||
def _start_serve(data: Path, api_port: int, site: Path) -> None:
|
||||
"""Start bincio serve in a background thread."""
|
||||
import uvicorn
|
||||
import bincio.serve.server as srv
|
||||
|
||||
srv.data_dir = data
|
||||
srv.site_dir = site
|
||||
|
||||
config = uvicorn.Config(
|
||||
srv.app,
|
||||
host="127.0.0.1",
|
||||
port=api_port,
|
||||
log_level="warning", # quiet — astro dev output takes priority
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
server.run()
|
||||
|
||||
|
||||
@click.command("dev")
|
||||
@click.option("--data-dir", default=None, help="BAS data directory (must contain instance.db)")
|
||||
@click.option("--site-dir", default=None, help="Astro project directory (default: ./site)")
|
||||
@click.option("--port", default=4321, show_default=True, help="Astro dev server port")
|
||||
@click.option("--api-port", default=4041, show_default=True, help="bincio serve API port")
|
||||
def dev(
|
||||
data_dir: Optional[str],
|
||||
site_dir: Optional[str],
|
||||
port: int,
|
||||
api_port: int,
|
||||
) -> None:
|
||||
"""Start the local dev environment: bincio serve + astro dev.
|
||||
|
||||
Equivalent to running both servers manually in two terminals.
|
||||
Requires `bincio init` to have been run first.
|
||||
|
||||
\b
|
||||
Quick start:
|
||||
uv run bincio init --data-dir ./data --handle you --password secret
|
||||
uv run bincio extract --output ./data/you
|
||||
uv run bincio dev --data-dir ./data
|
||||
"""
|
||||
data = _find_data_dir(data_dir)
|
||||
site = _find_site_dir(site_dir)
|
||||
|
||||
has_auth = (data / "instance.db").exists()
|
||||
|
||||
console.print(f"[bold]bincio dev[/bold]")
|
||||
console.print(f" Data: [cyan]{data}[/cyan]")
|
||||
console.print(f" Site: [cyan]{site}[/cyan]")
|
||||
if has_auth:
|
||||
console.print(f" API: [cyan]http://127.0.0.1:{api_port}[/cyan]")
|
||||
else:
|
||||
console.print(f" Auth: [yellow]none[/yellow] (single-user, no instance.db)")
|
||||
console.print(f" Browser: [cyan]http://localhost:{port}[/cyan]")
|
||||
console.print()
|
||||
|
||||
_ensure_npm(site)
|
||||
|
||||
console.print("Merging sidecars…")
|
||||
_merge_all_users(data)
|
||||
|
||||
# Symlink site/public/data → data dir
|
||||
public_data = site / "public" / "data"
|
||||
public_data.parent.mkdir(parents=True, exist_ok=True)
|
||||
if public_data.is_symlink():
|
||||
if public_data.resolve() != data.resolve():
|
||||
public_data.unlink()
|
||||
public_data.symlink_to(data)
|
||||
elif not public_data.exists():
|
||||
public_data.symlink_to(data)
|
||||
|
||||
# Start bincio serve only when instance.db exists (auth / write API)
|
||||
if has_auth:
|
||||
console.print(f"Starting [cyan]bincio serve[/cyan] on port {api_port}…")
|
||||
t = threading.Thread(target=_start_serve, args=(data, api_port, site), daemon=True)
|
||||
t.start()
|
||||
|
||||
# Build env for astro dev
|
||||
env = {
|
||||
**os.environ,
|
||||
"BINCIO_DATA_DIR": str(data),
|
||||
"PUBLIC_EDIT_URL": "", # empty = proxy /api/* to bincio serve
|
||||
"VITE_API_PORT": str(api_port), # picked up by astro.config.mjs if needed
|
||||
}
|
||||
|
||||
# Start astro dev in foreground (Ctrl+C stops everything)
|
||||
console.print(f"Starting [cyan]astro dev[/cyan] on port {port}…")
|
||||
console.print()
|
||||
try:
|
||||
subprocess.run(
|
||||
["npm", "run", "dev", "--", "--port", str(port)],
|
||||
cwd=site,
|
||||
env=env,
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
@@ -342,6 +342,10 @@ def _resolve_config(
|
||||
cfg.input_dirs = [Path(input_dir).expanduser()]
|
||||
if output_dir:
|
||||
cfg.output_dir = Path(output_dir).expanduser()
|
||||
# Always write into {data_root}/{handle}/ so the data dir is always
|
||||
# instance-rooted and single/multi-user share the same layout.
|
||||
if cfg.output_dir.name != cfg.owner_handle:
|
||||
cfg.output_dir = cfg.output_dir / cfg.owner_handle
|
||||
return cfg
|
||||
|
||||
|
||||
|
||||
+1
-22
@@ -70,10 +70,6 @@ def _ensure_npm(site: Path) -> None:
|
||||
subprocess.run(["npm", "install"], cwd=site, check=True)
|
||||
|
||||
|
||||
def _is_multiuser(data: Path) -> bool:
|
||||
return (data / "instance.db").exists()
|
||||
|
||||
|
||||
def _user_dirs(data: Path) -> list[Path]:
|
||||
"""Return all per-user subdirectories (contain an activities/ dir)."""
|
||||
return sorted(
|
||||
@@ -86,7 +82,6 @@ def _merge_edits(data: Path, handle: str | None = None) -> None:
|
||||
"""Run the sidecar merge step for one user or all users."""
|
||||
from bincio.render.merge import merge_all
|
||||
|
||||
if _is_multiuser(data):
|
||||
targets = [data / handle] if handle else _user_dirs(data)
|
||||
total = 0
|
||||
for user_dir in targets:
|
||||
@@ -95,12 +90,6 @@ def _merge_edits(data: Path, handle: str | None = None) -> None:
|
||||
console.print(f" [cyan]{user_dir.name}[/cyan]: {n} sidecar(s) merged")
|
||||
if not total:
|
||||
console.print("No sidecars found — _merged/ dirs mirror extracted data.")
|
||||
else:
|
||||
n = merge_all(data)
|
||||
if n:
|
||||
console.print(f"Merged [cyan]{n}[/cyan] sidecar edit(s) into _merged/")
|
||||
else:
|
||||
console.print("No sidecars found — _merged/ mirrors extracted data.")
|
||||
|
||||
|
||||
def _write_root_manifest(data: Path) -> None:
|
||||
@@ -138,13 +127,8 @@ def _write_root_manifest(data: Path) -> None:
|
||||
|
||||
|
||||
def _link_data(site: Path, data: Path) -> None:
|
||||
"""Symlink site/public/data → data (multi-user) or data/_merged/ (single-user)."""
|
||||
if _is_multiuser(data):
|
||||
# Multi-user: link to data root directly (each user has their own _merged/)
|
||||
"""Symlink site/public/data → data root (each user has their own _merged/)."""
|
||||
target = data
|
||||
else:
|
||||
merged = data / "_merged"
|
||||
target = merged if merged.exists() else data
|
||||
public_data = site / "public" / "data"
|
||||
public_data.parent.mkdir(parents=True, exist_ok=True)
|
||||
if public_data.is_symlink():
|
||||
@@ -193,13 +177,8 @@ def render(
|
||||
console.print(f"Site: [cyan]{site}[/cyan]")
|
||||
console.print(f"Data: [cyan]{data}[/cyan]")
|
||||
|
||||
multiuser = _is_multiuser(data)
|
||||
if multiuser:
|
||||
console.print("[cyan]Multi-user mode[/cyan]")
|
||||
|
||||
_ensure_npm(site)
|
||||
_merge_edits(data, handle=handle)
|
||||
if multiuser:
|
||||
_write_root_manifest(data)
|
||||
_link_data(site, data)
|
||||
|
||||
|
||||
+12
-9
@@ -152,7 +152,7 @@ async def me(bincio_session: Optional[str] = Cookie(default=None)) -> JSONRespon
|
||||
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
async def login(request: Request, response: Response) -> JSONResponse:
|
||||
async def login(request: Request) -> JSONResponse:
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
_check_rate_limit(ip)
|
||||
|
||||
@@ -165,22 +165,24 @@ async def login(request: Request, response: Response) -> JSONResponse:
|
||||
raise HTTPException(401, "Invalid credentials")
|
||||
|
||||
token = create_session(_get_db(), handle)
|
||||
_set_session_cookie(response, token)
|
||||
return JSONResponse({"ok": True, "handle": user.handle, "display_name": user.display_name})
|
||||
resp = JSONResponse({"ok": True, "handle": user.handle, "display_name": user.display_name})
|
||||
_set_session_cookie(resp, token)
|
||||
return resp
|
||||
|
||||
|
||||
@app.post("/api/auth/logout")
|
||||
async def logout(response: Response, bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
||||
async def logout(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
||||
if bincio_session:
|
||||
delete_session(_get_db(), bincio_session)
|
||||
response.delete_cookie(_SESSION_COOKIE)
|
||||
return JSONResponse({"ok": True})
|
||||
resp = JSONResponse({"ok": True})
|
||||
resp.delete_cookie(_SESSION_COOKIE)
|
||||
return resp
|
||||
|
||||
|
||||
# ── Registration ──────────────────────────────────────────────────────────────
|
||||
|
||||
@app.post("/api/register")
|
||||
async def register(request: Request, response: Response) -> JSONResponse:
|
||||
async def register(request: Request) -> JSONResponse:
|
||||
body = await request.json()
|
||||
code = body.get("code", "").strip().upper()
|
||||
handle = body.get("handle", "").strip().lower()
|
||||
@@ -207,8 +209,9 @@ async def register(request: Request, response: Response) -> JSONResponse:
|
||||
(dd / handle / "edits").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
token = create_session(_get_db(), handle)
|
||||
_set_session_cookie(response, token)
|
||||
return JSONResponse({"ok": True, "handle": handle})
|
||||
resp = JSONResponse({"ok": True, "handle": handle})
|
||||
_set_session_cookie(resp, token)
|
||||
return resp
|
||||
|
||||
|
||||
# ── Invites ───────────────────────────────────────────────────────────────────
|
||||
|
||||
+20
-15
@@ -96,9 +96,27 @@ index.json
|
||||
|
||||
## Deployment modes
|
||||
|
||||
Single-user and multi-user share the same data layout. The only difference is whether `instance.db` exists (which enables auth).
|
||||
|
||||
### Data layout (always)
|
||||
|
||||
```
|
||||
{data-root}/
|
||||
index.json ← shard manifest (always; one shard for single-user)
|
||||
instance.db ← SQLite auth (only in multi-user, created by bincio init)
|
||||
{handle}/
|
||||
index.json ← user's BAS feed
|
||||
_merged/ ← sidecar-merged output
|
||||
activities/
|
||||
edits/
|
||||
athlete.json
|
||||
```
|
||||
|
||||
### Single-user (static)
|
||||
|
||||
No server process required. Run `bincio render`, drop `site/dist/` anywhere. The edit drawer requires `bincio edit` running locally and `PUBLIC_EDIT_URL` set in `site/.env`.
|
||||
No login, no server. Run `bincio dev --data-dir {root}` or `bincio render`, drop `site/dist/` anywhere. The site opens directly at `/u/{handle}/`. The "Feed" tab (combined feed) is hidden — there's only one user.
|
||||
|
||||
The edit drawer requires `bincio edit` running locally and `PUBLIC_EDIT_URL` set in `site/.env`.
|
||||
|
||||
### Multi-user (VPS)
|
||||
|
||||
@@ -113,20 +131,7 @@ nginx / caddy
|
||||
|
||||
`bincio serve` is a FastAPI application that owns auth, user management, and write operations. It never serves static files. nginx handles TLS and static file serving.
|
||||
|
||||
Data is partitioned per user:
|
||||
|
||||
```
|
||||
/data/
|
||||
instance.db ← SQLite: users, sessions, invites
|
||||
index.json ← root shard manifest (no activity data)
|
||||
{handle}/
|
||||
index.json ← user's BAS feed
|
||||
_merged/ ← sidecar-merged output
|
||||
activities/
|
||||
edits/
|
||||
```
|
||||
|
||||
The root `index.json` is a shard manifest that lists user shard URLs. The browser resolves all shards concurrently and merges them into a single feed.
|
||||
The root `index.json` shard manifest lists all user shard URLs. The browser resolves them concurrently and merges activities into a combined feed at `/`.
|
||||
|
||||
### Instance privacy
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Multi-user deployment
|
||||
|
||||
Multiple users share one bincio instance. Activities are public within the instance by default. The `private` flag hides individual activities. The whole instance requires login to view (private by default).
|
||||
Multiple users share one bincio instance. The whole instance requires login to view (private by default). Activities are visible to all logged-in users; the `private` flag hides individual activities.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -15,12 +15,12 @@ internet
|
||||
|
||||
`bincio serve` owns all dynamic behaviour — auth, user management, write operations. nginx serves static files and proxies API routes. `bincio serve` never handles static files.
|
||||
|
||||
Sessions are httpOnly cookies (`bincio_session`), stored in SQLite. The Astro site calls `GET /api/me` on page load to detect the logged-in user.
|
||||
Sessions are httpOnly cookies (`bincio_session`), stored in SQLite. The Astro site calls `GET /api/me` on page load to detect the logged-in user and update nav links.
|
||||
|
||||
## Data layout
|
||||
|
||||
```
|
||||
/data/ ← BINCIO_DATA_DIR
|
||||
/data/ ← instance root
|
||||
instance.db ← SQLite: users, sessions, invites
|
||||
index.json ← shard manifest (no activity data)
|
||||
{handle}/
|
||||
@@ -28,10 +28,13 @@ Sessions are httpOnly cookies (`bincio_session`), stored in SQLite. The Astro si
|
||||
_merged/ ← sidecar-merged output (served to browser)
|
||||
activities/
|
||||
edits/
|
||||
athlete.json
|
||||
strava_token.json
|
||||
```
|
||||
|
||||
The root `index.json` is a shard manifest — it lists user shard URLs but contains no activity data. Each user's `{handle}/index.json` is a valid standalone BAS feed (usable for federation). The browser resolves shards concurrently and merges them.
|
||||
The root `index.json` is a shard manifest — it lists user shard URLs but contains no activity data. Each user's `{handle}/index.json` is a valid standalone BAS feed. The browser resolves all shards concurrently and merges them into a combined feed.
|
||||
|
||||
This is the same layout used for single-user deployments — the only addition is `instance.db`.
|
||||
|
||||
## Step 1 — Initialise the instance
|
||||
|
||||
@@ -41,9 +44,9 @@ uv sync --extra serve
|
||||
uv run bincio init \
|
||||
--data-dir /var/bincio \
|
||||
--handle dave \
|
||||
--password 'your-password' \
|
||||
--display-name "Dave" \
|
||||
--name "My Bincio"
|
||||
# prompted for password
|
||||
```
|
||||
|
||||
This creates:
|
||||
@@ -56,10 +59,11 @@ This creates:
|
||||
|
||||
## Step 2 — Extract activities
|
||||
|
||||
Pass the **instance root** to `--output`. The handle is appended automatically:
|
||||
|
||||
```bash
|
||||
uv run bincio extract \
|
||||
--input ~/activity-files \
|
||||
--output /var/bincio/dave
|
||||
uv run bincio extract --output /var/bincio
|
||||
# → writes to /var/bincio/dave/
|
||||
```
|
||||
|
||||
## Step 3 — Build the site
|
||||
@@ -74,17 +78,16 @@ uv run bincio render \
|
||||
# Output: site/dist/
|
||||
```
|
||||
|
||||
In multi-user mode, `bincio render`:
|
||||
- Runs `merge_all()` for each user's directory
|
||||
- Rewrites the root `index.json` shard manifest
|
||||
- Symlinks `site/public/data → /var/bincio`
|
||||
- Builds the Astro site
|
||||
`bincio render` always:
|
||||
1. Runs `merge_all()` for each user's directory
|
||||
2. Rewrites the root `index.json` shard manifest
|
||||
3. Symlinks `site/public/data → /var/bincio`
|
||||
4. Builds the Astro site
|
||||
|
||||
Incremental rebuild (one user only):
|
||||
Incremental rebuild (one user only, no full site rebuild):
|
||||
|
||||
```bash
|
||||
uv run bincio render --data-dir /var/bincio --handle dave
|
||||
# Re-merges dave's shard, rewrites root manifest — does not rebuild the site
|
||||
```
|
||||
|
||||
## Step 4 — Configure nginx
|
||||
@@ -136,14 +139,29 @@ Restart=on-failure
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
## Inviting users
|
||||
|
||||
After initialising, `bincio init` prints an invite code. To generate more:
|
||||
## Local testing (before deploying)
|
||||
|
||||
```bash
|
||||
# From the admin account, via the browser at /invites/
|
||||
# Or directly in the database:
|
||||
python3 -c "
|
||||
# 1. Initialise the instance
|
||||
uv run bincio init --data-dir /tmp/bincio_test --handle dave
|
||||
|
||||
# 2. Extract activities (pass instance root, not user dir)
|
||||
uv run bincio extract --output /tmp/bincio_test
|
||||
# → writes to /tmp/bincio_test/dave/
|
||||
|
||||
# 3. Start everything with one command
|
||||
uv run bincio dev --data-dir /tmp/bincio_test
|
||||
# → http://localhost:4321
|
||||
```
|
||||
|
||||
`bincio dev` detects `instance.db`, starts `bincio serve` (port 4041) in the background and `astro dev` (port 4321) in the foreground. No `.env` file needed. Ctrl+C stops both.
|
||||
|
||||
## Inviting users
|
||||
|
||||
After initialising, `bincio init` prints a first invite code. Generate more from the browser at `/invites/`, or directly:
|
||||
|
||||
```bash
|
||||
uv run python -c "
|
||||
from pathlib import Path
|
||||
from bincio.serve.db import open_db, create_invite
|
||||
db = open_db(Path('/var/bincio'))
|
||||
@@ -153,42 +171,17 @@ print(create_invite(db, 'dave'))
|
||||
|
||||
Share the invite link: `https://example.com/register/?code=XXXXXXXX`
|
||||
|
||||
Invite limits: admins — unlimited. Regular users — 3 invites each (configurable in `bincio/serve/db.py`, `_MAX_USER_INVITES`).
|
||||
Invite limits: admins — unlimited. Regular users — 3 each (configurable via `_MAX_USER_INVITES` in `bincio/serve/db.py`).
|
||||
|
||||
## Instance privacy
|
||||
|
||||
By default, `bincio init` sets `"private": true` in the root `index.json`. This means every page (except `/login/` and `/register/`) redirects unauthenticated visitors to `/login/`.
|
||||
`bincio init` sets `"private": true` in the root `index.json` by default. This means every page (except `/login/` and `/register/`) redirects unauthenticated visitors to `/login/`.
|
||||
|
||||
To make the instance public, edit `/var/bincio/index.json` and set `"private": false`. The next `bincio render` will preserve this setting.
|
||||
|
||||
## Local testing (before deploying)
|
||||
|
||||
```bash
|
||||
# 1. Initialise a test instance
|
||||
uv run bincio init --data-dir /tmp/bincio_test --handle dave --password test
|
||||
|
||||
# 2. Extract activities into the user's dir
|
||||
uv run bincio extract --input ~/activity-files --output /tmp/bincio_test/dave
|
||||
|
||||
# 3. Build + start the dev server (terminal 1)
|
||||
uv run bincio render --data-dir /tmp/bincio_test --site-dir site --serve
|
||||
|
||||
# 4. Start bincio serve (terminal 2)
|
||||
uv run bincio serve --data-dir /tmp/bincio_test
|
||||
```
|
||||
|
||||
The Astro dev server proxies `/api/*` to `localhost:4041` (configured in `astro.config.mjs`), so cookies work same-origin. Set `site/.env`:
|
||||
|
||||
```
|
||||
BINCIO_DATA_DIR=/tmp/bincio_test
|
||||
PUBLIC_EDIT_URL=
|
||||
```
|
||||
|
||||
`PUBLIC_EDIT_URL` empty = edit UI enabled via proxy. The edit/upload button appears when `bincio serve` is running. In production nginx plays the same proxy role.
|
||||
To make the instance public, edit the root `index.json` and set `"private": false`. The next `bincio render` preserves this setting.
|
||||
|
||||
## Per-user Strava sync
|
||||
|
||||
Each user connects their own Strava account. The OAuth token is stored in `/var/bincio/{handle}/strava_token.json`. The "Connect Strava" and "Sync" buttons in the upload modal work per-session — each user syncs only their own activities.
|
||||
Each user connects their own Strava account. The OAuth token is stored in `{handle}/strava_token.json`. The "Sync from Strava" button in the upload modal works per-session — each user syncs only their own activities.
|
||||
|
||||
## Federation
|
||||
|
||||
@@ -209,5 +202,6 @@ The browser fetches and merges remote shards concurrently. Remote activities app
|
||||
|
||||
- [CLI reference — bincio init](../reference/cli.md#bincio-init)
|
||||
- [CLI reference — bincio serve](../reference/cli.md#bincio-serve)
|
||||
- [CLI reference — bincio dev](../reference/cli.md#bincio-dev)
|
||||
- [API reference](../reference/api.md)
|
||||
- [BAS schema — instance manifest](../../SCHEMA.md#instance-manifest)
|
||||
|
||||
@@ -1,32 +1,65 @@
|
||||
# Single-user deployment
|
||||
|
||||
One person, one machine, all your data stays with you. This is the default and simplest mode.
|
||||
One person, one machine, all your data stays with you. No login, no server process.
|
||||
|
||||
## Data layout
|
||||
|
||||
All data lives under your instance root in a per-user subdirectory:
|
||||
|
||||
```
|
||||
~/bincio_data/ ← instance root (output.dir in config)
|
||||
index.json ← shard manifest (generated by bincio render/dev)
|
||||
yourname/
|
||||
index.json ← your BAS feed
|
||||
_merged/ ← sidecar-merged output (served to browser)
|
||||
activities/
|
||||
edits/
|
||||
athlete.json
|
||||
```
|
||||
|
||||
`bincio extract` writes into `yourname/` automatically — pass the instance root to `--output`, not the user directory.
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
uv run bincio dev --data-dir ~/bincio_data
|
||||
# → http://localhost:4321/u/yourname/
|
||||
```
|
||||
|
||||
`bincio dev` without an `instance.db` runs in single-user mode: no login, no API server, just `astro dev`.
|
||||
|
||||
## GitHub Pages (free, automated)
|
||||
|
||||
```bash
|
||||
uv run bincio render --deploy github
|
||||
uv run bincio render --data-dir ~/bincio_data --deploy github
|
||||
```
|
||||
|
||||
This builds `site/dist/` and pushes it to the `gh-pages` branch. Requires `npx gh-pages` (`npm install -g gh-pages`).
|
||||
Builds `site/dist/` and pushes it to the `gh-pages` branch. Requires `npx gh-pages` (`npm install -g gh-pages`).
|
||||
|
||||
Set the repository to serve from the `gh-pages` branch in GitHub → Settings → Pages.
|
||||
|
||||
## Static hosting (Netlify, Vercel, Cloudflare Pages, etc.)
|
||||
|
||||
Build locally and deploy the `site/dist/` directory. Or set up CI:
|
||||
Build locally and deploy `site/dist/`:
|
||||
|
||||
```bash
|
||||
uv run bincio render --data-dir ~/bincio_data
|
||||
# upload site/dist/ to your host
|
||||
```
|
||||
|
||||
Or set up CI:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/deploy.yml (example)
|
||||
- run: uv run bincio render
|
||||
- run: uv run bincio render --data-dir ~/bincio_data
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: site/dist
|
||||
```
|
||||
|
||||
## VPS with nginx
|
||||
## VPS with nginx (read-only)
|
||||
|
||||
Serve `site/dist/` as a static directory. No server process needed for read-only access.
|
||||
Serve `site/dist/` as a static directory:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
@@ -40,7 +73,7 @@ server {
|
||||
|
||||
### Enable the edit UI on a VPS
|
||||
|
||||
If you want to edit activities from the browser while on your VPS:
|
||||
To edit activities from the browser on your VPS, run `bincio edit` and proxy `/api/*` to it:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
@@ -53,7 +86,6 @@ server {
|
||||
try_files $uri $uri/ $uri.html =404;
|
||||
}
|
||||
|
||||
# Proxy /api/* to bincio edit (local-only, never exposed directly)
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:4041;
|
||||
proxy_set_header Host $host;
|
||||
@@ -61,21 +93,20 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
Then run `bincio edit` as a background service:
|
||||
|
||||
```bash
|
||||
uv sync --extra edit
|
||||
uv run bincio edit --data-dir ~/bincio_data
|
||||
```
|
||||
|
||||
And set `PUBLIC_EDIT_URL=` (empty — the proxy makes /api/ same-origin) in your environment before building.
|
||||
Set `PUBLIC_EDIT_URL=` (empty — the proxy makes `/api/` same-origin) in your environment before building.
|
||||
|
||||
## Keeping the site up to date
|
||||
|
||||
After extracting new activities or editing sidecars:
|
||||
|
||||
```bash
|
||||
uv run bincio extract # process new files
|
||||
uv run bincio render # rebuild site/dist/
|
||||
uv run bincio extract --data-dir ~/bincio_data # process new files
|
||||
uv run bincio render --data-dir ~/bincio_data # rebuild site/dist/
|
||||
rsync -av site/dist/ user@server:/var/www/bincio/dist/
|
||||
```
|
||||
|
||||
|
||||
+50
-18
@@ -23,11 +23,11 @@ cp extract_config.example.yaml extract_config.yaml
|
||||
$EDITOR extract_config.yaml
|
||||
```
|
||||
|
||||
Minimum configuration:
|
||||
Set your handle and input directory at minimum:
|
||||
|
||||
```yaml
|
||||
owner:
|
||||
handle: yourname # used in URLs and federation
|
||||
handle: yourname # used in URLs: /u/yourname/
|
||||
display_name: Your Name
|
||||
|
||||
input:
|
||||
@@ -35,54 +35,86 @@ input:
|
||||
- ~/your-activity-data/activities
|
||||
|
||||
output:
|
||||
dir: ~/bincio_data
|
||||
dir: ~/bincio_data # instance root; activities go into ~/bincio_data/yourname/
|
||||
```
|
||||
|
||||
The config file is gitignored — safe to store Strava credentials here.
|
||||
|
||||
---
|
||||
|
||||
## Extract
|
||||
|
||||
```bash
|
||||
uv run bincio extract
|
||||
```
|
||||
|
||||
This reads all GPX/FIT/TCX files (including `.gz` variants), deduplicates them, and writes a BAS data store to `~/bincio_data/`.
|
||||
Reads all GPX/FIT/TCX files and writes a BAS data store to `~/bincio_data/yourname/`. Re-running is safe — unchanged files are skipped (hash-based).
|
||||
|
||||
Re-running is safe — unchanged files are skipped (hash-based). To force a full re-extract: `rm -rf ~/bincio_data && uv run bincio extract`.
|
||||
> `--output` overrides `output.dir` from the config and is the **instance root**,
|
||||
> not the user directory. The handle is always appended automatically:
|
||||
> `bincio extract --output ~/bincio_data` → writes to `~/bincio_data/yourname/`.
|
||||
|
||||
## Build the site
|
||||
---
|
||||
|
||||
## Single-user — no login, static site
|
||||
|
||||
```bash
|
||||
# Build and preview
|
||||
cd site && npm install && cd ..
|
||||
cp site/.env.example site/.env
|
||||
# Edit site/.env: set BINCIO_DATA_DIR=~/bincio_data
|
||||
uv run bincio render
|
||||
uv run bincio dev --data-dir ~/bincio_data
|
||||
# → http://localhost:4321
|
||||
```
|
||||
|
||||
Output is in `site/dist/` — a folder of static files. Drop it anywhere: GitHub Pages, Netlify, a Raspberry Pi, a USB stick.
|
||||
`bincio dev` merges edits, builds the shard manifest, and starts `astro dev`. No login required — the site opens directly at `/u/yourname/`.
|
||||
|
||||
## Dev mode
|
||||
To build for deployment (no live server):
|
||||
|
||||
```bash
|
||||
uv run bincio render --serve # → http://localhost:4321
|
||||
uv run bincio render --data-dir ~/bincio_data
|
||||
# output: site/dist/
|
||||
```
|
||||
|
||||
## Enable the edit UI
|
||||
See [Single-user deployment](deployment/single-user.md).
|
||||
|
||||
The edit UI lets you rename activities, add descriptions, upload photos, and sync from Strava — all from the browser.
|
||||
---
|
||||
|
||||
## Multi-user — shared instance, login required
|
||||
|
||||
```bash
|
||||
uv sync --extra serve
|
||||
|
||||
# One-time: create the instance database and admin account
|
||||
uv run bincio init --data-dir ~/bincio_data --handle yourname
|
||||
|
||||
# Start everything
|
||||
uv run bincio dev --data-dir ~/bincio_data
|
||||
# → http://localhost:4321 (login with the password set during init)
|
||||
```
|
||||
|
||||
`bincio dev` detects the `instance.db` and automatically starts `bincio serve` alongside `astro dev`. Ctrl+C stops both.
|
||||
|
||||
See [Multi-user deployment](deployment/multi-user.md).
|
||||
|
||||
---
|
||||
|
||||
## Enable the edit UI (single-user)
|
||||
|
||||
The edit UI lets you rename activities, add descriptions, upload photos, and sync from Strava — from the browser.
|
||||
|
||||
```bash
|
||||
uv sync --extra edit
|
||||
uv run bincio edit # starts on http://localhost:4041
|
||||
uv run bincio edit --data-dir ~/bincio_data
|
||||
# Add to site/.env:
|
||||
# PUBLIC_EDIT_URL=http://localhost:4041
|
||||
```
|
||||
|
||||
An Edit button and an Upload ↑ button appear in the nav.
|
||||
In multi-user mode the edit UI is always available via `bincio serve` — no extra step needed.
|
||||
|
||||
---
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Single-user deployment](deployment/single-user.md) — serve your site on a VPS or GitHub Pages
|
||||
- [Multi-user deployment](deployment/multi-user.md) — invite friends, shared feed
|
||||
- [Single-user deployment](deployment/single-user.md) — GitHub Pages, Netlify, VPS
|
||||
- [Multi-user deployment](deployment/multi-user.md) — VPS with nginx, inviting users
|
||||
- [CLI reference](reference/cli.md) — all commands and options
|
||||
- [BAS schema](../SCHEMA.md) — the data format and federation protocol
|
||||
|
||||
+45
-3
@@ -4,6 +4,39 @@ All commands are run via `uv run bincio <command>` from the project root.
|
||||
|
||||
---
|
||||
|
||||
## bincio dev
|
||||
|
||||
Start the full local development environment. One command replaces the two-terminal setup.
|
||||
|
||||
```bash
|
||||
uv sync --extra serve
|
||||
uv run bincio dev [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
|---|---|---|
|
||||
| `--data-dir DIR` | auto-detected | BAS data directory (must contain `instance.db`) |
|
||||
| `--site-dir DIR` | `./site` | Astro project directory |
|
||||
| `--port PORT` | `4321` | Astro dev server port |
|
||||
| `--api-port PORT` | `4041` | bincio serve API port |
|
||||
|
||||
`bincio dev` runs the following steps automatically:
|
||||
1. Merges sidecar edits for all users (`merge_all()`)
|
||||
2. Rewrites the root `index.json` shard manifest
|
||||
3. Symlinks `site/public/data` → data directory
|
||||
4. Starts `bincio serve` on `--api-port` in a background thread (**only if `instance.db` exists**)
|
||||
5. Starts `astro dev` on `--port` in the foreground
|
||||
|
||||
No `.env` file needed — `BINCIO_DATA_DIR` and `PUBLIC_EDIT_URL` are set automatically.
|
||||
|
||||
Works in both modes:
|
||||
- **Single-user** (no `instance.db`): no login, no API server, just `astro dev`
|
||||
- **Multi-user** (`instance.db` present): starts `bincio serve` alongside `astro dev`
|
||||
|
||||
Ctrl+C stops everything.
|
||||
|
||||
---
|
||||
|
||||
## bincio extract
|
||||
|
||||
Extract GPX/FIT/TCX files into a BAS data store.
|
||||
@@ -16,12 +49,21 @@ uv run bincio extract [OPTIONS]
|
||||
|---|---|---|
|
||||
| `--config PATH` | `extract_config.yaml` | Path to config file |
|
||||
| `--input DIR` | from config | Input directory (scanned recursively) |
|
||||
| `--output DIR` | from config | Output BAS data store directory |
|
||||
| `--output DIR` | from config | Instance root directory |
|
||||
| `--file PATH` | — | Extract a single file, print JSON to stdout |
|
||||
| `--since DATE` | — | Only process files newer than this date (YYYY-MM-DD) |
|
||||
| `--dev N` | — | Dev mode: sample N files evenly, output to `/tmp/bincio_dev/` |
|
||||
|
||||
Extraction is incremental by default — unchanged files (same hash) are skipped. To force a full re-extract: `rm -rf <output_dir>`.
|
||||
`--output` (and `output.dir` in config) is the **instance root**, not the user directory. The handle from `owner.handle` in `extract_config.yaml` is always appended automatically:
|
||||
|
||||
```
|
||||
bincio extract --output ~/bincio_data
|
||||
# → writes to ~/bincio_data/{handle}/
|
||||
```
|
||||
|
||||
This applies to both single-user and multi-user setups — the data layout is always the same.
|
||||
|
||||
Extraction is incremental by default — unchanged files (same hash) are skipped. To force a full re-extract, delete the user directory: `rm -rf ~/bincio_data/{handle}`.
|
||||
|
||||
Supported formats: GPX, FIT, TCX — all with optional `.gz` compression.
|
||||
|
||||
@@ -46,7 +88,7 @@ uv run bincio render [OPTIONS]
|
||||
|
||||
`bincio render` always:
|
||||
1. Runs `merge_all()` — applies sidecar edits, produces `_merged/`
|
||||
2. (Multi-user) Rewrites the root `index.json` shard manifest
|
||||
2. Rewrites the root `index.json` shard manifest
|
||||
3. Symlinks `site/public/data` → data directory
|
||||
4. Runs `astro build` (or `astro dev` with `--serve`)
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ import svelte from "@astrojs/svelte";
|
||||
import tailwind from "@astrojs/tailwind";
|
||||
|
||||
const env = loadEnv(process.env.NODE_ENV ?? 'development', process.cwd(), '');
|
||||
const serveTarget = env.PUBLIC_EDIT_URL || 'http://localhost:4041';
|
||||
// PUBLIC_EDIT_URL: non-empty → bincio edit URL; empty → proxy to bincio serve.
|
||||
// VITE_API_PORT lets `bincio dev` override the serve port without touching .env.
|
||||
const apiPort = process.env.VITE_API_PORT || '4041';
|
||||
const serveTarget = env.PUBLIC_EDIT_URL || `http://localhost:${apiPort}`;
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [svelte(), tailwind()],
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
import { loadIndex, loadAthlete } from '../lib/dataloader';
|
||||
|
||||
export let base: string = '/';
|
||||
/** Explicit index URL for multi-user per-user pages (user's shard). */
|
||||
export let indexUrl: string = '';
|
||||
/** Explicit athlete.json URL for multi-user per-user pages. */
|
||||
export let athleteUrl: string = '';
|
||||
|
||||
let athlete: AthleteJson | null = null;
|
||||
let activities: ActivitySummary[] = [];
|
||||
@@ -34,8 +38,8 @@
|
||||
mounted = true;
|
||||
try {
|
||||
const [athleteData, index] = await Promise.all([
|
||||
loadAthlete(import.meta.env.BASE_URL),
|
||||
loadIndex(import.meta.env.BASE_URL),
|
||||
loadAthlete(import.meta.env.BASE_URL, athleteUrl || undefined),
|
||||
loadIndex(import.meta.env.BASE_URL, indexUrl || undefined),
|
||||
]);
|
||||
if (!athleteData) throw new Error('athlete.json not found — run bincio extract first');
|
||||
athlete = athleteData;
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
import { formatDistance, formatDuration, sportIcon, sportColor, sportLabel } from '../lib/format';
|
||||
import { loadIndex } from '../lib/dataloader';
|
||||
|
||||
/** Explicit index URL — use for per-user stats pages in multi-user mode. */
|
||||
export let indexUrl: string = '';
|
||||
|
||||
const PAGE_YEARS = 4;
|
||||
|
||||
let all: ActivitySummary[] = [];
|
||||
@@ -31,7 +34,7 @@
|
||||
page = parseInt(params.get('page') ?? '0', 10) || 0;
|
||||
mounted = true;
|
||||
try {
|
||||
const index = await loadIndex(import.meta.env.BASE_URL);
|
||||
const index = await loadIndex(import.meta.env.BASE_URL, indexUrl || undefined);
|
||||
all = index.activities.filter(a => a.privacy !== 'private' && a.distance_m);
|
||||
} catch (e: any) {
|
||||
error = e.message;
|
||||
|
||||
@@ -12,8 +12,9 @@ const { title = 'BincioActivity', description = 'Your personal activity stats',
|
||||
const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? '';
|
||||
const baseUrl = import.meta.env.BASE_URL ?? '/';
|
||||
|
||||
// Detect whether this instance is private (multi-user, requires login to view).
|
||||
// Read root index.json at build time to detect instance configuration.
|
||||
let instancePrivate = false;
|
||||
let singleHandle: string | null = null; // set when there is exactly one shard
|
||||
try {
|
||||
const candidates = [
|
||||
process.env.BINCIO_DATA_DIR,
|
||||
@@ -24,6 +25,9 @@ try {
|
||||
if (dataDir) {
|
||||
const root = JSON.parse(readFileSync(join(dataDir, 'index.json'), 'utf-8'));
|
||||
instancePrivate = root?.instance?.private === true;
|
||||
const shards: Array<{ handle?: string }> = root?.shards ?? [];
|
||||
const handles = shards.map(s => s.handle).filter(Boolean);
|
||||
if (handles.length === 1) singleHandle = handles[0] as string;
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
---
|
||||
@@ -137,13 +141,29 @@ try {
|
||||
<a href={baseUrl} class="font-bold text-white tracking-tight hover:text-[--accent] transition-colors">
|
||||
Bincio<span class="text-[--accent]">Activity</span>
|
||||
</a>
|
||||
<!-- Feed tab: only shown for multi-user (more than one shard) -->
|
||||
{!singleHandle && (
|
||||
<a href={baseUrl} class="text-sm text-zinc-400 hover:text-white transition-colors">Feed</a>
|
||||
<a href={`${baseUrl}stats/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Stats</a>
|
||||
<a href={`${baseUrl}athlete/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Athlete</a>
|
||||
<a href={`${baseUrl}record/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Record</a>
|
||||
)}
|
||||
<!-- Single-user: static handle link. Multi-user: populated by user-widget script. -->
|
||||
{singleHandle
|
||||
? <a href={`${baseUrl}u/${singleHandle}/`} class="text-sm text-zinc-400 hover:text-white transition-colors">@{singleHandle}</a>
|
||||
: <a id="nav-me" href="#" style="display:none" class="text-sm text-[--accent] hover:text-white transition-colors"></a>
|
||||
}
|
||||
<!-- Per-user nav links — updated by user-widget script in multi-user mode -->
|
||||
<a id="nav-stats" href={singleHandle ? `${baseUrl}u/${singleHandle}/stats/` : `${baseUrl}stats/`} data-user-path="stats/" class="text-sm text-zinc-400 hover:text-white transition-colors">Stats</a>
|
||||
<a id="nav-athlete" href={singleHandle ? `${baseUrl}u/${singleHandle}/athlete/` : `${baseUrl}athlete/`} data-user-path="athlete/" class="text-sm text-zinc-400 hover:text-white transition-colors">Athlete</a>
|
||||
<a id="nav-record" href={singleHandle ? `${baseUrl}u/${singleHandle}/record/` : `${baseUrl}record/`} data-user-path="record/" class="text-sm text-zinc-400 hover:text-white transition-colors">Record</a>
|
||||
<a href={`${baseUrl}convert/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Convert</a>
|
||||
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
<!-- Logout button — hidden until logged in -->
|
||||
<button
|
||||
id="nav-logout"
|
||||
style="display:none"
|
||||
class="text-xs text-zinc-500 hover:text-white transition-colors px-2 h-8"
|
||||
aria-label="Log out"
|
||||
>Log out</button>
|
||||
{editUrl && (
|
||||
<button
|
||||
id="upload-btn"
|
||||
@@ -283,6 +303,41 @@ try {
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- User widget: only needed for multi-user (single-user nav links are static) -->
|
||||
{!singleHandle && (
|
||||
<script define:vars={{ baseUrl }}>
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch('/api/me', { credentials: 'include' });
|
||||
if (!r.ok) return;
|
||||
const user = await r.json();
|
||||
|
||||
// Show @handle link → /u/{handle}/
|
||||
const meEl = document.getElementById('nav-me');
|
||||
if (meEl) {
|
||||
meEl.textContent = '@' + user.handle;
|
||||
meEl.href = baseUrl + 'u/' + user.handle + '/';
|
||||
meEl.style.display = '';
|
||||
}
|
||||
|
||||
// Update per-user nav links to point to /u/{handle}/{page}/
|
||||
document.querySelectorAll('[data-user-path]').forEach(el => {
|
||||
el.href = baseUrl + 'u/' + user.handle + '/' + el.getAttribute('data-user-path');
|
||||
});
|
||||
|
||||
// Show logout button
|
||||
const logoutEl = document.getElementById('nav-logout');
|
||||
if (logoutEl) logoutEl.style.display = '';
|
||||
} catch (_) {}
|
||||
})();
|
||||
|
||||
document.getElementById('nav-logout')?.addEventListener('click', async () => {
|
||||
try { await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }); } catch (_) {}
|
||||
window.location.href = baseUrl + 'login/';
|
||||
});
|
||||
</script>
|
||||
)}
|
||||
|
||||
{editUrl && (
|
||||
<script define:vars={{ editUrl, baseUrl }}>
|
||||
const modal = document.getElementById('upload-modal');
|
||||
|
||||
@@ -161,10 +161,16 @@ export async function loadActivity(
|
||||
/**
|
||||
* Load athlete profile. Athlete data is not stored locally yet, so this is
|
||||
* always a network fetch with a graceful null on failure.
|
||||
*
|
||||
* @param baseUrl Site base URL (used to build the default path)
|
||||
* @param athleteUrl Explicit full URL — use for per-user pages in multi-user mode
|
||||
*/
|
||||
export async function loadAthlete(baseUrl: string): Promise<Record<string, unknown> | null> {
|
||||
export async function loadAthlete(
|
||||
baseUrl: string,
|
||||
athleteUrl?: string,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
return await fetchJSON(`${baseUrl}data/athlete.json`);
|
||||
return await fetchJSON(athleteUrl ?? `${baseUrl}data/athlete.json`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Build-time helpers for reading the BAS shard manifest.
|
||||
* Only import this in .astro frontmatter — it uses Node.js APIs.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
export function findDataDir(): string | null {
|
||||
const candidates = [
|
||||
process.env.BINCIO_DATA_DIR,
|
||||
resolve(process.cwd(), 'public', 'data'),
|
||||
resolve(process.cwd(), '..', 'bincio_data'),
|
||||
].filter(Boolean) as string[];
|
||||
return candidates.find(d => {
|
||||
try { readFileSync(join(d, 'index.json')); return true; } catch { return false; }
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
export interface ShardHandle {
|
||||
handle: string;
|
||||
/** Shard URL as written in the manifest (relative to data root). */
|
||||
url: string;
|
||||
}
|
||||
|
||||
export function readShardHandles(): ShardHandle[] {
|
||||
try {
|
||||
const dataDir = findDataDir();
|
||||
if (!dataDir) return [];
|
||||
const root = JSON.parse(readFileSync(join(dataDir, 'index.json'), 'utf-8'));
|
||||
const shards: Array<{ handle?: string; url: string }> = root.shards ?? [];
|
||||
return shards
|
||||
.filter(s => !!s.handle)
|
||||
.map(s => ({ handle: s.handle!, url: s.url }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import AthleteView from '../../components/AthleteView.svelte';
|
||||
/**
|
||||
* Legacy route — redirects to /u/{handle}/athlete/
|
||||
*/
|
||||
import { readShardHandles } from '../../lib/manifest';
|
||||
const base = import.meta.env.BASE_URL;
|
||||
const shards = readShardHandles();
|
||||
const handle = shards[0]?.handle ?? null;
|
||||
---
|
||||
<Base title="Athlete — BincioActivity">
|
||||
<h1 class="text-2xl font-bold text-white mb-6">Athlete</h1>
|
||||
<AthleteView {base} client:load />
|
||||
</Base>
|
||||
{handle ? (
|
||||
<meta http-equiv="refresh" content={`0;url=${base}u/${handle}/athlete/`} />
|
||||
<script define:vars={{ base, handle }}>
|
||||
window.location.replace(base + 'u/' + handle + '/athlete/');
|
||||
</script>
|
||||
) : (
|
||||
<p>No data found. Run <code>bincio extract</code> first.</p>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import ActivityFeed from '../components/ActivityFeed.svelte';
|
||||
import { readShardHandles } from '../lib/manifest';
|
||||
|
||||
const base = import.meta.env.BASE_URL;
|
||||
const shards = readShardHandles();
|
||||
const isSingleUser = shards.length === 1;
|
||||
const singleHandle = isSingleUser ? shards[0].handle : null;
|
||||
---
|
||||
<Base title="BincioActivity — Feed">
|
||||
<h1 class="text-2xl font-bold text-white mb-6">Activities</h1>
|
||||
<ActivityFeed client:load />
|
||||
</Base>
|
||||
{isSingleUser ? (
|
||||
<!-- Single-user: redirect / → /u/{handle}/ -->
|
||||
<meta http-equiv="refresh" content={`0;url=${base}u/${singleHandle}/`} />
|
||||
<script define:vars={{ base, singleHandle }}>
|
||||
window.location.replace(base + 'u/' + singleHandle + '/');
|
||||
</script>
|
||||
) : (
|
||||
<Base title="BincioActivity — Feed">
|
||||
<h1 class="text-2xl font-bold text-white mb-6">Feed</h1>
|
||||
<ActivityFeed {base} client:only="svelte" />
|
||||
</Base>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import StatsView from '../../components/StatsView.svelte';
|
||||
/**
|
||||
* Legacy route — redirects to /u/{handle}/stats/
|
||||
* In multi-user mode the user-widget script in the nav handles the correct link.
|
||||
*/
|
||||
import { readShardHandles } from '../../lib/manifest';
|
||||
const base = import.meta.env.BASE_URL;
|
||||
const shards = readShardHandles();
|
||||
const handle = shards[0]?.handle ?? null;
|
||||
---
|
||||
<Base title="Stats — BincioActivity">
|
||||
<h1 class="text-2xl font-bold text-white mb-6">Stats</h1>
|
||||
<StatsView client:load />
|
||||
</Base>
|
||||
{handle ? (
|
||||
<meta http-equiv="refresh" content={`0;url=${base}u/${handle}/stats/`} />
|
||||
<script define:vars={{ base, handle }}>
|
||||
window.location.replace(base + 'u/' + handle + '/stats/');
|
||||
</script>
|
||||
) : (
|
||||
<p>No data found. Run <code>bincio extract</code> first.</p>
|
||||
)}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
/**
|
||||
* Per-user profile page: /u/{handle}/
|
||||
*
|
||||
* In multi-user mode, getStaticPaths reads the root index.json shard manifest
|
||||
* to discover all handles. In single-user mode this page is never generated.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import ActivityFeed from '../../components/ActivityFeed.svelte';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
try {
|
||||
const candidates = [
|
||||
process.env.BINCIO_DATA_DIR,
|
||||
resolve(process.cwd(), 'public', 'data'),
|
||||
resolve(process.cwd(), '..', 'bincio_data'),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
const dataDir = candidates.find(d => {
|
||||
try { readFileSync(join(d, 'index.json')); return true; } catch { return false; }
|
||||
});
|
||||
if (!dataDir) return [];
|
||||
|
||||
const root = JSON.parse(readFileSync(join(dataDir, 'index.json'), 'utf-8'));
|
||||
const shards: Array<{ handle?: string; url: string }> = root.shards ?? [];
|
||||
const handles = shards.map(s => s.handle).filter(Boolean) as string[];
|
||||
|
||||
return handles.map(handle => ({
|
||||
params: { handle },
|
||||
props: { handle, indexUrl: `${handle}/index.json` },
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const { handle, indexUrl } = Astro.props as { handle: string; indexUrl: string };
|
||||
const base = import.meta.env.BASE_URL;
|
||||
---
|
||||
<Base title={`@${handle} — BincioActivity`}>
|
||||
<div class="max-w-5xl mx-auto px-4 pt-6 pb-2">
|
||||
<h1 class="text-2xl font-bold text-white mb-1">@{handle}</h1>
|
||||
<p class="text-zinc-500 text-sm">Activities by this user</p>
|
||||
</div>
|
||||
<ActivityFeed {base} filterHandle={handle} profileIndexUrl={indexUrl} client:only="svelte" />
|
||||
</Base>
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
/**
|
||||
* Per-user athlete page: /u/{handle}/athlete/
|
||||
*/
|
||||
import Base from '../../../../layouts/Base.astro';
|
||||
import AthleteView from '../../../../components/AthleteView.svelte';
|
||||
import { readShardHandles } from '../../../../lib/manifest';
|
||||
|
||||
export function getStaticPaths() {
|
||||
return readShardHandles().map(({ handle }) => ({
|
||||
params: { handle },
|
||||
props: { handle },
|
||||
}));
|
||||
}
|
||||
|
||||
const { handle } = Astro.props as { handle: string };
|
||||
const base = import.meta.env.BASE_URL;
|
||||
const mergedBase = `${base}data/${handle}/_merged/`;
|
||||
const indexUrl = `${mergedBase}index.json`;
|
||||
const athleteUrl = `${mergedBase}athlete.json`;
|
||||
---
|
||||
<Base title={`@${handle} Athlete — BincioActivity`}>
|
||||
<div class="max-w-5xl mx-auto px-4 pt-6 pb-2">
|
||||
<h1 class="text-2xl font-bold text-white mb-0.5">@{handle}</h1>
|
||||
<nav class="flex gap-4 mt-1 mb-6">
|
||||
<a href={`${base}u/${handle}/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Feed</a>
|
||||
<a href={`${base}u/${handle}/stats/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Stats</a>
|
||||
<a href={`${base}u/${handle}/athlete/`} class="text-sm text-[--accent]">Athlete</a>
|
||||
</nav>
|
||||
</div>
|
||||
<AthleteView {base} {indexUrl} {athleteUrl} client:only="svelte" />
|
||||
</Base>
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
/**
|
||||
* Per-user profile / feed page: /u/{handle}/
|
||||
*
|
||||
* Shows only this user's activities. In multi-user mode, getStaticPaths
|
||||
* reads the root shard manifest to discover all handles.
|
||||
*/
|
||||
import Base from '../../../layouts/Base.astro';
|
||||
import ActivityFeed from '../../../components/ActivityFeed.svelte';
|
||||
import { readShardHandles } from '../../../lib/manifest';
|
||||
|
||||
export function getStaticPaths() {
|
||||
return readShardHandles().map(({ handle, url }) => ({
|
||||
params: { handle },
|
||||
props: { handle, shardUrl: url },
|
||||
}));
|
||||
}
|
||||
|
||||
const { handle, shardUrl } = Astro.props as { handle: string; shardUrl: string };
|
||||
const base = import.meta.env.BASE_URL;
|
||||
---
|
||||
<Base title={`@${handle} — BincioActivity`}>
|
||||
<div class="max-w-5xl mx-auto px-4 pt-6 pb-2 flex items-center gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white mb-0.5">@{handle}</h1>
|
||||
<nav class="flex gap-4 mt-1">
|
||||
<a href={`${base}u/${handle}/`} class="text-sm text-[--accent]">Feed</a>
|
||||
<a href={`${base}u/${handle}/stats/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Stats</a>
|
||||
<a href={`${base}u/${handle}/athlete/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Athlete</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<ActivityFeed {base} filterHandle={handle} profileIndexUrl={shardUrl} client:only="svelte" />
|
||||
</Base>
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
/**
|
||||
* Per-user stats page: /u/{handle}/stats/
|
||||
*/
|
||||
import Base from '../../../../layouts/Base.astro';
|
||||
import StatsView from '../../../../components/StatsView.svelte';
|
||||
import { readShardHandles } from '../../../../lib/manifest';
|
||||
|
||||
export function getStaticPaths() {
|
||||
return readShardHandles().map(({ handle }) => ({
|
||||
params: { handle },
|
||||
props: { handle },
|
||||
}));
|
||||
}
|
||||
|
||||
const { handle } = Astro.props as { handle: string };
|
||||
const base = import.meta.env.BASE_URL;
|
||||
const indexUrl = `${base}data/${handle}/_merged/index.json`;
|
||||
---
|
||||
<Base title={`@${handle} Stats — BincioActivity`}>
|
||||
<div class="max-w-5xl mx-auto px-4 pt-6 pb-2">
|
||||
<h1 class="text-2xl font-bold text-white mb-0.5">@{handle}</h1>
|
||||
<nav class="flex gap-4 mt-1 mb-6">
|
||||
<a href={`${base}u/${handle}/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Feed</a>
|
||||
<a href={`${base}u/${handle}/stats/`} class="text-sm text-[--accent]">Stats</a>
|
||||
<a href={`${base}u/${handle}/athlete/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Athlete</a>
|
||||
</nav>
|
||||
</div>
|
||||
<StatsView {indexUrl} client:only="svelte" />
|
||||
</Base>
|
||||
Reference in New Issue
Block a user