diff --git a/CLAUDE.md b/CLAUDE.md index 6d038a1..60e2517 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 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 static site and Astro are untouched — no hybrid mode, no dead-code API routes in prod. +The edit UI is a **slide-in drawer** (`EditDrawer.svelte`) in the Astro site. +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:** ``` -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) -- `GET /api/activity/{id}` — returns merged BAS JSON + existing sidecar fields -- `POST /api/activity/{id}` — writes `edits/{id}.md` to the data dir +- Edit button appears on the activity detail page **only when `PUBLIC_EDIT_URL` is set** in `site/.env` +- Clicking Edit opens the drawer in the same page — no navigation, no copy-pasting IDs +- 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 `):** +- `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}` -- 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:** -- Title text input (pre-filled from BAS JSON) -- Sport dropdown (pre-filled, shows all known sport types) -- Markdown textarea for description, with minimal toolbar (bold, italic, link, image insert) -- Live markdown preview panel -- `hide_stats` checkbox group: elevation, speed, heart_rate, cadence, power -- `highlight` toggle (feature in feed) -- `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 +**Edit drawer features:** +- Title, sport dropdown, gear +- Markdown textarea for description (images inserted as `![name](filename)` references) +- Image drag-and-drop zone with chip list + delete +- Hide stat panels (elevation, speed, heart_rate, cadence, power) — toggle buttons +- Highlight flag (★ — sorts to top of feed, visual badge) +- Private flag (⊘ — suppressed from index at render time) -**Workflow (typical):** -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 +### Image storage and serving ``` ~/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: `![Summit](col-summit.jpg)`. -The render stage resolves relative image paths against `edits/images/{id}/` and copies them -to `site/public/images/activities/{id}/` so they're served from the static site. +`merge_all()` symlinks `edits/images/{id}/` → `_merged/activities/images/{id}/` so images +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 -- **Sidecar location**: `edits/` subdirectory (not co-located with JSON) — cleaner, easier to - backup/sync just your customisations independently of the extracted data -- **`private: true`**: suppresses from `index.json` at render time (not client-side hide) — - safer for public hosting -- **`highlight`**: visual badge in feed + sorted before non-highlighted activities -- **Edit UI**: `bincio edit --serve` FastAPI server (Option B) — not integrated into Astro +- **Sidecar location**: `edits/` subdirectory — cleaner, easier to backup/sync independently +- **Merge output**: `data/_merged/` — extracted data stays pristine; `public/data` → `_merged/` +- **`private: true`**: suppressed from `index.json` at render time (not client-side hide) +- **`highlight`**: sorts to top of feed; visual badge TBD +- **Edit UI**: drawer in Astro site, `bincio edit` is a pure write API (no HTML serving) ## 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) - [ ] GitHub Actions template for auto-publish - [ ] Karoo/Garmin Connect importers beyond Strava -- [ ] `bincio.render.merge` module: walk `edits/`, parse sidecars, produce enriched data for Astro -- [ ] `bincio render --watch` incremental rebuild on sidecar changes -- [ ] Sidecar `.md` format: title, sport, description, hide_stats, highlight, private, images -- [ ] `bincio edit --serve` FastAPI server with Svelte edit UI (port 4041) -- [ ] Edit button on activity detail pages (visible when `PUBLIC_EDIT_URL` env var set) -- [ ] Image upload → `edits/images/{id}/`, render stage copies to `public/images/activities/{id}/` +- [x] `bincio.render.merge` — sidecar parser, `_merged/` output, private filter, highlight sort +- [x] `bincio edit` FastAPI write API (GET/POST activity, image upload/delete, triggers merge) +- [x] `EditDrawer.svelte` — slide-in edit UI in the Astro site (no separate HTML from server) +- [x] `PUBLIC_EDIT_URL` feature flag — unset = no edit UI, set = drawer enabled +- [x] Markdown rendering in activity description with image path rewriting +- [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) diff --git a/bincio/cli.py b/bincio/cli.py index 796c628..fe05826 100644 --- a/bincio/cli.py +++ b/bincio/cli.py @@ -13,6 +13,8 @@ def main() -> None: from bincio.extract.cli import extract # 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(render) +main.add_command(edit) diff --git a/bincio/edit/__init__.py b/bincio/edit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bincio/edit/cli.py b/bincio/edit/cli.py new file mode 100644 index 0000000..4db0d6f --- /dev/null +++ b/bincio/edit/cli.py @@ -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 /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/[/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." + ) diff --git a/bincio/edit/server.py b/bincio/edit/server.py new file mode 100644 index 0000000..1d1886d --- /dev/null +++ b/bincio/edit/server.py @@ -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 = """\ + + + + + +Edit Activity + + + +
+
+ ← Back to site +

