feat(ui): green map pin for applied flats, hide map reject after apply, lightbox image viewer
Map: replace Leaflet's default marker with a divIcon SVG pin coloured per state — green when the user has already successfully applied (status.chip === "ok"), brand-blue otherwise. Same condition also hides the action buttons in the popup, matching the list view, which already hid both Bewerben and Ablehnen on success — so the only remaining action on an applied flat is opening the original ad link. Image gallery: clicks now open a global lightbox modal instead of a new tab. The viewer fits each image into the viewport via max-width/height + object-fit: contain (uniform sizing regardless of source aspect), shows × top-right, prev/next arrows on the sides, ←/→/Esc keyboard nav, and click-on-backdrop to close. Prev arrow is hidden on the first image and next on the last. Tile changes from <a target="_blank"> to <button> since the new-tab fallback is no longer wanted. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fe43a402d8
commit
787f848aba
5 changed files with 173 additions and 21 deletions
|
|
@ -128,11 +128,48 @@ body:has(#v_map:checked) .view-map { display: block; }
|
||||||
gap: 8px; }
|
gap: 8px; }
|
||||||
.flat-gallery-tile { aspect-ratio: 4 / 3; overflow: hidden;
|
.flat-gallery-tile { aspect-ratio: 4 / 3; overflow: hidden;
|
||||||
border-radius: 8px; border: 1px solid var(--border);
|
border-radius: 8px; border: 1px solid var(--border);
|
||||||
background: #f0f5fa; display: block; }
|
background: #f0f5fa; display: block;
|
||||||
|
padding: 0; cursor: zoom-in; }
|
||||||
.flat-gallery-tile img { width: 100%; height: 100%; object-fit: cover;
|
.flat-gallery-tile img { width: 100%; height: 100%; object-fit: cover;
|
||||||
display: block; transition: transform .3s; }
|
display: block; transition: transform .3s; }
|
||||||
.flat-gallery-tile:hover img { transform: scale(1.04); }
|
.flat-gallery-tile:hover img { transform: scale(1.04); }
|
||||||
|
|
||||||
|
/* Image lightbox — full-viewport overlay with a centered, uniformly sized
|
||||||
|
image. Prev arrow is hidden on the first image; next arrow on the last. */
|
||||||
|
.lightbox { position: fixed; inset: 0; z-index: 1000;
|
||||||
|
background: rgba(8, 18, 32, .92);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
animation: lightbox-fade .15s ease-out; }
|
||||||
|
.lightbox.hidden { display: none; }
|
||||||
|
.lightbox-image { max-width: 92vw; max-height: 88vh; object-fit: contain;
|
||||||
|
border-radius: 8px; box-shadow: 0 10px 40px rgba(0,0,0,.5);
|
||||||
|
background: #0c1726; }
|
||||||
|
.lightbox button { background: rgba(255,255,255,.08); color: #fff; border: 0;
|
||||||
|
border-radius: 9999px; cursor: pointer;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
transition: background .15s, transform .05s; padding: 0; }
|
||||||
|
.lightbox button:hover { background: rgba(255,255,255,.22); }
|
||||||
|
.lightbox button:active { transform: scale(.96); }
|
||||||
|
.lightbox-close { position: absolute; top: 1.25rem; right: 1.25rem;
|
||||||
|
width: 2.5rem; height: 2.5rem; }
|
||||||
|
.lightbox-prev, .lightbox-next { position: absolute; top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 3rem; height: 3rem; }
|
||||||
|
.lightbox-prev:active, .lightbox-next:active { transform: translateY(-50%) scale(.96); }
|
||||||
|
.lightbox-prev { left: 1.25rem; }
|
||||||
|
.lightbox-next { right: 1.25rem; }
|
||||||
|
.lightbox-prev[hidden], .lightbox-next[hidden] { display: none; }
|
||||||
|
.lightbox-counter { position: absolute; bottom: 1.25rem; left: 50%;
|
||||||
|
transform: translateX(-50%); color: rgba(255,255,255,.7);
|
||||||
|
font-size: .85rem; font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: .02em; pointer-events: none; }
|
||||||
|
body.lightbox-open { overflow: hidden; }
|
||||||
|
@keyframes lightbox-fade { from { opacity: 0 } to { opacity: 1 } }
|
||||||
|
|
||||||
|
/* Map pin — divIcon default class has a white box; clear it so the SVG sits clean. */
|
||||||
|
.lazyflat-pin { background: transparent; border: 0; }
|
||||||
|
.lazyflat-pin svg { display: block; filter: drop-shadow(0 1px 2px rgba(0,0,0,.25)); }
|
||||||
|
|
||||||
/* Leaflet popup — match site visual */
|
/* Leaflet popup — match site visual */
|
||||||
.leaflet-popup-content-wrapper { border-radius: 12px; box-shadow: 0 6px 20px rgba(16,37,63,.15); }
|
.leaflet-popup-content-wrapper { border-radius: 12px; box-shadow: 0 6px 20px rgba(16,37,63,.15); }
|
||||||
.leaflet-popup-content { margin: 12px 14px; min-width: 220px; color: var(--text); }
|
.leaflet-popup-content { margin: 12px 14px; min-width: 220px; color: var(--text); }
|
||||||
|
|
|
||||||
|
|
@ -128,3 +128,76 @@ function openDeepLinkedFlat() {
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", openDeepLinkedFlat);
|
document.addEventListener("DOMContentLoaded", openDeepLinkedFlat);
|
||||||
|
|
||||||
|
// Image lightbox — single global modal in base.html, opened by clicking any
|
||||||
|
// .flat-gallery-tile. Click handler is delegated so it survives HTMX swaps.
|
||||||
|
(function () {
|
||||||
|
const overlay = document.getElementById("lazyflat-lightbox");
|
||||||
|
if (!overlay) return;
|
||||||
|
const imgEl = overlay.querySelector(".lightbox-image");
|
||||||
|
const counterEl = overlay.querySelector(".lightbox-counter");
|
||||||
|
const prevBtn = overlay.querySelector("[data-lightbox-prev]");
|
||||||
|
const nextBtn = overlay.querySelector("[data-lightbox-next]");
|
||||||
|
const closeBtn = overlay.querySelector("[data-lightbox-close]");
|
||||||
|
let urls = [];
|
||||||
|
let idx = 0;
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
imgEl.src = urls[idx];
|
||||||
|
imgEl.alt = `Foto ${idx + 1} von ${urls.length}`;
|
||||||
|
counterEl.textContent = urls.length > 1 ? `${idx + 1} / ${urls.length}` : "";
|
||||||
|
prevBtn.hidden = idx <= 0;
|
||||||
|
nextBtn.hidden = idx >= urls.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(list, startIdx) {
|
||||||
|
if (!list.length) return;
|
||||||
|
urls = list;
|
||||||
|
idx = Math.max(0, Math.min(startIdx | 0, urls.length - 1));
|
||||||
|
overlay.classList.remove("hidden");
|
||||||
|
overlay.setAttribute("aria-hidden", "false");
|
||||||
|
document.body.classList.add("lightbox-open");
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
overlay.classList.add("hidden");
|
||||||
|
overlay.setAttribute("aria-hidden", "true");
|
||||||
|
document.body.classList.remove("lightbox-open");
|
||||||
|
imgEl.removeAttribute("src");
|
||||||
|
urls = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function step(delta) {
|
||||||
|
const next = idx + delta;
|
||||||
|
if (next < 0 || next >= urls.length) return;
|
||||||
|
idx = next;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
prevBtn.addEventListener("click", () => step(-1));
|
||||||
|
nextBtn.addEventListener("click", () => step(1));
|
||||||
|
closeBtn.addEventListener("click", close);
|
||||||
|
overlay.addEventListener("click", (ev) => {
|
||||||
|
if (ev.target === overlay) close();
|
||||||
|
});
|
||||||
|
document.addEventListener("keydown", (ev) => {
|
||||||
|
if (overlay.classList.contains("hidden")) return;
|
||||||
|
if (ev.key === "Escape") close();
|
||||||
|
else if (ev.key === "ArrowLeft") step(-1);
|
||||||
|
else if (ev.key === "ArrowRight") step(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", (ev) => {
|
||||||
|
const tile = ev.target.closest(".flat-gallery-tile");
|
||||||
|
if (!tile) return;
|
||||||
|
const gallery = tile.closest(".flat-gallery");
|
||||||
|
if (!gallery) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
const tiles = Array.from(gallery.querySelectorAll(".flat-gallery-tile"));
|
||||||
|
const list = tiles
|
||||||
|
.map((t) => t.dataset.fullSrc || (t.querySelector("img") || {}).src || "")
|
||||||
|
.filter(Boolean);
|
||||||
|
open(list, tiles.indexOf(tile));
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,10 @@ function popupHtml(f, csrf) {
|
||||||
`<span class="chip chip-${escHtml(f.status.chip)}">${escHtml(f.status.label)}</span>` +
|
`<span class="chip chip-${escHtml(f.status.chip)}">${escHtml(f.status.label)}</span>` +
|
||||||
`</div>`;
|
`</div>`;
|
||||||
}
|
}
|
||||||
|
// Once the user has successfully applied, both action buttons disappear —
|
||||||
|
// matches the list view, which hides them on success too.
|
||||||
|
const applied = !!(f.status && f.status.chip === "ok");
|
||||||
|
if (!applied) {
|
||||||
html += `<div class="map-popup-actions">`;
|
html += `<div class="map-popup-actions">`;
|
||||||
if (f.can_apply) {
|
if (f.can_apply) {
|
||||||
const confirm = `Bewerbung für ${addrText.replace(/"/g, "'")} starten?`;
|
const confirm = `Bewerbung für ${addrText.replace(/"/g, "'")} starten?`;
|
||||||
|
|
@ -85,10 +89,31 @@ function popupHtml(f, csrf) {
|
||||||
`<button class="btn btn-ghost text-xs" type="submit"` +
|
`<button class="btn btn-ghost text-xs" type="submit"` +
|
||||||
` hx-confirm="Ablehnen und aus der Liste entfernen?">Ablehnen</button>` +
|
` hx-confirm="Ablehnen und aus der Liste entfernen?">Ablehnen</button>` +
|
||||||
`</form>`;
|
`</form>`;
|
||||||
html += `</div></div>`;
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
html += `</div>`;
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Custom pin icon — divIcon with inline SVG so we can color it per-state
|
||||||
|
// without shipping marker images. Successful apply → green; everything else
|
||||||
|
// → brand blue. Sized + anchored to roughly match Leaflet's default pin.
|
||||||
|
function pinIcon(color) {
|
||||||
|
const svg =
|
||||||
|
`<svg width="28" height="38" viewBox="0 0 28 38" xmlns="http://www.w3.org/2000/svg">` +
|
||||||
|
`<path d="M14 1C7 1 1 6.6 1 13.4c0 9.5 13 23.6 13 23.6s13-14.1 13-23.6C27 6.6 21 1 14 1z" ` +
|
||||||
|
`fill="${color}" stroke="rgba(0,0,0,0.25)" stroke-width="1"/>` +
|
||||||
|
`<circle cx="14" cy="13.5" r="4.5" fill="white"/>` +
|
||||||
|
`</svg>`;
|
||||||
|
return L.divIcon({
|
||||||
|
className: "lazyflat-pin",
|
||||||
|
html: svg,
|
||||||
|
iconSize: [28, 38],
|
||||||
|
iconAnchor: [14, 38],
|
||||||
|
popupAnchor: [0, -34],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderMarkers(payload) {
|
function renderMarkers(payload) {
|
||||||
if (!mapInstance) return;
|
if (!mapInstance) return;
|
||||||
const { csrf, flats } = payload;
|
const { csrf, flats } = payload;
|
||||||
|
|
@ -106,7 +131,9 @@ function renderMarkers(payload) {
|
||||||
|
|
||||||
flats.forEach((f) => {
|
flats.forEach((f) => {
|
||||||
if (typeof f.lat !== "number" || typeof f.lng !== "number") return;
|
if (typeof f.lat !== "number" || typeof f.lng !== "number") return;
|
||||||
L.marker([f.lat, f.lng])
|
const applied = !!(f.status && f.status.chip === "ok");
|
||||||
|
const color = applied ? "#1f8a4a" : "#2f8ae0";
|
||||||
|
L.marker([f.lat, f.lng], { icon: pinIcon(color) })
|
||||||
.addTo(markerLayer)
|
.addTo(markerLayer)
|
||||||
.bindPopup(popupHtml(f, csrf), { minWidth: 240, closeButton: true });
|
.bindPopup(popupHtml(f, csrf), { minWidth: 240, closeButton: true });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,9 @@
|
||||||
<div class="px-4 py-4 space-y-3">
|
<div class="px-4 py-4 space-y-3">
|
||||||
<div class="flat-gallery">
|
<div class="flat-gallery">
|
||||||
{% for src in image_urls %}
|
{% for src in image_urls %}
|
||||||
<a class="flat-gallery-tile" href="{{ src }}" target="_blank" rel="noopener">
|
<button type="button" class="flat-gallery-tile" data-full-src="{{ src }}" aria-label="Foto {{ loop.index }} öffnen">
|
||||||
<img src="{{ src }}" loading="lazy" alt="Foto {{ loop.index }}">
|
<img src="{{ src }}" loading="lazy" alt="Foto {{ loop.index }}">
|
||||||
</a>
|
</button>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs">
|
<div class="text-xs">
|
||||||
|
|
|
||||||
|
|
@ -18,5 +18,20 @@
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen">
|
<body class="min-h-screen">
|
||||||
{% block body %}{% endblock %}
|
{% block body %}{% endblock %}
|
||||||
|
|
||||||
|
{# Image lightbox — global so any flat-gallery on any page reuses the same modal. #}
|
||||||
|
<div id="lazyflat-lightbox" class="lightbox hidden" aria-hidden="true" role="dialog" aria-label="Bildansicht">
|
||||||
|
<button class="lightbox-close" type="button" aria-label="Schließen" data-lightbox-close>
|
||||||
|
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="lightbox-prev" type="button" aria-label="Vorheriges Bild" data-lightbox-prev>
|
||||||
|
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="lightbox-next" type="button" aria-label="Nächstes Bild" data-lightbox-next>
|
||||||
|
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
</button>
|
||||||
|
<img class="lightbox-image" alt="">
|
||||||
|
<div class="lightbox-counter"></div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue