Merge branch 'dev/edit'
This commit is contained in:
@@ -254,44 +254,46 @@ Sidecars work for *remote* activities too: if you include someone else's BAS fee
|
|||||||
you can write local `.md` sidecars for their activity IDs. Your render stage applies
|
you can write local `.md` sidecars for their activity IDs. Your render stage applies
|
||||||
your overrides on top of their data. This is a natural extension of the local case.
|
your overrides on top of their data. This is a natural extension of the local case.
|
||||||
|
|
||||||
### Editing UX: `bincio edit --serve`
|
### Editing UX: drawer in Astro + `bincio edit` write API
|
||||||
|
|
||||||
A separate FastAPI server (`bincio edit --serve`, default port 4041) handles all writes.
|
The edit UI is a **slide-in drawer** (`EditDrawer.svelte`) in the Astro site.
|
||||||
The static site and Astro are untouched — no hybrid mode, no dead-code API routes in prod.
|
The drawer fetches from and POSTs to the `bincio edit` FastAPI server (write API only —
|
||||||
|
the server no longer serves its own HTML UI).
|
||||||
|
|
||||||
**How it works:**
|
**How it works:**
|
||||||
|
|
||||||
```
|
```
|
||||||
bincio edit --serve --data ~/bincio_data # starts on :4041
|
bincio render --serve # Astro dev server, port 4321
|
||||||
|
bincio edit --data-dir ~/… # write API only, port 4041
|
||||||
```
|
```
|
||||||
|
|
||||||
- Serves a bundled Svelte UI (single compiled HTML, reuses existing Svelte investment)
|
- Edit button appears on the activity detail page **only when `PUBLIC_EDIT_URL` is set** in `site/.env`
|
||||||
- `GET /api/activity/{id}` — returns merged BAS JSON + existing sidecar fields
|
- Clicking Edit opens the drawer in the same page — no navigation, no copy-pasting IDs
|
||||||
- `POST /api/activity/{id}` — writes `edits/{id}.md` to the data dir
|
- Drawer fetches `GET /api/activity/{id}` to pre-fill, `POST /api/activity/{id}` to save
|
||||||
|
- After save: server runs `merge_all()` automatically → Astro serves updated data immediately on refresh
|
||||||
|
- Closing the drawer applies `title` + `description` changes optimistically to the local page state
|
||||||
|
(no full reload required to see the text change)
|
||||||
|
|
||||||
|
**`PUBLIC_EDIT_URL` as feature flag:**
|
||||||
|
- **Unset** → no Edit button, no drawer. Works as a normal static site. Safe for public hosting.
|
||||||
|
- **Set** (e.g. `http://localhost:4041`) → editing enabled. Lives in `site/.env` (gitignored).
|
||||||
|
Each deployment opts in explicitly.
|
||||||
|
|
||||||
|
**Edit server API (`bincio edit --data-dir <dir>`):**
|
||||||
|
- `GET /api/activity/{id}` — current values (sidecar overrides layered on BAS JSON)
|
||||||
|
- `POST /api/activity/{id}` — write sidecar `.md`, trigger `merge_all()`
|
||||||
- `POST /api/activity/{id}/images` — multipart upload → `edits/images/{id}/{filename}`
|
- `POST /api/activity/{id}/images` — multipart upload → `edits/images/{id}/{filename}`
|
||||||
- The Astro dev server's file watcher picks up `.md` writes → incremental rebuild
|
- `DELETE /api/activity/{id}/images/{filename}` — remove uploaded image
|
||||||
|
|
||||||
**Edit UI features:**
|
**Edit drawer features:**
|
||||||
- Title text input (pre-filled from BAS JSON)
|
- Title, sport dropdown, gear
|
||||||
- Sport dropdown (pre-filled, shows all known sport types)
|
- Markdown textarea for description (images inserted as `` references)
|
||||||
- Markdown textarea for description, with minimal toolbar (bold, italic, link, image insert)
|
- Image drag-and-drop zone with chip list + delete
|
||||||
- Live markdown preview panel
|
- Hide stat panels (elevation, speed, heart_rate, cadence, power) — toggle buttons
|
||||||
- `hide_stats` checkbox group: elevation, speed, heart_rate, cadence, power
|
- Highlight flag (★ — sorts to top of feed, visual badge)
|
||||||
- `highlight` toggle (feature in feed)
|
- Private flag (⊘ — suppressed from index at render time)
|
||||||
- `private` toggle (suppress from feed at render time)
|
|
||||||
- Image drag-and-drop zone → uploads to `edits/images/{id}/`, inserts `![]()` into textarea
|
|
||||||
- Save button → POST to API → success toast
|
|
||||||
|
|
||||||
**Workflow (typical):**
|
### Image storage and serving
|
||||||
1. User browses the Astro dev server on :4040
|
|
||||||
2. Activity detail page has an "Edit" button (rendered only when `PUBLIC_EDIT_URL` env var is set)
|
|
||||||
3. Button links to `:4041/edit/{id}` — opens the FastAPI-served edit UI
|
|
||||||
4. User fills in form, saves → sidecar written → Astro rebuilds → refreshing :4040 shows changes
|
|
||||||
|
|
||||||
The `PUBLIC_EDIT_URL` env var in `.env` controls whether the Edit button appears;
|
|
||||||
leave it unset for production builds, set to `http://localhost:4041` for local dev.
|
|
||||||
|
|
||||||
### Image storage
|
|
||||||
|
|
||||||
```
|
```
|
||||||
~/bincio_data/
|
~/bincio_data/
|
||||||
@@ -304,17 +306,20 @@ leave it unset for production builds, set to `http://localhost:4041` for local d
|
|||||||
```
|
```
|
||||||
|
|
||||||
Images are referenced in the markdown body with relative paths: ``.
|
Images are referenced in the markdown body with relative paths: ``.
|
||||||
The render stage resolves relative image paths against `edits/images/{id}/` and copies them
|
`merge_all()` symlinks `edits/images/{id}/` → `_merged/activities/images/{id}/` so images
|
||||||
to `site/public/images/activities/{id}/` so they're served from the static site.
|
are served at `data/activities/images/{id}/{filename}` by the Astro dev server.
|
||||||
|
`ActivityDetail.svelte` rewrites relative image paths to this URL when rendering markdown.
|
||||||
|
|
||||||
|
**Note:** browsers cannot display `.HEIC` files. Convert to JPEG/PNG first:
|
||||||
|
`sips -s format jpeg photo.HEIC --out photo.jpg` (macOS).
|
||||||
|
|
||||||
### Decided
|
### Decided
|
||||||
|
|
||||||
- **Sidecar location**: `edits/` subdirectory (not co-located with JSON) — cleaner, easier to
|
- **Sidecar location**: `edits/` subdirectory — cleaner, easier to backup/sync independently
|
||||||
backup/sync just your customisations independently of the extracted data
|
- **Merge output**: `data/_merged/` — extracted data stays pristine; `public/data` → `_merged/`
|
||||||
- **`private: true`**: suppresses from `index.json` at render time (not client-side hide) —
|
- **`private: true`**: suppressed from `index.json` at render time (not client-side hide)
|
||||||
safer for public hosting
|
- **`highlight`**: sorts to top of feed; visual badge TBD
|
||||||
- **`highlight`**: visual badge in feed + sorted before non-highlighted activities
|
- **Edit UI**: drawer in Astro site, `bincio edit` is a pure write API (no HTML serving)
|
||||||
- **Edit UI**: `bincio edit --serve` FastAPI server (Option B) — not integrated into Astro
|
|
||||||
|
|
||||||
## Known issues / next steps
|
## Known issues / next steps
|
||||||
|
|
||||||
@@ -337,9 +342,12 @@ to `site/public/images/activities/{id}/` so they're served from the static site.
|
|||||||
- [ ] Map thumbnail in activity cards (SVG path from GeoJSON)
|
- [ ] Map thumbnail in activity cards (SVG path from GeoJSON)
|
||||||
- [ ] GitHub Actions template for auto-publish
|
- [ ] GitHub Actions template for auto-publish
|
||||||
- [ ] Karoo/Garmin Connect importers beyond Strava
|
- [ ] Karoo/Garmin Connect importers beyond Strava
|
||||||
- [ ] `bincio.render.merge` module: walk `edits/`, parse sidecars, produce enriched data for Astro
|
- [x] `bincio.render.merge` — sidecar parser, `_merged/` output, private filter, highlight sort
|
||||||
- [ ] `bincio render --watch` incremental rebuild on sidecar changes
|
- [x] `bincio edit` FastAPI write API (GET/POST activity, image upload/delete, triggers merge)
|
||||||
- [ ] Sidecar `.md` format: title, sport, description, hide_stats, highlight, private, images
|
- [x] `EditDrawer.svelte` — slide-in edit UI in the Astro site (no separate HTML from server)
|
||||||
- [ ] `bincio edit --serve` FastAPI server with Svelte edit UI (port 4041)
|
- [x] `PUBLIC_EDIT_URL` feature flag — unset = no edit UI, set = drawer enabled
|
||||||
- [ ] Edit button on activity detail pages (visible when `PUBLIC_EDIT_URL` env var set)
|
- [x] Markdown rendering in activity description with image path rewriting
|
||||||
- [ ] Image upload → `edits/images/{id}/`, render stage copies to `public/images/activities/{id}/`
|
- [x] `hide_stats` support in activity detail stats panel
|
||||||
|
- [ ] `bincio render --watch` incremental rebuild on sidecar/data changes
|
||||||
|
- [ ] Highlight badge in activity feed cards
|
||||||
|
- [ ] Image format warning (HEIC → JPEG conversion hint in the upload UI)
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ def main() -> None:
|
|||||||
|
|
||||||
from bincio.extract.cli import extract # noqa: E402
|
from bincio.extract.cli import extract # noqa: E402
|
||||||
from bincio.render.cli import render # noqa: E402
|
from bincio.render.cli import render # noqa: E402
|
||||||
|
from bincio.edit.cli import edit # noqa: E402
|
||||||
|
|
||||||
main.add_command(extract)
|
main.add_command(extract)
|
||||||
main.add_command(render)
|
main.add_command(render)
|
||||||
|
main.add_command(edit)
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""bincio edit — local edit server for activity sidecar files."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import click
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option("--data-dir", default=None,
|
||||||
|
help="BAS data store directory (output of bincio extract).")
|
||||||
|
@click.option("--port", default=4041, show_default=True,
|
||||||
|
help="Port for the edit server.")
|
||||||
|
@click.option("--site-url", default="http://localhost:4321", show_default=True,
|
||||||
|
help="URL of the Astro dev server (for the Back link).")
|
||||||
|
@click.option("--config", "config_path", default=None,
|
||||||
|
help="Path to extract_config.yaml (reads output.dir from it).")
|
||||||
|
def edit(
|
||||||
|
data_dir: Optional[str],
|
||||||
|
port: int,
|
||||||
|
site_url: str,
|
||||||
|
config_path: Optional[str],
|
||||||
|
) -> None:
|
||||||
|
"""Start a local web UI for editing activity sidecar files.
|
||||||
|
|
||||||
|
Writes sidecar .md files to <data-dir>/edits/ which bincio render picks
|
||||||
|
up and applies at build time.
|
||||||
|
|
||||||
|
Run alongside the Astro dev server:
|
||||||
|
|
||||||
|
\b
|
||||||
|
bincio render --serve # port 4321 (or npm run dev)
|
||||||
|
bincio edit # port 4041
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import uvicorn
|
||||||
|
except ImportError:
|
||||||
|
raise click.ClickException(
|
||||||
|
"uvicorn is required for the edit server.\n"
|
||||||
|
"Install with: uv add 'bincio[edit]'"
|
||||||
|
)
|
||||||
|
|
||||||
|
data = _resolve_data_dir(data_dir, config_path)
|
||||||
|
console.print(f"Data dir: [cyan]{data}[/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("Press [bold]Ctrl+C[/bold] to stop.\n")
|
||||||
|
|
||||||
|
import bincio.edit.server as srv
|
||||||
|
srv.data_dir = data
|
||||||
|
srv.site_url = site_url
|
||||||
|
|
||||||
|
uvicorn.run(srv.app, host="127.0.0.1", port=port, log_level="warning")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_data_dir(explicit: Optional[str], config_path: Optional[str]) -> Path:
|
||||||
|
if explicit:
|
||||||
|
return Path(explicit).expanduser().resolve()
|
||||||
|
|
||||||
|
if config_path and Path(config_path).exists():
|
||||||
|
import yaml
|
||||||
|
raw = yaml.safe_load(Path(config_path).read_text())
|
||||||
|
out = raw.get("output", {}).get("dir")
|
||||||
|
if out:
|
||||||
|
return Path(out).expanduser().resolve()
|
||||||
|
|
||||||
|
default = Path.cwd() / "bincio_data"
|
||||||
|
if default.exists():
|
||||||
|
return default
|
||||||
|
|
||||||
|
raise click.UsageError(
|
||||||
|
"Could not find the BAS data directory. "
|
||||||
|
"Run `bincio extract` first, or pass --data-dir."
|
||||||
|
)
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
"""FastAPI edit server — serves the activity edit UI and writes sidecar .md files."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import FastAPI, File, HTTPException, UploadFile
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
|
|
||||||
|
# Populated by the CLI before uvicorn starts
|
||||||
|
data_dir: Path | None = None
|
||||||
|
site_url: str = "http://localhost:4321"
|
||||||
|
|
||||||
|
app = FastAPI(title="BincioActivity Edit Server", docs_url=None, redoc_url=None)
|
||||||
|
|
||||||
|
# Allow the Astro dev server (and any local origin) to call the write API
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
SPORTS = ["cycling", "running", "hiking", "walking", "swimming", "skiing", "other"]
|
||||||
|
STAT_PANELS = ["elevation", "speed", "heart_rate", "cadence", "power"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── HTML UI ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_HTML = """\
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Edit Activity</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
:root {
|
||||||
|
--bg: #09090b; --surface: #18181b; --border: #27272a;
|
||||||
|
--text: #fafafa; --muted: #71717a; --accent: #3b82f6;
|
||||||
|
--accent-dim: #1d3461; --danger: #ef4444;
|
||||||
|
--radius: 10px; --font: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
body { background: var(--bg); color: var(--text); font-family: var(--font);
|
||||||
|
font-size: 14px; line-height: 1.5; padding: 24px; min-height: 100vh; }
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
h1 { font-size: 1.25rem; font-weight: 700; }
|
||||||
|
label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
||||||
|
input, select, textarea {
|
||||||
|
width: 100%; padding: 8px 12px; background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 6px; color: var(--text); font-size: 14px; font-family: var(--font);
|
||||||
|
outline: none; transition: border-color .15s;
|
||||||
|
}
|
||||||
|
input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
||||||
|
textarea { resize: vertical; min-height: 140px; }
|
||||||
|
.field { margin-bottom: 16px; }
|
||||||
|
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||||
|
.check-group { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
|
||||||
|
.check-item { display: flex; align-items: center; gap: 6px; cursor: pointer;
|
||||||
|
padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
user-select: none; transition: border-color .15s, background .15s; }
|
||||||
|
.check-item:hover { border-color: var(--muted); }
|
||||||
|
.check-item input[type=checkbox] { width: auto; accent-color: var(--accent); }
|
||||||
|
.check-item.active { border-color: var(--accent); background: var(--accent-dim); }
|
||||||
|
.toggle-row { display: flex; gap: 16px; }
|
||||||
|
.toggle { display: flex; align-items: center; gap: 8px; cursor: pointer;
|
||||||
|
padding: 6px 12px; border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
transition: border-color .15s, background .15s; }
|
||||||
|
.toggle:hover { border-color: var(--muted); }
|
||||||
|
.toggle.active { border-color: var(--accent); background: var(--accent-dim); }
|
||||||
|
.toggle input { width: auto; accent-color: var(--accent); }
|
||||||
|
.drop-zone { border: 2px dashed var(--border); border-radius: var(--radius);
|
||||||
|
padding: 24px; text-align: center; color: var(--muted); cursor: pointer;
|
||||||
|
transition: border-color .15s; margin-top: 4px; }
|
||||||
|
.drop-zone:hover, .drop-zone.drag-over { border-color: var(--accent); color: var(--text); }
|
||||||
|
.image-list { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
||||||
|
.image-chip { display: flex; align-items: center; gap: 6px; padding: 4px 10px;
|
||||||
|
background: var(--surface); border: 1px solid var(--border); border-radius: 20px;
|
||||||
|
font-size: 12px; }
|
||||||
|
.image-chip button { background: none; border: none; color: var(--muted);
|
||||||
|
cursor: pointer; font-size: 14px; line-height: 1; padding: 0 2px; }
|
||||||
|
.image-chip button:hover { color: var(--danger); }
|
||||||
|
.actions { display: flex; gap: 12px; align-items: center; margin-top: 8px; }
|
||||||
|
.btn { padding: 8px 20px; border-radius: 6px; font-size: 14px; font-weight: 500;
|
||||||
|
cursor: pointer; border: none; transition: opacity .15s; }
|
||||||
|
.btn:disabled { opacity: .4; cursor: default; }
|
||||||
|
.btn-primary { background: var(--accent); color: #fff; }
|
||||||
|
.btn-primary:hover:not(:disabled) { opacity: .85; }
|
||||||
|
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||||||
|
.btn-ghost:hover:not(:disabled) { border-color: var(--muted); }
|
||||||
|
.status { font-size: 13px; }
|
||||||
|
.status.ok { color: #4ade80; }
|
||||||
|
.status.err { color: var(--danger); }
|
||||||
|
.header { display: flex; align-items: baseline; gap: 16px; margin-bottom: 24px; }
|
||||||
|
.back { font-size: 13px; color: var(--muted); }
|
||||||
|
.meta { font-size: 12px; color: var(--muted); margin-top: 4px; }
|
||||||
|
.card { background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); padding: 20px; max-width: 780px; margin: 0 auto; }
|
||||||
|
.section-title { font-size: 11px; text-transform: uppercase; letter-spacing: .08em;
|
||||||
|
color: var(--muted); margin-bottom: 14px; padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid var(--border); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div style="max-width:780px;margin:0 auto">
|
||||||
|
<div class="header">
|
||||||
|
<a class="back" href="__SITE_URL__">← Back to site</a>
|
||||||
|
<h1 id="page-title">Edit Activity</h1>
|
||||||
|
</div>
|
||||||
|
<p id="meta" class="meta" style="margin-bottom:16px"></p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<form id="form" autocomplete="off">
|
||||||
|
<p class="section-title">Identity</p>
|
||||||
|
<div class="row">
|
||||||
|
<div class="field">
|
||||||
|
<label for="title">Title</label>
|
||||||
|
<input id="title" name="title" type="text" placeholder="Leave blank to keep extracted title">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="sport">Sport</label>
|
||||||
|
<select id="sport" name="sport">
|
||||||
|
__SPORT_OPTIONS__
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="gear">Gear</label>
|
||||||
|
<input id="gear" name="gear" type="text" placeholder="e.g. Trek Domane SL6">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="section-title" style="margin-top:20px">Description</p>
|
||||||
|
<div class="field">
|
||||||
|
<label for="description">Markdown supported</label>
|
||||||
|
<textarea id="description" name="description" placeholder="Write about this activity…"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="section-title" style="margin-top:20px">Display</p>
|
||||||
|
<div class="field">
|
||||||
|
<label>Hide stat panels</label>
|
||||||
|
<div class="check-group" id="hide-stats-group">
|
||||||
|
__STAT_CHECKBOXES__
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field" style="margin-top:12px">
|
||||||
|
<label>Flags</label>
|
||||||
|
<div class="toggle-row">
|
||||||
|
<label class="toggle" id="toggle-highlight">
|
||||||
|
<input type="checkbox" id="highlight" name="highlight"> Highlight in feed
|
||||||
|
</label>
|
||||||
|
<label class="toggle" id="toggle-private">
|
||||||
|
<input type="checkbox" id="private" name="private"> Private (hide from feed)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="section-title" style="margin-top:20px">Images</p>
|
||||||
|
<div class="field">
|
||||||
|
<label>Drag & drop images or click to browse</label>
|
||||||
|
<div class="drop-zone" id="drop-zone">
|
||||||
|
<span id="drop-label">Drop images here or click to upload</span>
|
||||||
|
<input type="file" id="file-input" accept="image/*" multiple style="display:none">
|
||||||
|
</div>
|
||||||
|
<div class="image-list" id="image-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="btn btn-primary" id="save-btn">Save</button>
|
||||||
|
<span class="status" id="status"></span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const id = location.pathname.split('/edit/')[1];
|
||||||
|
const api = '/api/activity/' + id;
|
||||||
|
let uploadedImages = [];
|
||||||
|
|
||||||
|
// Fetch current data
|
||||||
|
fetch(api).then(r => r.json()).then(data => {
|
||||||
|
document.getElementById('page-title').textContent = 'Edit: ' + (data.title || id);
|
||||||
|
document.getElementById('meta').textContent = data.started_at
|
||||||
|
? new Date(data.started_at).toLocaleString() : '';
|
||||||
|
document.getElementById('title').value = data.title || '';
|
||||||
|
document.getElementById('sport').value = data.sport || 'other';
|
||||||
|
document.getElementById('gear').value = data.gear || '';
|
||||||
|
document.getElementById('description').value = data.description || '';
|
||||||
|
if (data.highlight) setToggle('highlight', true);
|
||||||
|
if (data.private) setToggle('private', true);
|
||||||
|
(data.hide_stats || []).forEach(s => {
|
||||||
|
const cb = document.querySelector(`input[data-stat="${s}"]`);
|
||||||
|
if (cb) { cb.checked = true; cb.closest('.check-item').classList.add('active'); }
|
||||||
|
});
|
||||||
|
uploadedImages = data.images || [];
|
||||||
|
renderImageList();
|
||||||
|
}).catch(() => {
|
||||||
|
document.getElementById('status').textContent = 'Could not load activity data.';
|
||||||
|
document.getElementById('status').className = 'status err';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle active class on check items
|
||||||
|
document.querySelectorAll('.check-item input[type=checkbox]').forEach(cb => {
|
||||||
|
cb.addEventListener('change', () => {
|
||||||
|
cb.closest('.check-item').classList.toggle('active', cb.checked);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function setToggle(name, val) {
|
||||||
|
const cb = document.getElementById(name);
|
||||||
|
cb.checked = val;
|
||||||
|
document.getElementById('toggle-' + name).classList.toggle('active', val);
|
||||||
|
}
|
||||||
|
document.getElementById('highlight').addEventListener('change', e => {
|
||||||
|
document.getElementById('toggle-highlight').classList.toggle('active', e.target.checked);
|
||||||
|
});
|
||||||
|
document.getElementById('private').addEventListener('change', e => {
|
||||||
|
document.getElementById('toggle-private').classList.toggle('active', e.target.checked);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Image upload
|
||||||
|
const dropZone = document.getElementById('drop-zone');
|
||||||
|
const fileInput = document.getElementById('file-input');
|
||||||
|
|
||||||
|
dropZone.addEventListener('click', () => fileInput.click());
|
||||||
|
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
|
||||||
|
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
|
||||||
|
dropZone.addEventListener('drop', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.remove('drag-over');
|
||||||
|
uploadFiles([...e.dataTransfer.files]);
|
||||||
|
});
|
||||||
|
fileInput.addEventListener('change', () => uploadFiles([...fileInput.files]));
|
||||||
|
|
||||||
|
async function uploadFiles(files) {
|
||||||
|
for (const file of files) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
const r = await fetch(api + '/images', { method: 'POST', body: fd });
|
||||||
|
if (r.ok) {
|
||||||
|
const d = await r.json();
|
||||||
|
if (!uploadedImages.includes(d.filename)) uploadedImages.push(d.filename);
|
||||||
|
renderImageList();
|
||||||
|
// Insert markdown image reference at end of description
|
||||||
|
const ta = document.getElementById('description');
|
||||||
|
const ref = '\\n![' + d.filename.replace(/\\.[^.]+$/, '') + '](' + d.filename + ')';
|
||||||
|
ta.value = ta.value.trimEnd() + ref;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderImageList() {
|
||||||
|
const list = document.getElementById('image-list');
|
||||||
|
list.innerHTML = uploadedImages.map(f =>
|
||||||
|
`<span class="image-chip">${f}
|
||||||
|
<button type="button" onclick="removeImage('${f}')" title="Remove">×</button>
|
||||||
|
</span>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeImage(filename) {
|
||||||
|
await fetch(api + '/images/' + encodeURIComponent(filename), { method: 'DELETE' });
|
||||||
|
uploadedImages = uploadedImages.filter(f => f !== filename);
|
||||||
|
renderImageList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save
|
||||||
|
document.getElementById('form').addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = document.getElementById('save-btn');
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
btn.disabled = true;
|
||||||
|
status.textContent = 'Saving…';
|
||||||
|
status.className = 'status';
|
||||||
|
|
||||||
|
const hideStats = [...document.querySelectorAll('input[data-stat]:checked')]
|
||||||
|
.map(cb => cb.dataset.stat);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
title: document.getElementById('title').value.trim(),
|
||||||
|
sport: document.getElementById('sport').value,
|
||||||
|
gear: document.getElementById('gear').value.trim(),
|
||||||
|
description: document.getElementById('description').value.trim(),
|
||||||
|
highlight: document.getElementById('highlight').checked,
|
||||||
|
private: document.getElementById('private').checked,
|
||||||
|
hide_stats: hideStats,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await fetch(api, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(await r.text());
|
||||||
|
status.textContent = 'Saved! Re-run `bincio render` to rebuild.';
|
||||||
|
status.className = 'status ok';
|
||||||
|
} catch (err) {
|
||||||
|
status.textContent = 'Error: ' + err.message;
|
||||||
|
status.className = 'status err';
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ── Routes ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _get_data_dir() -> Path:
|
||||||
|
if data_dir is None:
|
||||||
|
raise HTTPException(500, "Edit server not configured (data_dir is None)")
|
||||||
|
return data_dir
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root() -> RedirectResponse:
|
||||||
|
return RedirectResponse(url=site_url)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/edit/{activity_id}", response_class=HTMLResponse)
|
||||||
|
async def edit_page(activity_id: str) -> str:
|
||||||
|
sport_opts = "\n".join(
|
||||||
|
f'<option value="{s}">{s.capitalize()}</option>' for s in SPORTS
|
||||||
|
)
|
||||||
|
stat_cbs = "\n".join(
|
||||||
|
f'<label class="check-item"><input type="checkbox" data-stat="{s}"> {s.replace("_", " ").capitalize()}</label>'
|
||||||
|
for s in STAT_PANELS
|
||||||
|
)
|
||||||
|
html = (
|
||||||
|
_HTML
|
||||||
|
.replace("__SITE_URL__", site_url)
|
||||||
|
.replace("__SPORT_OPTIONS__", sport_opts)
|
||||||
|
.replace("__STAT_CHECKBOXES__", stat_cbs)
|
||||||
|
)
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/activity/{activity_id}")
|
||||||
|
async def get_activity(activity_id: str) -> JSONResponse:
|
||||||
|
dd = _get_data_dir()
|
||||||
|
json_path = dd / "activities" / f"{activity_id}.json"
|
||||||
|
if not json_path.exists():
|
||||||
|
raise HTTPException(404, f"Activity {activity_id!r} not found")
|
||||||
|
|
||||||
|
detail: dict[str, Any] = json.loads(json_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
# Read existing sidecar if any — these are the "user" values shown in the form
|
||||||
|
from bincio.render.merge import parse_sidecar
|
||||||
|
sidecar_path = dd / "edits" / f"{activity_id}.md"
|
||||||
|
fm: dict = {}
|
||||||
|
body = ""
|
||||||
|
if sidecar_path.exists():
|
||||||
|
fm, body = parse_sidecar(sidecar_path)
|
||||||
|
|
||||||
|
# Existing uploaded images for this activity
|
||||||
|
images_dir = dd / "edits" / "images" / activity_id
|
||||||
|
images = sorted(p.name for p in images_dir.iterdir() if p.is_file()) if images_dir.exists() else []
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"id": activity_id,
|
||||||
|
"started_at": detail.get("started_at", ""),
|
||||||
|
"title": fm.get("title", detail.get("title", "")),
|
||||||
|
"sport": fm.get("sport", detail.get("sport", "other")),
|
||||||
|
"gear": fm.get("gear", detail.get("gear") or ""),
|
||||||
|
"description": body or fm.get("description") or detail.get("description") or "",
|
||||||
|
"highlight": fm.get("highlight", detail.get("custom", {}).get("highlight", False)),
|
||||||
|
"private": fm.get("private", detail.get("privacy") == "private"),
|
||||||
|
"hide_stats": fm.get("hide_stats", detail.get("custom", {}).get("hide_stats", [])),
|
||||||
|
"images": images,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/activity/{activity_id}")
|
||||||
|
async def save_activity(activity_id: str, payload: dict[str, Any]) -> JSONResponse:
|
||||||
|
dd = _get_data_dir()
|
||||||
|
if not (dd / "activities" / f"{activity_id}.json").exists():
|
||||||
|
raise HTTPException(404, f"Activity {activity_id!r} not found")
|
||||||
|
|
||||||
|
edits_dir = dd / "edits"
|
||||||
|
edits_dir.mkdir(exist_ok=True)
|
||||||
|
sidecar_path = edits_dir / f"{activity_id}.md"
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
if payload.get("title"):
|
||||||
|
lines.append(f"title: {json.dumps(payload['title'])}")
|
||||||
|
if payload.get("sport") and payload["sport"] != "other":
|
||||||
|
lines.append(f"sport: {payload['sport']}")
|
||||||
|
if payload.get("gear"):
|
||||||
|
lines.append(f"gear: {json.dumps(payload['gear'])}")
|
||||||
|
if payload.get("highlight"):
|
||||||
|
lines.append("highlight: true")
|
||||||
|
if payload.get("private"):
|
||||||
|
lines.append("private: true")
|
||||||
|
hide = payload.get("hide_stats") or []
|
||||||
|
if hide:
|
||||||
|
lines.append(f"hide_stats: [{', '.join(str(s) for s in hide)}]")
|
||||||
|
|
||||||
|
description = (payload.get("description") or "").strip()
|
||||||
|
|
||||||
|
content = "---\n" + "\n".join(lines) + "\n---\n"
|
||||||
|
if description:
|
||||||
|
content += "\n" + description + "\n"
|
||||||
|
|
||||||
|
sidecar_path.write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
|
# Re-merge so the Astro dev server immediately serves updated data
|
||||||
|
from bincio.render.merge import merge_all
|
||||||
|
merge_all(dd)
|
||||||
|
|
||||||
|
return JSONResponse({"ok": True, "sidecar": str(sidecar_path)})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/activity/{activity_id}/images")
|
||||||
|
async def upload_image(activity_id: str, file: UploadFile = File(...)) -> JSONResponse:
|
||||||
|
dd = _get_data_dir()
|
||||||
|
if not (dd / "activities" / f"{activity_id}.json").exists():
|
||||||
|
raise HTTPException(404, f"Activity {activity_id!r} not found")
|
||||||
|
if not file.filename:
|
||||||
|
raise HTTPException(400, "No filename")
|
||||||
|
|
||||||
|
images_dir = dd / "edits" / "images" / activity_id
|
||||||
|
images_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest = images_dir / Path(file.filename).name
|
||||||
|
dest.write_bytes(await file.read())
|
||||||
|
return JSONResponse({"ok": True, "filename": dest.name})
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/activity/{activity_id}/images/{filename}")
|
||||||
|
async def delete_image(activity_id: str, filename: str) -> JSONResponse:
|
||||||
|
dd = _get_data_dir()
|
||||||
|
target = dd / "edits" / "images" / activity_id / filename
|
||||||
|
if target.exists() and target.is_file():
|
||||||
|
target.unlink()
|
||||||
|
# Remove empty parent dir
|
||||||
|
if not any(target.parent.iterdir()):
|
||||||
|
shutil.rmtree(target.parent)
|
||||||
|
return JSONResponse({"ok": True})
|
||||||
+17
-4
@@ -61,11 +61,23 @@ def _ensure_npm(site: Path) -> None:
|
|||||||
subprocess.run(["npm", "install"], cwd=site, check=True)
|
subprocess.run(["npm", "install"], cwd=site, check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_edits(data: Path) -> None:
|
||||||
|
"""Run the sidecar merge step, producing data/_merged/."""
|
||||||
|
from bincio.render.merge import merge_all
|
||||||
|
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 _link_data(site: Path, data: Path) -> None:
|
def _link_data(site: Path, data: Path) -> None:
|
||||||
"""Symlink the BAS data store into site/public/data."""
|
"""Symlink site/public/data → data/_merged/ (the post-merge output)."""
|
||||||
|
merged = data / "_merged"
|
||||||
|
target = merged if merged.exists() else data
|
||||||
public_data = site / "public" / "data"
|
public_data = site / "public" / "data"
|
||||||
if public_data.is_symlink():
|
if public_data.is_symlink():
|
||||||
if public_data.resolve() == data:
|
if public_data.resolve() == target.resolve():
|
||||||
return # already correct
|
return # already correct
|
||||||
public_data.unlink()
|
public_data.unlink()
|
||||||
elif public_data.exists():
|
elif public_data.exists():
|
||||||
@@ -74,8 +86,8 @@ def _link_data(site: Path, data: Path) -> None:
|
|||||||
"remove it manually if you want bincio to manage it."
|
"remove it manually if you want bincio to manage it."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
public_data.symlink_to(data)
|
public_data.symlink_to(target)
|
||||||
console.print(f"Linked data: [cyan]{data}[/cyan] → [cyan]{public_data}[/cyan]")
|
console.print(f"Linked data: [cyan]{target}[/cyan] → [cyan]{public_data}[/cyan]")
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
@@ -108,6 +120,7 @@ def render(
|
|||||||
console.print(f"Data: [cyan]{data}[/cyan]")
|
console.print(f"Data: [cyan]{data}[/cyan]")
|
||||||
|
|
||||||
_ensure_npm(site)
|
_ensure_npm(site)
|
||||||
|
_merge_edits(data)
|
||||||
_link_data(site, data)
|
_link_data(site, data)
|
||||||
|
|
||||||
env = {**os.environ, "BINCIO_DATA_DIR": str(data)}
|
env = {**os.environ, "BINCIO_DATA_DIR": str(data)}
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""Apply sidecar .md edits to BAS JSON files.
|
||||||
|
|
||||||
|
Produces data_dir/_merged/ — a mirror of data_dir where:
|
||||||
|
- Files without sidecars are symlinked to the originals (cheap, preserves extracted data)
|
||||||
|
- Files with sidecars are written as merged copies
|
||||||
|
- index.json is rewritten with private filtering + highlight sort
|
||||||
|
|
||||||
|
This keeps data_dir/activities/*.json pristine (re-running extract never clobbers
|
||||||
|
user edits, and removing a sidecar always reverts fully on the next render).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
def parse_sidecar(path: Path) -> tuple[dict, str]:
|
||||||
|
"""Return (frontmatter_dict, markdown_body) from a sidecar .md file."""
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
if text.startswith("---"):
|
||||||
|
parts = re.split(r"^---[ \t]*$", text, maxsplit=2, flags=re.MULTILINE)
|
||||||
|
if len(parts) >= 3:
|
||||||
|
fm = yaml.safe_load(parts[1]) or {}
|
||||||
|
return fm, parts[2].strip()
|
||||||
|
return {}, text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def apply_sidecar(detail: dict, fm: dict, body: str) -> dict:
|
||||||
|
"""Apply sidecar overrides to a detail JSON dict. Returns a modified copy."""
|
||||||
|
d = dict(detail)
|
||||||
|
d.setdefault("custom", {})
|
||||||
|
d["custom"] = dict(d["custom"]) # don't mutate original
|
||||||
|
|
||||||
|
if "title" in fm:
|
||||||
|
d["title"] = str(fm["title"])
|
||||||
|
if "sport" in fm:
|
||||||
|
d["sport"] = str(fm["sport"])
|
||||||
|
if "gear" in fm:
|
||||||
|
d["gear"] = str(fm["gear"]) if fm["gear"] else d.get("gear")
|
||||||
|
if body:
|
||||||
|
d["description"] = body
|
||||||
|
elif "description" in fm:
|
||||||
|
d["description"] = str(fm["description"])
|
||||||
|
if "highlight" in fm:
|
||||||
|
d["custom"]["highlight"] = bool(fm["highlight"])
|
||||||
|
if "private" in fm:
|
||||||
|
d["privacy"] = "private" if fm["private"] else detail.get("privacy", "public")
|
||||||
|
if "hide_stats" in fm:
|
||||||
|
d["custom"]["hide_stats"] = [str(s) for s in (fm["hide_stats"] or [])]
|
||||||
|
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_sidecar_summary(summary: dict, fm: dict) -> dict:
|
||||||
|
"""Apply sidecar overrides to an index summary entry."""
|
||||||
|
s = dict(summary)
|
||||||
|
s.setdefault("custom", {})
|
||||||
|
s["custom"] = dict(s["custom"])
|
||||||
|
|
||||||
|
if "title" in fm:
|
||||||
|
s["title"] = str(fm["title"])
|
||||||
|
if "sport" in fm:
|
||||||
|
s["sport"] = str(fm["sport"])
|
||||||
|
if "highlight" in fm:
|
||||||
|
s["custom"]["highlight"] = bool(fm["highlight"])
|
||||||
|
if "private" in fm:
|
||||||
|
s["privacy"] = "private" if fm["private"] else summary.get("privacy", "public")
|
||||||
|
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def merge_all(data_dir: Path) -> int:
|
||||||
|
"""Build data_dir/_merged/ with all sidecar overrides applied.
|
||||||
|
|
||||||
|
Returns the number of sidecars found and applied.
|
||||||
|
"""
|
||||||
|
edits_dir = data_dir / "edits"
|
||||||
|
acts_dir = data_dir / "activities"
|
||||||
|
merged_dir = data_dir / "_merged"
|
||||||
|
merged_acts = merged_dir / "activities"
|
||||||
|
|
||||||
|
# Collect sidecars upfront
|
||||||
|
sidecars: dict[str, tuple[dict, str]] = {}
|
||||||
|
if edits_dir.exists():
|
||||||
|
for md_path in sorted(edits_dir.glob("*.md")):
|
||||||
|
sidecars[md_path.stem] = parse_sidecar(md_path)
|
||||||
|
|
||||||
|
# Collect image lists — activities with uploaded images get custom.images even
|
||||||
|
# if they have no sidecar text yet
|
||||||
|
image_lists: dict[str, list[str]] = {}
|
||||||
|
images_root = edits_dir / "images" if edits_dir.exists() else None
|
||||||
|
if images_root and images_root.exists():
|
||||||
|
for img_dir in sorted(images_root.iterdir()):
|
||||||
|
if img_dir.is_dir():
|
||||||
|
files = sorted(
|
||||||
|
p.name for p in img_dir.iterdir()
|
||||||
|
if p.is_file() and not p.name.startswith(".")
|
||||||
|
)
|
||||||
|
if files:
|
||||||
|
image_lists[img_dir.name] = files
|
||||||
|
|
||||||
|
to_merge = set(sidecars) | set(image_lists)
|
||||||
|
|
||||||
|
# Wipe and recreate _merged/activities/
|
||||||
|
if merged_acts.exists():
|
||||||
|
shutil.rmtree(merged_acts)
|
||||||
|
merged_acts.mkdir(parents=True)
|
||||||
|
|
||||||
|
# Mirror activities/ — symlink unmodified, write merged copies for overridden
|
||||||
|
if acts_dir.exists():
|
||||||
|
for src in sorted(acts_dir.iterdir()):
|
||||||
|
if not src.is_file():
|
||||||
|
continue
|
||||||
|
dest = merged_acts / src.name
|
||||||
|
activity_id = src.stem
|
||||||
|
if src.suffix == ".json" and activity_id in to_merge:
|
||||||
|
detail = json.loads(src.read_text(encoding="utf-8"))
|
||||||
|
if activity_id in sidecars:
|
||||||
|
fm, body = sidecars[activity_id]
|
||||||
|
detail = apply_sidecar(detail, fm, body)
|
||||||
|
if activity_id in image_lists:
|
||||||
|
detail["custom"] = dict(detail.get("custom") or {})
|
||||||
|
detail["custom"]["images"] = image_lists[activity_id]
|
||||||
|
dest.write_text(json.dumps(detail, indent=2, ensure_ascii=False))
|
||||||
|
else:
|
||||||
|
dest.symlink_to(src.resolve())
|
||||||
|
|
||||||
|
# Mirror edits/images/ → _merged/activities/images/ so the site can serve them
|
||||||
|
if images_root and images_root.exists():
|
||||||
|
merged_images = merged_acts / "images"
|
||||||
|
merged_images.mkdir(exist_ok=True)
|
||||||
|
for img_dir in images_root.iterdir():
|
||||||
|
if img_dir.is_dir():
|
||||||
|
dest_img = merged_images / img_dir.name
|
||||||
|
if not dest_img.exists():
|
||||||
|
dest_img.symlink_to(img_dir.resolve())
|
||||||
|
|
||||||
|
# Write merged index.json (private filtered, highlight sorted)
|
||||||
|
index_path = data_dir / "index.json"
|
||||||
|
if index_path.exists():
|
||||||
|
index = json.loads(index_path.read_text(encoding="utf-8"))
|
||||||
|
activities = []
|
||||||
|
for s in index.get("activities", []):
|
||||||
|
aid = s.get("id", "")
|
||||||
|
if aid in sidecars:
|
||||||
|
fm, _ = sidecars[aid]
|
||||||
|
s = _apply_sidecar_summary(s, fm)
|
||||||
|
activities.append(s)
|
||||||
|
|
||||||
|
# Drop private activities from the published feed
|
||||||
|
activities = [a for a in activities if a.get("privacy") != "private"]
|
||||||
|
|
||||||
|
# Sort: newest first, then bring highlighted activities to the top
|
||||||
|
activities.sort(key=lambda a: a.get("started_at", ""), reverse=True)
|
||||||
|
activities.sort(key=lambda a: 0 if a.get("custom", {}).get("highlight") else 1)
|
||||||
|
|
||||||
|
index["activities"] = activities
|
||||||
|
(merged_dir / "index.json").write_text(
|
||||||
|
json.dumps(index, indent=2, ensure_ascii=False)
|
||||||
|
)
|
||||||
|
elif (merged_dir / "index.json").exists():
|
||||||
|
(merged_dir / "index.json").unlink()
|
||||||
|
|
||||||
|
return len(sidecars)
|
||||||
@@ -32,6 +32,11 @@ dependencies = [
|
|||||||
classifier = [
|
classifier = [
|
||||||
"scikit-learn>=1.5",
|
"scikit-learn>=1.5",
|
||||||
]
|
]
|
||||||
|
edit = [
|
||||||
|
"fastapi>=0.110",
|
||||||
|
"uvicorn[standard]>=0.29",
|
||||||
|
"python-multipart>=0.0.9",
|
||||||
|
]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=9.0",
|
"pytest>=9.0",
|
||||||
"pytest-cov>=5.0",
|
"pytest-cov>=5.0",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
BINCIO_DATA_DIR=/tmp/bincio_test
|
||||||
|
PUBLIC_EDIT_URL=http://localhost:4041
|
||||||
@@ -4,6 +4,7 @@ import tailwind from "@astrojs/tailwind";
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
integrations: [svelte(), tailwind()],
|
integrations: [svelte(), tailwind()],
|
||||||
|
devToolbar: { enabled: false },
|
||||||
output: "static",
|
output: "static",
|
||||||
// When hosting at a subdirectory (e.g. GitHub Pages project site), set:
|
// When hosting at a subdirectory (e.g. GitHub Pages project site), set:
|
||||||
// base: "/repo-name",
|
// base: "/repo-name",
|
||||||
|
|||||||
+2
-1
@@ -12,9 +12,10 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/svelte": "^7.0.0",
|
"@astrojs/svelte": "^7.0.0",
|
||||||
"@astrojs/tailwind": "^5.1.0",
|
"@astrojs/tailwind": "^5.1.0",
|
||||||
|
"@observablehq/plot": "^0.6.0",
|
||||||
"astro": "^5.0.0",
|
"astro": "^5.0.0",
|
||||||
"maplibre-gl": "^5.0.0",
|
"maplibre-gl": "^5.0.0",
|
||||||
"@observablehq/plot": "^0.6.0",
|
"marked": "^17.0.5",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"tailwindcss": "^3.4.0"
|
"tailwindcss": "^3.4.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { marked } from 'marked';
|
||||||
import type { ActivitySummary, ActivityDetail } from '../lib/types';
|
import type { ActivitySummary, ActivityDetail } from '../lib/types';
|
||||||
import { formatDistance, formatDuration, formatElevation, formatSpeed, formatDate, formatTime, sportIcon, sportLabel, sportColor } from '../lib/format';
|
import { formatDistance, formatDuration, formatElevation, formatSpeed, formatDate, formatTime, sportIcon, sportLabel, sportColor } from '../lib/format';
|
||||||
import ActivityMap from './ActivityMap.svelte';
|
import ActivityMap from './ActivityMap.svelte';
|
||||||
import ActivityCharts from './ActivityCharts.svelte';
|
import ActivityCharts from './ActivityCharts.svelte';
|
||||||
|
import EditDrawer from './EditDrawer.svelte';
|
||||||
|
|
||||||
export let activity: ActivitySummary;
|
export let activity: ActivitySummary;
|
||||||
export let base: string = '/';
|
export let base: string = '/';
|
||||||
@@ -12,8 +14,14 @@
|
|||||||
|
|
||||||
let detail: ActivityDetail | null = null;
|
let detail: ActivityDetail | null = null;
|
||||||
let error = '';
|
let error = '';
|
||||||
// Linked hover index shared between map and charts
|
|
||||||
let hoveredIdx: number | null = null;
|
let hoveredIdx: number | null = null;
|
||||||
|
let editOpen = false;
|
||||||
|
let lightboxIndex: number | null = null;
|
||||||
|
|
||||||
|
// Local overrides applied immediately after a save (no re-fetch needed)
|
||||||
|
let localTitle = '';
|
||||||
|
let localDescription = '';
|
||||||
|
$: displayTitle = localTitle || activity.title;
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (!activity.detail_url) return;
|
if (!activity.detail_url) return;
|
||||||
@@ -26,9 +34,42 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function onSaved(e: CustomEvent<{ title: string; description: string }>) {
|
||||||
|
editOpen = false;
|
||||||
|
localTitle = e.detail.title;
|
||||||
|
localDescription = e.detail.description;
|
||||||
|
}
|
||||||
|
|
||||||
$: trackUrl = activity.track_url ? `${base}data/${activity.track_url}` : null;
|
$: trackUrl = activity.track_url ? `${base}data/${activity.track_url}` : null;
|
||||||
$: color = sportColor(activity.sport);
|
$: color = sportColor(activity.sport);
|
||||||
|
|
||||||
|
function lightboxPrev() { if (lightboxIndex !== null) lightboxIndex = (lightboxIndex - 1 + galleryImages.length) % galleryImages.length; }
|
||||||
|
function lightboxNext() { if (lightboxIndex !== null) lightboxIndex = (lightboxIndex + 1) % galleryImages.length; }
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (lightboxIndex === null) return;
|
||||||
|
if (e.key === 'ArrowLeft') { e.preventDefault(); lightboxPrev(); }
|
||||||
|
if (e.key === 'ArrowRight') { e.preventDefault(); lightboxNext(); }
|
||||||
|
if (e.key === 'Escape') { lightboxIndex = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
$: rawDescription = localDescription || detail?.description || '';
|
||||||
|
$: descriptionHtml = (() => {
|
||||||
|
if (!rawDescription) return '';
|
||||||
|
const renderer = new marked.Renderer();
|
||||||
|
// Local relative images are always shown in the gallery — suppress inline rendering
|
||||||
|
renderer.image = ({ href, title, text }) => {
|
||||||
|
const isLocal = href && !href.startsWith('http') && !href.startsWith('/') && !href.startsWith('data:');
|
||||||
|
if (isLocal) return '';
|
||||||
|
const titleAttr = title ? ` title="${title}"` : '';
|
||||||
|
return `<img src="${href ?? ''}" alt="${text}"${titleAttr} class="rounded-lg max-w-full my-2">`;
|
||||||
|
};
|
||||||
|
return marked(rawDescription, { renderer }) as string;
|
||||||
|
})();
|
||||||
|
|
||||||
|
$: imageBase = `${base}data/activities/images/${activity.id}/`;
|
||||||
|
$: galleryImages = (detail?.custom as any)?.images as string[] ?? [];
|
||||||
|
|
||||||
|
|
||||||
const stat = (label: string, value: string, key?: string) => ({ label, value, key });
|
const stat = (label: string, value: string, key?: string) => ({ label, value, key });
|
||||||
$: hiddenStats = new Set<string>((detail?.custom as any)?.hide_stats ?? []);
|
$: hiddenStats = new Set<string>((detail?.custom as any)?.hide_stats ?? []);
|
||||||
$: stats = [
|
$: stats = [
|
||||||
@@ -43,6 +84,62 @@
|
|||||||
].filter(s => !s.key || !hiddenStats.has(s.key));
|
].filter(s => !s.key || !hiddenStats.has(s.key));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={onKeydown} />
|
||||||
|
|
||||||
|
{#if editOpen && editUrl}
|
||||||
|
<EditDrawer activityId={activity.id} {editUrl} on:saved={onSaved} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Lightbox -->
|
||||||
|
{#if lightboxIndex !== null}
|
||||||
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
|
||||||
|
on:click={() => lightboxIndex = null}
|
||||||
|
on:keydown={onKeydown}
|
||||||
|
>
|
||||||
|
<!-- Prev -->
|
||||||
|
{#if galleryImages.length > 1}
|
||||||
|
<button
|
||||||
|
class="absolute left-4 top-1/2 -translate-y-1/2 text-white/60 hover:text-white text-3xl px-3 py-6 transition-colors z-10"
|
||||||
|
on:click|stopPropagation={lightboxPrev}
|
||||||
|
aria-label="Previous"
|
||||||
|
>‹</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={imageBase + galleryImages[lightboxIndex]}
|
||||||
|
alt={galleryImages[lightboxIndex]}
|
||||||
|
class="max-h-[90vh] max-w-[90vw] rounded-lg shadow-2xl object-contain"
|
||||||
|
on:click|stopPropagation
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Next -->
|
||||||
|
{#if galleryImages.length > 1}
|
||||||
|
<button
|
||||||
|
class="absolute right-4 top-1/2 -translate-y-1/2 text-white/60 hover:text-white text-3xl px-3 py-6 transition-colors z-10"
|
||||||
|
on:click|stopPropagation={lightboxNext}
|
||||||
|
aria-label="Next"
|
||||||
|
>›</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Counter + filename -->
|
||||||
|
<div class="absolute bottom-6 left-1/2 -translate-x-1/2 text-white/50 text-xs text-center">
|
||||||
|
<p>{galleryImages[lightboxIndex]}</p>
|
||||||
|
{#if galleryImages.length > 1}
|
||||||
|
<p class="mt-0.5">{lightboxIndex + 1} / {galleryImages.length}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Close -->
|
||||||
|
<button
|
||||||
|
class="absolute top-4 right-5 text-white/50 hover:text-white text-2xl transition-colors"
|
||||||
|
on:click={() => lightboxIndex = null}
|
||||||
|
aria-label="Close"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-start gap-4 mb-6">
|
<div class="flex items-start gap-4 mb-6">
|
||||||
<a href={`${base}`} class="text-zinc-500 hover:text-white transition-colors mt-1 shrink-0">
|
<a href={`${base}`} class="text-zinc-500 hover:text-white transition-colors mt-1 shrink-0">
|
||||||
@@ -61,24 +158,44 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<h1 class="text-2xl font-bold text-white">{activity.title}</h1>
|
<h1 class="text-2xl font-bold text-white">{displayTitle}</h1>
|
||||||
{#if editUrl}
|
{#if editUrl}
|
||||||
<a
|
<button
|
||||||
href={`${editUrl}/edit/${activity.id}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
class="text-xs px-2 py-0.5 rounded border border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-white transition-colors shrink-0"
|
class="text-xs px-2 py-0.5 rounded border border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-white transition-colors shrink-0"
|
||||||
|
on:click={() => editOpen = true}
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</a>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if detail?.description}
|
{#if descriptionHtml}
|
||||||
<p class="text-zinc-400 mt-1 text-sm whitespace-pre-line">{detail.description}</p>
|
<div class="text-zinc-400 mt-2 text-sm leading-relaxed [&_img]:rounded-lg [&_img]:my-2 [&_p]:my-1 [&_a]:text-blue-400">
|
||||||
|
{@html descriptionHtml}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Photo gallery -->
|
||||||
|
{#if galleryImages.length}
|
||||||
|
<div class="mb-4 grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-2">
|
||||||
|
{#each galleryImages as img, i}
|
||||||
|
<button
|
||||||
|
class="relative overflow-hidden rounded-lg bg-zinc-800 aspect-square hover:opacity-90 transition-opacity focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
on:click={() => lightboxIndex = i}
|
||||||
|
aria-label="Open photo {i + 1}"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={imageBase + img}
|
||||||
|
alt={img}
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Map + Stats split -->
|
<!-- Map + Stats split -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-4 mb-4">
|
<div class="grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-4 mb-4">
|
||||||
<!-- Map -->
|
<!-- Map -->
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import type { Sport } from '../lib/types';
|
||||||
|
|
||||||
|
export let activityId: string;
|
||||||
|
export let editUrl: string;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{ saved: { title: string; description: string } }>();
|
||||||
|
|
||||||
|
const SPORTS: Sport[] = ['cycling', 'running', 'hiking', 'walking', 'swimming', 'skiing', 'other'];
|
||||||
|
const STAT_PANELS = [
|
||||||
|
{ key: 'elevation', label: 'Elevation' },
|
||||||
|
{ key: 'speed', label: 'Speed' },
|
||||||
|
{ key: 'heart_rate', label: 'Heart rate' },
|
||||||
|
{ key: 'cadence', label: 'Cadence' },
|
||||||
|
{ key: 'power', label: 'Power' },
|
||||||
|
];
|
||||||
|
|
||||||
|
let loading = true;
|
||||||
|
let loadError = '';
|
||||||
|
let saving = false;
|
||||||
|
let saveStatus = '';
|
||||||
|
let saveOk = false;
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
let title = '';
|
||||||
|
let sport: Sport = 'cycling';
|
||||||
|
let gear = '';
|
||||||
|
let description = '';
|
||||||
|
let highlight = false;
|
||||||
|
let isPrivate = false;
|
||||||
|
let hideStats: string[] = [];
|
||||||
|
let images: string[] = [];
|
||||||
|
|
||||||
|
// Image upload
|
||||||
|
let uploading = false;
|
||||||
|
let fileInput: HTMLInputElement;
|
||||||
|
|
||||||
|
const api = `${editUrl}/api/activity/${activityId}`;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
loadError = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch(api);
|
||||||
|
if (!res.ok) throw new Error(`Edit server returned ${res.status} — is bincio edit running?`);
|
||||||
|
const d = await res.json();
|
||||||
|
title = d.title ?? '';
|
||||||
|
sport = d.sport ?? 'cycling';
|
||||||
|
gear = d.gear ?? '';
|
||||||
|
description = d.description ?? '';
|
||||||
|
highlight = d.highlight ?? false;
|
||||||
|
isPrivate = d.private ?? false;
|
||||||
|
hideStats = d.hide_stats ?? [];
|
||||||
|
images = d.images ?? [];
|
||||||
|
} catch (e: any) {
|
||||||
|
loadError = e.message;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving = true;
|
||||||
|
saveStatus = '';
|
||||||
|
saveOk = false;
|
||||||
|
try {
|
||||||
|
const res = await fetch(api, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title, sport, gear, description, highlight, private: isPrivate, hide_stats: hideStats }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
saveStatus = 'Saved';
|
||||||
|
saveOk = true;
|
||||||
|
dispatch('saved', { title, description });
|
||||||
|
} catch (e: any) {
|
||||||
|
saveStatus = e.message;
|
||||||
|
saveOk = false;
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadImages(files: FileList) {
|
||||||
|
uploading = true;
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
const res = await fetch(`${api}/images`, { method: 'POST', body: fd });
|
||||||
|
if (res.ok) {
|
||||||
|
const d = await res.json();
|
||||||
|
if (!images.includes(d.filename)) images = [...images, d.filename];
|
||||||
|
// Insert markdown reference at cursor or end
|
||||||
|
const ref = `\n![${d.filename.replace(/\.[^.]+$/, '')}](${d.filename})`;
|
||||||
|
description = description.trimEnd() + ref;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uploading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteImage(filename: string) {
|
||||||
|
await fetch(`${api}/images/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||||||
|
images = images.filter(f => f !== filename);
|
||||||
|
// Remove the markdown reference too
|
||||||
|
description = description.replace(new RegExp(`!\\[[^\\]]*\\]\\(${filename}\\)`, 'g'), '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleStat(key: string) {
|
||||||
|
hideStats = hideStats.includes(key)
|
||||||
|
? hideStats.filter(s => s !== key)
|
||||||
|
: [...hideStats, key];
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-black/60 z-40 backdrop-blur-sm"
|
||||||
|
on:click={() => dispatch('saved', { title, description })}
|
||||||
|
role="presentation"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Drawer -->
|
||||||
|
<aside class="fixed top-0 right-0 h-full w-full max-w-md bg-zinc-950 border-l border-zinc-800 z-50 flex flex-col shadow-2xl">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between px-5 py-4 border-b border-zinc-800 shrink-0">
|
||||||
|
<h2 class="font-semibold text-white text-sm">Edit activity</h2>
|
||||||
|
<button
|
||||||
|
class="text-zinc-500 hover:text-white transition-colors text-xl leading-none"
|
||||||
|
on:click={() => dispatch('saved', { title, description })}
|
||||||
|
aria-label="Close"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="flex-1 overflow-y-auto px-5 py-4">
|
||||||
|
{#if loading}
|
||||||
|
<div class="space-y-3 animate-pulse">
|
||||||
|
{#each Array(4) as _}
|
||||||
|
<div class="h-9 rounded bg-zinc-800" />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if loadError}
|
||||||
|
<p class="text-red-400 text-sm">{loadError}</p>
|
||||||
|
{:else}
|
||||||
|
<!-- Title -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-xs text-zinc-500 mb-1" for="ed-title">Title</label>
|
||||||
|
<input
|
||||||
|
id="ed-title"
|
||||||
|
type="text"
|
||||||
|
bind:value={title}
|
||||||
|
class="w-full px-3 py-2 bg-zinc-900 border border-zinc-700 rounded-lg text-sm text-white outline-none focus:border-blue-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sport + Gear -->
|
||||||
|
<div class="grid grid-cols-2 gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-zinc-500 mb-1" for="ed-sport">Sport</label>
|
||||||
|
<select
|
||||||
|
id="ed-sport"
|
||||||
|
bind:value={sport}
|
||||||
|
class="w-full px-3 py-2 bg-zinc-900 border border-zinc-700 rounded-lg text-sm text-white outline-none focus:border-blue-500 transition-colors"
|
||||||
|
>
|
||||||
|
{#each SPORTS as s}
|
||||||
|
<option value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-zinc-500 mb-1" for="ed-gear">Gear</label>
|
||||||
|
<input
|
||||||
|
id="ed-gear"
|
||||||
|
type="text"
|
||||||
|
bind:value={gear}
|
||||||
|
placeholder="e.g. Trek Domane"
|
||||||
|
class="w-full px-3 py-2 bg-zinc-900 border border-zinc-700 rounded-lg text-sm text-white placeholder-zinc-600 outline-none focus:border-blue-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-xs text-zinc-500 mb-1" for="ed-desc">Description <span class="text-zinc-600">(markdown)</span></label>
|
||||||
|
<textarea
|
||||||
|
id="ed-desc"
|
||||||
|
bind:value={description}
|
||||||
|
rows={6}
|
||||||
|
placeholder="Write about this activity…"
|
||||||
|
class="w-full px-3 py-2 bg-zinc-900 border border-zinc-700 rounded-lg text-sm text-white placeholder-zinc-600 outline-none focus:border-blue-500 transition-colors resize-y"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Images -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<p class="text-xs text-zinc-500 mb-2">Images</p>
|
||||||
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
|
<div
|
||||||
|
class="border border-dashed border-zinc-700 rounded-lg px-4 py-3 text-center text-xs text-zinc-500 cursor-pointer hover:border-zinc-500 hover:text-zinc-300 transition-colors"
|
||||||
|
on:click={() => fileInput.click()}
|
||||||
|
on:dragover|preventDefault
|
||||||
|
on:drop|preventDefault={e => e.dataTransfer?.files && uploadImages(e.dataTransfer.files)}
|
||||||
|
>
|
||||||
|
{uploading ? 'Uploading…' : 'Drop images or click to upload'}
|
||||||
|
</div>
|
||||||
|
<input bind:this={fileInput} type="file" accept="image/*" multiple class="hidden"
|
||||||
|
on:change={e => e.currentTarget.files && uploadImages(e.currentTarget.files)} />
|
||||||
|
{#if images.length}
|
||||||
|
<div class="flex flex-wrap gap-2 mt-2">
|
||||||
|
{#each images as img}
|
||||||
|
<span class="flex items-center gap-1 text-xs bg-zinc-800 border border-zinc-700 rounded-full px-2 py-0.5">
|
||||||
|
{img}
|
||||||
|
<button class="text-zinc-500 hover:text-red-400 transition-colors" on:click={() => deleteImage(img)}>×</button>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hide stats -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<p class="text-xs text-zinc-500 mb-2">Hide stat panels</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each STAT_PANELS as panel}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-xs px-3 py-1 rounded-full border transition-colors"
|
||||||
|
class:border-zinc-700={!hideStats.includes(panel.key)}
|
||||||
|
class:text-zinc-400={!hideStats.includes(panel.key)}
|
||||||
|
class:border-blue-500={hideStats.includes(panel.key)}
|
||||||
|
class:text-white={hideStats.includes(panel.key)}
|
||||||
|
style={hideStats.includes(panel.key) ? 'background:rgba(59,130,246,.15)' : ''}
|
||||||
|
on:click={() => toggleStat(panel.key)}
|
||||||
|
>
|
||||||
|
{panel.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Flags -->
|
||||||
|
<div class="flex gap-3 mb-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-2 text-xs px-3 py-1.5 rounded-lg border transition-colors"
|
||||||
|
class:border-zinc-700={!highlight}
|
||||||
|
class:text-zinc-400={!highlight}
|
||||||
|
class:border-yellow-500={highlight}
|
||||||
|
class:text-yellow-300={highlight}
|
||||||
|
style={highlight ? 'background:rgba(234,179,8,.1)' : ''}
|
||||||
|
on:click={() => highlight = !highlight}
|
||||||
|
>
|
||||||
|
★ Highlight
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-2 text-xs px-3 py-1.5 rounded-lg border transition-colors"
|
||||||
|
class:border-zinc-700={!isPrivate}
|
||||||
|
class:text-zinc-400={!isPrivate}
|
||||||
|
class:border-red-500={isPrivate}
|
||||||
|
class:text-red-300={isPrivate}
|
||||||
|
style={isPrivate ? 'background:rgba(239,68,68,.1)' : ''}
|
||||||
|
on:click={() => isPrivate = !isPrivate}
|
||||||
|
>
|
||||||
|
⊘ Private
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
{#if !loading && !loadError}
|
||||||
|
<div class="px-5 py-4 border-t border-zinc-800 flex items-center gap-3 shrink-0">
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
|
disabled={saving}
|
||||||
|
on:click={save}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
{#if saveStatus}
|
||||||
|
<span class="text-xs" class:text-green-400={saveOk} class:text-red-400={!saveOk}>
|
||||||
|
{saveStatus}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</aside>
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"""Tests for bincio.render.merge — sidecar edit overlay logic."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import textwrap
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from bincio.render.merge import apply_sidecar, merge_all, parse_sidecar
|
||||||
|
|
||||||
|
|
||||||
|
# ── parse_sidecar ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_sidecar_full(tmp_path):
|
||||||
|
md = tmp_path / "act.md"
|
||||||
|
md.write_text(textwrap.dedent("""\
|
||||||
|
---
|
||||||
|
title: "Ride to the coast"
|
||||||
|
sport: cycling
|
||||||
|
highlight: true
|
||||||
|
private: false
|
||||||
|
hide_stats: [cadence, power]
|
||||||
|
gear: "Trek Domane"
|
||||||
|
---
|
||||||
|
|
||||||
|
Great day out with Marco.
|
||||||
|
"""))
|
||||||
|
fm, body = parse_sidecar(md)
|
||||||
|
assert fm["title"] == "Ride to the coast"
|
||||||
|
assert fm["sport"] == "cycling"
|
||||||
|
assert fm["highlight"] is True
|
||||||
|
assert fm["private"] is False
|
||||||
|
assert fm["hide_stats"] == ["cadence", "power"]
|
||||||
|
assert fm["gear"] == "Trek Domane"
|
||||||
|
assert body == "Great day out with Marco."
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_sidecar_no_frontmatter(tmp_path):
|
||||||
|
md = tmp_path / "act.md"
|
||||||
|
md.write_text("Just a description, no frontmatter.\n")
|
||||||
|
fm, body = parse_sidecar(md)
|
||||||
|
assert fm == {}
|
||||||
|
assert body == "Just a description, no frontmatter."
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_sidecar_frontmatter_only(tmp_path):
|
||||||
|
md = tmp_path / "act.md"
|
||||||
|
md.write_text("---\ntitle: Solo spin\n---\n")
|
||||||
|
fm, body = parse_sidecar(md)
|
||||||
|
assert fm["title"] == "Solo spin"
|
||||||
|
assert body == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ── apply_sidecar ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
BASE_DETAIL = {
|
||||||
|
"id": "2024-01-01T08:00:00Z_cycling",
|
||||||
|
"title": "Morning Ride",
|
||||||
|
"sport": "cycling",
|
||||||
|
"started_at": "2024-01-01T08:00:00Z",
|
||||||
|
"description": "Original description from Strava.",
|
||||||
|
"privacy": "public",
|
||||||
|
"gear": None,
|
||||||
|
"custom": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_sidecar_title_and_sport():
|
||||||
|
fm = {"title": "Renamed", "sport": "gravel"}
|
||||||
|
result = apply_sidecar(BASE_DETAIL, fm, "")
|
||||||
|
assert result["title"] == "Renamed"
|
||||||
|
assert result["sport"] == "gravel"
|
||||||
|
# Original must be unchanged
|
||||||
|
assert BASE_DETAIL["title"] == "Morning Ride"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_sidecar_body_becomes_description():
|
||||||
|
result = apply_sidecar(BASE_DETAIL, {}, "My **epic** ride.")
|
||||||
|
assert result["description"] == "My **epic** ride."
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_sidecar_body_takes_precedence_over_fm_description():
|
||||||
|
fm = {"description": "FM description"}
|
||||||
|
result = apply_sidecar(BASE_DETAIL, fm, "Body description")
|
||||||
|
assert result["description"] == "Body description"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_sidecar_private_flag():
|
||||||
|
result = apply_sidecar(BASE_DETAIL, {"private": True}, "")
|
||||||
|
assert result["privacy"] == "private"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_sidecar_highlight():
|
||||||
|
result = apply_sidecar(BASE_DETAIL, {"highlight": True}, "")
|
||||||
|
assert result["custom"]["highlight"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_sidecar_hide_stats():
|
||||||
|
result = apply_sidecar(BASE_DETAIL, {"hide_stats": ["cadence", "power"]}, "")
|
||||||
|
assert result["custom"]["hide_stats"] == ["cadence", "power"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_sidecar_does_not_mutate_input():
|
||||||
|
fm = {"title": "New title", "highlight": True}
|
||||||
|
original_custom = BASE_DETAIL["custom"]
|
||||||
|
apply_sidecar(BASE_DETAIL, fm, "")
|
||||||
|
assert BASE_DETAIL["title"] == "Morning Ride"
|
||||||
|
assert BASE_DETAIL["custom"] is original_custom
|
||||||
|
assert "highlight" not in original_custom
|
||||||
|
|
||||||
|
|
||||||
|
# ── merge_all ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def data_dir(tmp_path):
|
||||||
|
acts = tmp_path / "activities"
|
||||||
|
acts.mkdir()
|
||||||
|
# Two activities
|
||||||
|
for act_id, title in [
|
||||||
|
("2024-01-01T08:00:00Z_cycling", "Morning Ride"),
|
||||||
|
("2024-01-02T09:00:00Z_running", "Easy Run"),
|
||||||
|
]:
|
||||||
|
detail = {
|
||||||
|
"id": act_id, "title": title, "sport": act_id.split("_")[1],
|
||||||
|
"started_at": act_id.split("_")[0],
|
||||||
|
"description": "", "privacy": "public", "custom": {},
|
||||||
|
}
|
||||||
|
(acts / f"{act_id}.json").write_text(json.dumps(detail))
|
||||||
|
# Index
|
||||||
|
index = {"activities": [
|
||||||
|
{"id": "2024-01-01T08:00:00Z_cycling", "title": "Morning Ride",
|
||||||
|
"sport": "cycling", "started_at": "2024-01-01T08:00:00Z", "privacy": "public", "custom": {}},
|
||||||
|
{"id": "2024-01-02T09:00:00Z_running", "title": "Easy Run",
|
||||||
|
"sport": "running", "started_at": "2024-01-02T09:00:00Z", "privacy": "public", "custom": {}},
|
||||||
|
]}
|
||||||
|
(tmp_path / "index.json").write_text(json.dumps(index))
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_all_no_sidecars(data_dir):
|
||||||
|
n = merge_all(data_dir)
|
||||||
|
assert n == 0
|
||||||
|
merged = data_dir / "_merged"
|
||||||
|
assert merged.exists()
|
||||||
|
# Unmodified files are symlinked
|
||||||
|
detail_link = merged / "activities" / "2024-01-01T08:00:00Z_cycling.json"
|
||||||
|
assert detail_link.is_symlink()
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_all_applies_sidecar(data_dir):
|
||||||
|
edits = data_dir / "edits"
|
||||||
|
edits.mkdir()
|
||||||
|
(edits / "2024-01-01T08:00:00Z_cycling.md").write_text(
|
||||||
|
"---\ntitle: Epic Ride\nhighlight: true\n---\n\nWhat a day!"
|
||||||
|
)
|
||||||
|
n = merge_all(data_dir)
|
||||||
|
assert n == 1
|
||||||
|
|
||||||
|
merged_json = data_dir / "_merged" / "activities" / "2024-01-01T08:00:00Z_cycling.json"
|
||||||
|
assert not merged_json.is_symlink()
|
||||||
|
data = json.loads(merged_json.read_text())
|
||||||
|
assert data["title"] == "Epic Ride"
|
||||||
|
assert data["custom"]["highlight"] is True
|
||||||
|
assert data["description"] == "What a day!"
|
||||||
|
|
||||||
|
# Untouched activity is still a symlink
|
||||||
|
run_link = data_dir / "_merged" / "activities" / "2024-01-02T09:00:00Z_running.json"
|
||||||
|
assert run_link.is_symlink()
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_all_private_filtered_from_index(data_dir):
|
||||||
|
edits = data_dir / "edits"
|
||||||
|
edits.mkdir()
|
||||||
|
(edits / "2024-01-01T08:00:00Z_cycling.md").write_text("---\nprivate: true\n---\n")
|
||||||
|
merge_all(data_dir)
|
||||||
|
|
||||||
|
index = json.loads((data_dir / "_merged" / "index.json").read_text())
|
||||||
|
ids = [a["id"] for a in index["activities"]]
|
||||||
|
assert "2024-01-01T08:00:00Z_cycling" not in ids
|
||||||
|
assert "2024-01-02T09:00:00Z_running" in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_all_highlight_sorts_first(data_dir):
|
||||||
|
edits = data_dir / "edits"
|
||||||
|
edits.mkdir()
|
||||||
|
# Highlight the older activity — it should appear first
|
||||||
|
(edits / "2024-01-01T08:00:00Z_cycling.md").write_text("---\nhighlight: true\n---\n")
|
||||||
|
merge_all(data_dir)
|
||||||
|
|
||||||
|
index = json.loads((data_dir / "_merged" / "index.json").read_text())
|
||||||
|
ids = [a["id"] for a in index["activities"]]
|
||||||
|
assert ids[0] == "2024-01-01T08:00:00Z_cycling"
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_all_idempotent(data_dir):
|
||||||
|
edits = data_dir / "edits"
|
||||||
|
edits.mkdir()
|
||||||
|
(edits / "2024-01-01T08:00:00Z_cycling.md").write_text("---\ntitle: Renamed\n---\n")
|
||||||
|
merge_all(data_dir)
|
||||||
|
merge_all(data_dir) # second run should not error or double-apply
|
||||||
|
data = json.loads(
|
||||||
|
(data_dir / "_merged" / "activities" / "2024-01-01T08:00:00Z_cycling.json").read_text()
|
||||||
|
)
|
||||||
|
assert data["title"] == "Renamed"
|
||||||
Reference in New Issue
Block a user