Edit Activity

+
+

+ +
+
+

Identity

+
+
+ + +
+
+ + +
+
+
+ + +
+ +

Description

+
+ + +
+ +

Display

+
+ +
+ __STAT_CHECKBOXES__ +
+
+
+ +
+ + +
+
+ +

Images

+
+ +
+ Drop images here or click to upload + +
+
+
+ +
+ + +
+
+
+
+ + + + +""" + +# ── 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'' for s in SPORTS + ) + stat_cbs = "\n".join( + f'' + 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}) diff --git a/bincio/render/cli.py b/bincio/render/cli.py index 49cbb87..322a500 100644 --- a/bincio/render/cli.py +++ b/bincio/render/cli.py @@ -61,11 +61,23 @@ def _ensure_npm(site: Path) -> None: 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: - """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" if public_data.is_symlink(): - if public_data.resolve() == data: + if public_data.resolve() == target.resolve(): return # already correct public_data.unlink() 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." ) return - public_data.symlink_to(data) - console.print(f"Linked data: [cyan]{data}[/cyan] → [cyan]{public_data}[/cyan]") + public_data.symlink_to(target) + console.print(f"Linked data: [cyan]{target}[/cyan] → [cyan]{public_data}[/cyan]") @click.command() @@ -108,6 +120,7 @@ def render( console.print(f"Data: [cyan]{data}[/cyan]") _ensure_npm(site) + _merge_edits(data) _link_data(site, data) env = {**os.environ, "BINCIO_DATA_DIR": str(data)} diff --git a/bincio/render/merge.py b/bincio/render/merge.py new file mode 100644 index 0000000..ece0a62 --- /dev/null +++ b/bincio/render/merge.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index f6f8893..626df86 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,11 @@ dependencies = [ classifier = [ "scikit-learn>=1.5", ] +edit = [ + "fastapi>=0.110", + "uvicorn[standard]>=0.29", + "python-multipart>=0.0.9", +] dev = [ "pytest>=9.0", "pytest-cov>=5.0", diff --git a/site/.env b/site/.env new file mode 100644 index 0000000..bb084dc --- /dev/null +++ b/site/.env @@ -0,0 +1,2 @@ +BINCIO_DATA_DIR=/tmp/bincio_test +PUBLIC_EDIT_URL=http://localhost:4041 diff --git a/site/astro.config.mjs b/site/astro.config.mjs index 8190b24..94b8c2e 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -4,6 +4,7 @@ import tailwind from "@astrojs/tailwind"; export default defineConfig({ integrations: [svelte(), tailwind()], + devToolbar: { enabled: false }, output: "static", // When hosting at a subdirectory (e.g. GitHub Pages project site), set: // base: "/repo-name", diff --git a/site/package.json b/site/package.json index c0f6980..6aa64c2 100644 --- a/site/package.json +++ b/site/package.json @@ -12,9 +12,10 @@ "dependencies": { "@astrojs/svelte": "^7.0.0", "@astrojs/tailwind": "^5.1.0", + "@observablehq/plot": "^0.6.0", "astro": "^5.0.0", "maplibre-gl": "^5.0.0", - "@observablehq/plot": "^0.6.0", + "marked": "^17.0.5", "svelte": "^5.0.0", "tailwindcss": "^3.4.0" }, diff --git a/site/src/components/ActivityDetail.svelte b/site/src/components/ActivityDetail.svelte index f4cf9d6..79787b6 100644 --- a/site/src/components/ActivityDetail.svelte +++ b/site/src/components/ActivityDetail.svelte @@ -1,9 +1,11 @@ + + +{#if editOpen && editUrl} + +{/if} + + +{#if lightboxIndex !== null} + +
lightboxIndex = null} + on:keydown={onKeydown} + > + + {#if galleryImages.length > 1} + + {/if} + + {galleryImages[lightboxIndex]} + + + {#if galleryImages.length > 1} + + {/if} + + +
+

{galleryImages[lightboxIndex]}

+ {#if galleryImages.length > 1} +

{lightboxIndex + 1} / {galleryImages.length}

+ {/if} +
+ + + +
+{/if} + - {#if detail?.description} -

{detail.description}

+ {#if descriptionHtml} +
+ {@html descriptionHtml} +
{/if} + +{#if galleryImages.length} +
+ {#each galleryImages as img, i} + + {/each} +
+{/if} +
diff --git a/site/src/components/EditDrawer.svelte b/site/src/components/EditDrawer.svelte new file mode 100644 index 0000000..259f83a --- /dev/null +++ b/site/src/components/EditDrawer.svelte @@ -0,0 +1,291 @@ + + + +
dispatch('saved', { title, description })} + role="presentation" +/> + + +