gallery of photos in the activity page

This commit is contained in:
Davide Scaini
2026-03-29 15:51:39 +02:00
parent b0ab7fbe3f
commit a9839e8242
4 changed files with 125 additions and 12 deletions
+9
View File
@@ -8,6 +8,7 @@ from pathlib import Path
from typing import Any from typing import Any
from fastapi import FastAPI, File, HTTPException, UploadFile from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
# Populated by the CLI before uvicorn starts # Populated by the CLI before uvicorn starts
@@ -16,6 +17,14 @@ site_url: str = "http://localhost:4321"
app = FastAPI(title="BincioActivity Edit Server", docs_url=None, redoc_url=None) 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"] SPORTS = ["cycling", "running", "hiking", "walking", "swimming", "skiing", "other"]
STAT_PANELS = ["elevation", "speed", "heart_rate", "cadence", "power"] STAT_PANELS = ["elevation", "speed", "heart_rate", "cadence", "power"]
+26 -7
View File
@@ -90,6 +90,22 @@ def merge_all(data_dir: Path) -> int:
for md_path in sorted(edits_dir.glob("*.md")): for md_path in sorted(edits_dir.glob("*.md")):
sidecars[md_path.stem] = parse_sidecar(md_path) 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/ # Wipe and recreate _merged/activities/
if merged_acts.exists(): if merged_acts.exists():
shutil.rmtree(merged_acts) shutil.rmtree(merged_acts)
@@ -102,20 +118,23 @@ def merge_all(data_dir: Path) -> int:
continue continue
dest = merged_acts / src.name dest = merged_acts / src.name
activity_id = src.stem activity_id = src.stem
if src.suffix == ".json" and activity_id in sidecars: if src.suffix == ".json" and activity_id in to_merge:
fm, body = sidecars[activity_id]
detail = json.loads(src.read_text(encoding="utf-8")) detail = json.loads(src.read_text(encoding="utf-8"))
merged = apply_sidecar(detail, fm, body) if activity_id in sidecars:
dest.write_text(json.dumps(merged, indent=2, ensure_ascii=False)) 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: else:
dest.symlink_to(src.resolve()) dest.symlink_to(src.resolve())
# Mirror edits/images/ → _merged/activities/images/ so the site can serve them # Mirror edits/images/ → _merged/activities/images/ so the site can serve them
edits_images = edits_dir / "images" if edits_dir.exists() else None if images_root and images_root.exists():
if edits_images and edits_images.exists():
merged_images = merged_acts / "images" merged_images = merged_acts / "images"
merged_images.mkdir(exist_ok=True) 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(): if img_dir.is_dir():
dest_img = merged_images / img_dir.name dest_img = merged_images / img_dir.name
if not dest_img.exists(): if not dest_img.exists():
+1
View File
@@ -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",
+89 -5
View File
@@ -16,6 +16,7 @@
let error = ''; let error = '';
let hoveredIdx: number | null = null; let hoveredIdx: number | null = null;
let editOpen = false; let editOpen = false;
let lightboxIndex: number | null = null;
// Local overrides applied immediately after a save (no re-fetch needed) // Local overrides applied immediately after a save (no re-fetch needed)
let localTitle = ''; let localTitle = '';
@@ -42,21 +43,32 @@
$: 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 || ''; $: rawDescription = localDescription || detail?.description || '';
$: descriptionHtml = (() => { $: descriptionHtml = (() => {
if (!rawDescription) return ''; if (!rawDescription) return '';
const imageBase = `${base}data/activities/images/${activity.id}/`;
const renderer = new marked.Renderer(); const renderer = new marked.Renderer();
// Local relative images are always shown in the gallery — suppress inline rendering
renderer.image = ({ href, title, text }) => { renderer.image = ({ href, title, text }) => {
const src = href && !href.startsWith('http') && !href.startsWith('/') && !href.startsWith('data:') const isLocal = href && !href.startsWith('http') && !href.startsWith('/') && !href.startsWith('data:');
? imageBase + href if (isLocal) return '';
: href ?? '';
const titleAttr = title ? ` title="${title}"` : ''; const titleAttr = title ? ` title="${title}"` : '';
return `<img src="${src}" alt="${text}"${titleAttr} class="rounded-lg max-w-full my-2">`; return `<img src="${href ?? ''}" alt="${text}"${titleAttr} class="rounded-lg max-w-full my-2">`;
}; };
return marked(rawDescription, { renderer }) as string; 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 = [
@@ -71,10 +83,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} {#if editOpen && editUrl}
<EditDrawer activityId={activity.id} {editUrl} on:saved={onSaved} /> <EditDrawer activityId={activity.id} {editUrl} on:saved={onSaved} />
{/if} {/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">
@@ -111,6 +175,26 @@
</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 -->