feat: surface photos in the map balloon + fullscreen lightbox (v0.2.7)

Photos live on disk as photos/<board_key>/<file>.{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 <noreply@anthropic.com>
This commit is contained in:
aaverbitskiy 2026-08-15 08:00:10 +00:00
parent dec639581b
commit 8e6c19e9a4
7 changed files with 177 additions and 1 deletions

1
.gitignore vendored
View File

@ -6,4 +6,5 @@ __pycache__/
static/assets/ static/assets/
static/index.html static/index.html
static/next/ static/next/
photos/
.env .env

View File

@ -9,6 +9,7 @@ services:
- .env - .env
volumes: volumes:
- ./static:/app/static:ro - ./static:/app/static:ro
- ./photos:/app/photos:ro
networks: networks:
edge: edge:
external: true external: true

View File

@ -1,7 +1,7 @@
{ {
"name": "mapdash-frontend", "name": "mapdash-frontend",
"private": true, "private": true,
"version": "0.2.6", "version": "0.2.7",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@ -70,10 +70,38 @@ function fmtD(iso: string): string {
return p.length === 3 ? `${p[2]}.${p[1]}.${p[0]}` : iso; 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 = `<div class="bl-photos" data-photos="${data}">`;
h += `<div class="bl-photo" data-idx="0" data-lb="main">`;
h += `<img src="${escapeHtml(ph[0]!)}" loading="lazy" alt="" />`;
if (n > 1) h += `<span class="bl-count">1 / ${n}</span>`;
h += `<span class="bl-exp" aria-hidden="true">⛶</span></div>`;
if (n > 1) {
h += '<div class="bl-thumbs">';
const shown = Math.min(n, MAX_THUMBS);
for (let i = 0; i < shown; i++) {
h += `<button type="button" class="bl-thumb${i === 0 ? ' bl-thumb-act' : ''}" data-th="${i}"><img src="${escapeHtml(ph[i]!)}" loading="lazy" alt="" /></button>`;
}
if (n > MAX_THUMBS) h += `<button type="button" class="bl-more" data-lb="more">+${n - MAX_THUMBS}</button>`;
h += '</div>';
}
return h + '</div>';
}
// Balloon (click) — structured card matching the timeline tooltip style. // Balloon (click) — structured card matching the timeline tooltip style.
function balloonBody(s: MapSurface): string { function balloonBody(s: MapSurface): string {
const busyColor = s.occupied_now ? CLUSTER_BUSY : CLUSTER_FREE; const busyColor = s.occupied_now ? CLUSTER_BUSY : CLUSTER_FREE;
const rows: string[] = ['<div class="bl">']; const rows: string[] = ['<div class="bl">'];
const gallery = photoGallery(s);
if (gallery) rows.push(gallery);
const meta = [s.dimension, s.board_type].filter(Boolean).map(escapeHtml).join(' · '); const meta = [s.dimension, s.board_type].filter(Boolean).map(escapeHtml).join(' · ');
if (meta) rows.push(`<div class="bl-sub">${meta}</div>`); if (meta) rows.push(`<div class="bl-sub">${meta}</div>`);
rows.push(`<div class="bl-sub">${escapeHtml(s.city || '')}, ${escapeHtml(s.address || '')}</div>`); rows.push(`<div class="bl-sub">${escapeHtml(s.city || '')}, ${escapeHtml(s.address || '')}</div>`);
@ -94,6 +122,88 @@ function balloonBody(s: MapSurface): string {
return rows.join(''); 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 =
'<button type="button" class="lb-close" aria-label="Закрыть">✕</button>' +
'<button type="button" class="lb-prev" aria-label="Предыдущее"></button>' +
'<img class="lb-img" alt="" />' +
'<button type="button" class="lb-next" aria-label="Следующее"></button>' +
'<div class="lb-count"></div>';
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). // Hover tooltip for a single marker: code, size, address (+ brand/company if booked).
function singleTip(s: MapSurface): string { function singleTip(s: MapSurface): string {
const parts: string[] = []; const parts: string[] = [];
@ -134,6 +244,7 @@ function clusterTip(list: MapSurface[]): string {
} }
export function createMapView(els: MapElements, apiKey: string): MapView { export function createMapView(els: MapElements, apiKey: string): MapView {
setupBalloonPhotos();
let ymaps: any = null; let ymaps: any = null;
let map: any = null; let map: any = null;
let objectManager: any = null; let objectManager: any = null;

View File

@ -429,6 +429,28 @@ html.role-pending .manager-only { display: none !important; }
.bl-dates { color: var(--text-muted); } .bl-dates { color: var(--text-muted); }
.bl-link { color: var(--accent); text-decoration: none; font-weight: 600; } .bl-link { color: var(--accent); text-decoration: none; font-weight: 600; }
.bl-link:hover { text-decoration: underline; } .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; } #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. */ /* Custom hover tooltip for map markers and clusters. */
.map-tip { .map-tip {

View File

@ -66,6 +66,8 @@ export interface MapSurface {
lon: number | null; lon: number | null;
occupied_now: boolean; occupied_now: boolean;
bookings: MapBooking[]; 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). */ /** task_status value that marks an archived booking (painted slate #708090). */

39
main.py
View File

@ -1,5 +1,7 @@
import asyncio import asyncio
import os import os
import time
from urllib.parse import quote
import httpx import httpx
from fastapi import Depends, FastAPI, Header, Query from fastapi import Depends, FastAPI, Header, Query
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
@ -305,6 +307,39 @@ async def bookings(role: str = Depends(get_role), city: str = "", dimension: str
return rows return rows
# Surface photos live on disk as photos/<board_key>/<file>.{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") @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 = ""): async def map_data(role: str = Depends(get_role), city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = ""):
if role != "manager": 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) by_key.setdefault(b["board_key"], []).append(b)
anon = role != "manager" anon = role != "manager"
photos = photo_index()
out = [] out = []
for s in surfaces: for s in surfaces:
active = by_key.get(s["board_key"], []) 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 # Truncated tier keeps the free/occupied marker colour but hides the
# balloon's booking details (company/brand/status). # balloon's booking details (company/brand/status).
"bookings": [] if anon else active, "bookings": [] if anon else active,
# Surface photos are public inventory — shown to everyone.
"photos": photos.get(str(s["board_key"]), []),
}) })
return out return out
app.mount("/photos", StaticFiles(directory=PHOTOS_DIR, check_dir=False), name="photos")
app.mount("/", StaticFiles(directory="static", html=True), name="static") app.mount("/", StaticFiles(directory="static", html=True), name="static")