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:
parent
dec639581b
commit
8e6c19e9a4
1
.gitignore
vendored
1
.gitignore
vendored
@ -6,4 +6,5 @@ __pycache__/
|
||||
static/assets/
|
||||
static/index.html
|
||||
static/next/
|
||||
photos/
|
||||
.env
|
||||
|
||||
@ -9,6 +9,7 @@ services:
|
||||
- .env
|
||||
volumes:
|
||||
- ./static:/app/static:ro
|
||||
- ./photos:/app/photos:ro
|
||||
networks:
|
||||
edge:
|
||||
external: true
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mapdash-frontend",
|
||||
"private": true,
|
||||
"version": "0.2.6",
|
||||
"version": "0.2.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@ -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 = `<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.
|
||||
function balloonBody(s: MapSurface): string {
|
||||
const busyColor = s.occupied_now ? CLUSTER_BUSY : CLUSTER_FREE;
|
||||
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(' · ');
|
||||
if (meta) rows.push(`<div class="bl-sub">${meta}</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('');
|
||||
}
|
||||
|
||||
// ---- 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).
|
||||
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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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). */
|
||||
|
||||
39
main.py
39
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/<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")
|
||||
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")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user