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:
EiSiMo 2026-04-23 12:37:15 +02:00
parent fe43a402d8
commit 787f848aba
5 changed files with 173 additions and 21 deletions

View file

@ -64,31 +64,56 @@ function popupHtml(f, csrf) {
`<span class="chip chip-${escHtml(f.status.chip)}">${escHtml(f.status.label)}</span>` +
`</div>`;
}
html += `<div class="map-popup-actions">`;
if (f.can_apply) {
const confirm = `Bewerbung für ${addrText.replace(/"/g, "'")} starten?`;
// 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">`;
if (f.can_apply) {
const confirm = `Bewerbung für ${addrText.replace(/"/g, "'")} starten?`;
html +=
`<form hx-post="/actions/apply" hx-target="#wohnungen-body" hx-swap="outerHTML">` +
`<input type="hidden" name="csrf" value="${escHtml(csrf)}">` +
`<input type="hidden" name="flat_id" value="${escHtml(f.id)}">` +
`<button class="btn btn-primary text-xs" type="submit"` +
(f.is_running ? " disabled" : "") +
` hx-confirm="${escHtml(confirm)}">` +
(f.is_running ? "läuft…" : "Bewerben") +
`</button>` +
`</form>`;
}
html +=
`<form hx-post="/actions/apply" hx-target="#wohnungen-body" hx-swap="outerHTML">` +
`<form hx-post="/actions/reject" hx-target="#wohnungen-body" hx-swap="outerHTML">` +
`<input type="hidden" name="csrf" value="${escHtml(csrf)}">` +
`<input type="hidden" name="flat_id" value="${escHtml(f.id)}">` +
`<button class="btn btn-primary text-xs" type="submit"` +
(f.is_running ? " disabled" : "") +
` hx-confirm="${escHtml(confirm)}">` +
(f.is_running ? "läuft…" : "Bewerben") +
`</button>` +
`<button class="btn btn-ghost text-xs" type="submit"` +
` hx-confirm="Ablehnen und aus der Liste entfernen?">Ablehnen</button>` +
`</form>`;
html += `</div>`;
}
html +=
`<form hx-post="/actions/reject" hx-target="#wohnungen-body" hx-swap="outerHTML">` +
`<input type="hidden" name="csrf" value="${escHtml(csrf)}">` +
`<input type="hidden" name="flat_id" value="${escHtml(f.id)}">` +
`<button class="btn btn-ghost text-xs" type="submit"` +
` hx-confirm="Ablehnen und aus der Liste entfernen?">Ablehnen</button>` +
`</form>`;
html += `</div></div>`;
html += `</div>`;
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) {
if (!mapInstance) return;
const { csrf, flats } = payload;
@ -106,7 +131,9 @@ function renderMarkers(payload) {
flats.forEach((f) => {
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)
.bindPopup(popupHtml(f, csrf), { minWidth: 240, closeButton: true });
});