From 8e6c19e9a4430ea50534f106f874b3dfbf9cc775 Mon Sep 17 00:00:00 2001 From: aaverbitskiy Date: Sat, 15 Aug 2026 08:00:10 +0000 Subject: [PATCH] feat: surface photos in the map balloon + fullscreen lightbox (v0.2.7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Photos live on disk as photos//.{jpg,jpeg,png} (a read-only volume). A cached directory index (60s TTL) attaches a `photos` URL list to each surface in /api/map; a /photos StaticFiles mount serves the files. Photos are public — shown to anonymous visitors and managers alike. The balloon renders a gallery (main image + thumbnail strip + "1 / N" counter); clicking a thumbnail swaps the main image, and clicking the main image / "+N" opens a fullscreen lightbox over the map (prev/next, Esc, backdrop close). Surfaces without photos render exactly as before. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 + docker-compose.yml | 1 + frontend/package.json | 2 +- frontend/src/map.ts | 111 ++++++++++++++++++++++++++++++++++++++++ frontend/src/styles.css | 22 ++++++++ frontend/src/types.ts | 2 + main.py | 39 ++++++++++++++ 7 files changed, 177 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f59a7ef..b3fbbe1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ __pycache__/ static/assets/ static/index.html static/next/ +photos/ .env diff --git a/docker-compose.yml b/docker-compose.yml index 07f2e89..1614fe5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,7 @@ services: - .env volumes: - ./static:/app/static:ro + - ./photos:/app/photos:ro networks: edge: external: true diff --git a/frontend/package.json b/frontend/package.json index 4196007..1772a48 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mapdash-frontend", "private": true, - "version": "0.2.6", + "version": "0.2.7", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/map.ts b/frontend/src/map.ts index fb68d7b..a653749 100644 --- a/frontend/src/map.ts +++ b/frontend/src/map.ts @@ -70,10 +70,38 @@ function fmtD(iso: string): string { return p.length === 3 ? `${p[2]}.${p[1]}.${p[0]}` : iso; } +// Photo gallery block for the balloon: main image + thumbnail strip (up to +// MAX_THUMBS, then a "+N" chip). Clicks are handled by delegation (setupBalloonPhotos): +// a thumb swaps the main image, the main image / "+N" opens the lightbox. +const MAX_THUMBS = 4; +function photoGallery(s: MapSurface): string { + const ph = s.photos || []; + if (!ph.length) return ''; + const data = escapeHtml(ph.join('|')); + const n = ph.length; + let h = `
`; + h += `
`; + h += ``; + if (n > 1) h += `1 / ${n}`; + h += `
`; + if (n > 1) { + h += '
'; + const shown = Math.min(n, MAX_THUMBS); + for (let i = 0; i < shown; i++) { + h += ``; + } + if (n > MAX_THUMBS) h += ``; + h += '
'; + } + return h + '
'; +} + // Balloon (click) — structured card matching the timeline tooltip style. function balloonBody(s: MapSurface): string { const busyColor = s.occupied_now ? CLUSTER_BUSY : CLUSTER_FREE; const rows: string[] = ['
']; + const gallery = photoGallery(s); + if (gallery) rows.push(gallery); const meta = [s.dimension, s.board_type].filter(Boolean).map(escapeHtml).join(' · '); if (meta) rows.push(`
${meta}
`); rows.push(`
${escapeHtml(s.city || '')}, ${escapeHtml(s.address || '')}
`); @@ -94,6 +122,88 @@ function balloonBody(s: MapSurface): string { return rows.join(''); } +// ---- photo lightbox (fullscreen overlay above the map) -------------------- +interface Lightbox { overlay: HTMLElement; img: HTMLImageElement; cnt: HTMLElement; prev: HTMLElement; next: HTMLElement; photos: string[]; idx: number; } +let lb: Lightbox | null = null; + +function lbShow(i: number): void { + if (!lb || !lb.photos.length) return; + const n = lb.photos.length; + lb.idx = (i % n + n) % n; + lb.img.src = lb.photos[lb.idx]!; + lb.cnt.textContent = n > 1 ? `${lb.idx + 1} / ${n}` : ''; + lb.prev.style.display = lb.next.style.display = n > 1 ? '' : 'none'; +} + +function ensureLightbox(): Lightbox { + if (lb) return lb; + const overlay = document.createElement('div'); + overlay.className = 'lb-overlay'; + overlay.innerHTML = + '' + + '' + + '' + + '' + + '
'; + document.body.appendChild(overlay); + lb = { + overlay, + img: overlay.querySelector('.lb-img') as HTMLImageElement, + cnt: overlay.querySelector('.lb-count') as HTMLElement, + prev: overlay.querySelector('.lb-prev') as HTMLElement, + next: overlay.querySelector('.lb-next') as HTMLElement, + photos: [], + idx: 0, + }; + const close = (): void => overlay.classList.remove('open'); + overlay.addEventListener('click', (e) => { if (e.target === overlay || e.target === lb!.img) close(); }); + overlay.querySelector('.lb-close')!.addEventListener('click', close); + lb.prev.addEventListener('click', (e) => { e.stopPropagation(); lbShow(lb!.idx - 1); }); + lb.next.addEventListener('click', (e) => { e.stopPropagation(); lbShow(lb!.idx + 1); }); + document.addEventListener('keydown', (e) => { + if (!overlay.classList.contains('open')) return; + if (e.key === 'Escape') close(); + else if (e.key === 'ArrowLeft') lbShow(lb!.idx - 1); + else if (e.key === 'ArrowRight') lbShow(lb!.idx + 1); + }); + return lb; +} + +function openLightbox(photos: string[], idx: number): void { + const l = ensureLightbox(); + l.photos = photos; + l.overlay.classList.add('open'); + lbShow(idx); +} + +// Delegated handling of balloon photo clicks (attached once). Thumbnails swap +// the main image; the main image and the "+N" chip open the lightbox. +let balloonPhotosBound = false; +function setupBalloonPhotos(): void { + if (balloonPhotosBound) return; + balloonPhotosBound = true; + document.addEventListener('click', (e) => { + const t = e.target as HTMLElement; + const wrap = t.closest('.bl-photos') as HTMLElement | null; + if (!wrap) return; + const photos = (wrap.dataset.photos || '').split('|').filter(Boolean); + if (!photos.length) return; + const main = wrap.querySelector('.bl-photo') as HTMLElement; + const thumb = t.closest('.bl-thumb') as HTMLElement | null; + if (thumb) { + const i = Number(thumb.dataset.th || 0); + (main.querySelector('img') as HTMLImageElement).src = photos[i]!; + main.dataset.idx = String(i); + const cnt = main.querySelector('.bl-count'); + if (cnt) cnt.textContent = `${i + 1} / ${photos.length}`; + wrap.querySelectorAll('.bl-thumb').forEach((el) => el.classList.toggle('bl-thumb-act', el === thumb)); + return; + } + if (t.closest('.bl-photo')) { openLightbox(photos, Number(main.dataset.idx || 0)); return; } + if (t.closest('.bl-more')) { openLightbox(photos, MAX_THUMBS); } + }); +} + // Hover tooltip for a single marker: code, size, address (+ brand/company if booked). function singleTip(s: MapSurface): string { const parts: string[] = []; @@ -134,6 +244,7 @@ function clusterTip(list: MapSurface[]): string { } export function createMapView(els: MapElements, apiKey: string): MapView { + setupBalloonPhotos(); let ymaps: any = null; let map: any = null; let objectManager: any = null; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index ca993f1..76992b4 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -429,6 +429,28 @@ html.role-pending .manager-only { display: none !important; } .bl-dates { color: var(--text-muted); } .bl-link { color: var(--accent); text-decoration: none; font-weight: 600; } .bl-link:hover { text-decoration: underline; } +/* Balloon photo gallery: main image + thumbnail strip; opens the lightbox. */ +.bl-photos { margin: 2px 0 9px; } +.bl-photo { position: relative; height: 168px; border-radius: 8px; overflow: hidden; background: var(--surface-2); cursor: zoom-in; } +.bl-photo img { width: 100%; height: 100%; object-fit: cover; display: block; } +.bl-count { position: absolute; top: 8px; right: 8px; background: rgba(17,20,26,.72); color: #fff; font-size: 11px; padding: 2px 8px; border-radius: 20px; } +.bl-exp { position: absolute; bottom: 8px; right: 8px; background: rgba(17,20,26,.72); color: #fff; width: 26px; height: 26px; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 14px; } +.bl-thumbs { display: flex; gap: 6px; margin-top: 6px; } +.bl-thumb { width: 62px; height: 42px; padding: 0; border-radius: 6px; overflow: hidden; cursor: pointer; border: 2px solid transparent; background: var(--surface-2); } +.bl-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; } +.bl-thumb-act { border-color: var(--accent); } +.bl-more { width: 62px; height: 42px; border-radius: 6px; border: none; background: var(--surface-2); color: var(--text-muted); font-size: 13px; cursor: pointer; } +/* Fullscreen photo lightbox above the map. */ +.lb-overlay { position: fixed; inset: 0; z-index: 10000; background: rgba(0,0,0,.85); display: none; align-items: center; justify-content: center; } +.lb-overlay.open { display: flex; } +.lb-img { max-width: 92vw; max-height: 88vh; object-fit: contain; border-radius: 4px; } +.lb-close, .lb-prev, .lb-next { position: absolute; background: rgba(255,255,255,.12); color: #fff; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; border-radius: 8px; } +.lb-close:hover, .lb-prev:hover, .lb-next:hover { background: rgba(255,255,255,.24); } +.lb-close { top: 18px; right: 20px; width: 40px; height: 40px; font-size: 20px; } +.lb-prev, .lb-next { top: 50%; transform: translateY(-50%); width: 46px; height: 66px; font-size: 30px; } +.lb-prev { left: 18px; } +.lb-next { right: 18px; } +.lb-count { position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); color: #fff; font-size: 13px; background: rgba(0,0,0,.4); padding: 3px 12px; border-radius: 20px; } #map-empty.empty-state { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 5; } /* Custom hover tooltip for map markers and clusters. */ .map-tip { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d7b25f2..2ce4357 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -66,6 +66,8 @@ export interface MapSurface { lon: number | null; occupied_now: boolean; bookings: MapBooking[]; + /** URLs of this surface's photos (empty when none). Shown to everyone. */ + photos: string[]; } /** task_status value that marks an archived booking (painted slate #708090). */ diff --git a/main.py b/main.py index 4e51328..f879954 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,7 @@ import asyncio import os +import time +from urllib.parse import quote import httpx from fastapi import Depends, FastAPI, Header, Query from fastapi.responses import FileResponse @@ -305,6 +307,39 @@ async def bookings(role: str = Depends(get_role), city: str = "", dimension: str return rows +# Surface photos live on disk as photos//.{jpg,jpeg,png}. The +# directory listing is cached briefly so /api/map doesn't stat the tree on every +# request; new photos appear within PHOTO_TTL seconds. +PHOTOS_DIR = "photos" +PHOTO_EXT = (".jpg", ".jpeg", ".png") +PHOTO_TTL = 60.0 +_photo_index: dict[str, list[str]] = {} +_photo_index_ts = 0.0 + + +def photo_index() -> dict[str, list[str]]: + global _photo_index, _photo_index_ts + now = time.monotonic() + if _photo_index_ts and now - _photo_index_ts < PHOTO_TTL: + return _photo_index + idx: dict[str, list[str]] = {} + try: + for key in os.listdir(PHOTOS_DIR): + sub = os.path.join(PHOTOS_DIR, key) + if not os.path.isdir(sub): + continue + files = sorted( + f for f in os.listdir(sub) + if f.lower().endswith(PHOTO_EXT) and os.path.isfile(os.path.join(sub, f)) + ) + if files: + idx[key] = [f"/photos/{quote(key)}/{quote(f)}" for f in files] + except FileNotFoundError: + pass + _photo_index, _photo_index_ts = idx, now + return _photo_index + + @app.get("/api/map") async def map_data(role: str = Depends(get_role), city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = ""): if role != "manager": @@ -361,6 +396,7 @@ async def map_data(role: str = Depends(get_role), city: str = "", dimension: str by_key.setdefault(b["board_key"], []).append(b) anon = role != "manager" + photos = photo_index() out = [] for s in surfaces: active = by_key.get(s["board_key"], []) @@ -370,8 +406,11 @@ async def map_data(role: str = Depends(get_role), city: str = "", dimension: str # Truncated tier keeps the free/occupied marker colour but hides the # balloon's booking details (company/brand/status). "bookings": [] if anon else active, + # Surface photos are public inventory — shown to everyone. + "photos": photos.get(str(s["board_key"]), []), }) return out +app.mount("/photos", StaticFiles(directory=PHOTOS_DIR, check_dir=False), name="photos") app.mount("/", StaticFiles(directory="static", html=True), name="static")