diff --git a/bincio/edit/server.py b/bincio/edit/server.py index 452410f..1d1886d 100644 --- a/bincio/edit/server.py +++ b/bincio/edit/server.py @@ -8,6 +8,7 @@ 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 @@ -16,6 +17,14 @@ 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"] diff --git a/bincio/render/merge.py b/bincio/render/merge.py index 2d8b99d..ece0a62 100644 --- a/bincio/render/merge.py +++ b/bincio/render/merge.py @@ -90,6 +90,22 @@ def merge_all(data_dir: Path) -> int: 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) @@ -102,20 +118,23 @@ def merge_all(data_dir: Path) -> int: continue dest = merged_acts / src.name activity_id = src.stem - if src.suffix == ".json" and activity_id in sidecars: - fm, body = sidecars[activity_id] + if src.suffix == ".json" and activity_id in to_merge: detail = json.loads(src.read_text(encoding="utf-8")) - merged = apply_sidecar(detail, fm, body) - dest.write_text(json.dumps(merged, indent=2, ensure_ascii=False)) + 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 - edits_images = edits_dir / "images" if edits_dir.exists() else None - if edits_images and edits_images.exists(): + if images_root and images_root.exists(): merged_images = merged_acts / "images" merged_images.mkdir(exist_ok=True) - for img_dir in edits_images.iterdir(): + for img_dir in images_root.iterdir(): if img_dir.is_dir(): dest_img = merged_images / img_dir.name if not dest_img.exists(): 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/src/components/ActivityDetail.svelte b/site/src/components/ActivityDetail.svelte index 40eecc5..a9e1514 100644 --- a/site/src/components/ActivityDetail.svelte +++ b/site/src/components/ActivityDetail.svelte @@ -16,6 +16,7 @@ let error = ''; 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 = ''; @@ -42,21 +43,32 @@ $: trackUrl = activity.track_url ? `${base}data/${activity.track_url}` : null; $: 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 imageBase = `${base}data/activities/images/${activity.id}/`; const renderer = new marked.Renderer(); + // Local relative images are always shown in the gallery — suppress inline rendering renderer.image = ({ href, title, text }) => { - const src = href && !href.startsWith('http') && !href.startsWith('/') && !href.startsWith('data:') - ? imageBase + href - : href ?? ''; + const isLocal = href && !href.startsWith('http') && !href.startsWith('/') && !href.startsWith('data:'); + if (isLocal) return ''; const titleAttr = title ? ` title="${title}"` : ''; - return ``; + return ``; }; 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 }); $: hiddenStats = new Set((detail?.custom as any)?.hide_stats ?? []); $: stats = [ @@ -71,10 +83,62 @@ ].filter(s => !s.key || !hiddenStats.has(s.key)); + + {#if editOpen && editUrl} {/if} + +{#if lightboxIndex !== null} + + lightboxIndex = null} + on:keydown={onKeydown} + > + + {#if galleryImages.length > 1} + ‹ + {/if} + + + + + {#if galleryImages.length > 1} + › + {/if} + + + + {galleryImages[lightboxIndex]} + {#if galleryImages.length > 1} + {lightboxIndex + 1} / {galleryImages.length} + {/if} + + + + lightboxIndex = null} + aria-label="Close" + >× + +{/if} + @@ -111,6 +175,26 @@ + +{#if galleryImages.length} + + {#each galleryImages as img, i} + lightboxIndex = i} + aria-label="Open photo {i + 1}" + > + + + {/each} + +{/if} +
{galleryImages[lightboxIndex]}
{lightboxIndex + 1} / {galleryImages.length}