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 = `
'];
+ 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")