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/assets/
|
||||||
static/index.html
|
static/index.html
|
||||||
static/next/
|
static/next/
|
||||||
|
photos/
|
||||||
.env
|
.env
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
39
main.py
@ -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")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user