Skip to content

Commit 54cd0e8

Browse files
enkoclaude
andcommitted
feat(frontend): Add address map view with Leaflet
Show geocoded addresses on an interactive map. Each address row gets a "Show map" toggle that opens a native <dialog> modal sized to 90vw x 90vh for proper focus management, ESC handling, and viewport use. - AddressMap component lazy-loads Leaflet so the bundle stays light for users who never open the map. - Bundle Leaflet CSS and marker icons from npm rather than the unpkg CDN, fixing air-gapped deployments and avoiding outbound IP leaks. - Refetch the friend or collective a few times after an address save so asynchronously resolved coordinates appear without a manual reload. - New EN/DE i18n strings under subresources.address.*. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3e55b49 commit 54cd0e8

9 files changed

Lines changed: 252 additions & 0 deletions

File tree

apps/frontend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"i18next": "25.7.4",
2626
"i18next-browser-languagedetector": "8.2.0",
2727
"isomorphic-dompurify": "2.35.0",
28+
"leaflet": "1.9.4",
2829
"svelte-easy-crop": "5.0.0",
2930
"svelte-heros-v2": "3.0.1",
3031
"svelte-i18next": "2.2.2"
@@ -37,6 +38,7 @@
3738
"@tailwindcss/postcss": "4.1.18",
3839
"@testing-library/svelte": "5.3.0",
3940
"@types/d3": "7.4.3",
41+
"@types/leaflet": "1.9.21",
4042
"autoprefixer": "10.4.23",
4143
"jsdom": "27.3.0",
4244
"postcss": "8.5.6",

apps/frontend/src/lib/components/collectives/collective-detail.svelte

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,13 @@ async function handleSave() {
349349
const created = await addAddress(collective.id, data);
350350
addresses = [...addresses, created];
351351
}
352+
// Geocoding runs asynchronously on the backend; refetch a few times to
353+
// pick up coordinates once they land.
354+
for (const delay of [800, 2000, 4500]) {
355+
setTimeout(() => {
356+
loadSubresources().catch(() => undefined);
357+
}, delay);
358+
}
352359
} else if (editingType === 'url' && urlFormRef?.isValid()) {
353360
const data = urlFormRef.getData();
354361
if (editingId) {

apps/frontend/src/lib/components/friends/sections/address-section.svelte

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,24 @@ let deleteConfirmName = $state('');
3030
3131
let showModal = $derived(isAdding || editingId !== null);
3232
33+
/**
34+
* Poll for geocoding completion. Backend geocodes addresses asynchronously
35+
* after the write commits, so the response we just stored may still have
36+
* null coordinates. Refetch a few times to pick up coordinates once they
37+
* land — fast for PostGIS (DACH) hits, up to a couple seconds for the
38+
* Nominatim fallback.
39+
*/
40+
function scheduleGeocodeRefresh() {
41+
const delaysMs = [800, 2000, 4500];
42+
for (const delay of delaysMs) {
43+
setTimeout(() => {
44+
friends.loadFriend(friendId).catch(() => {
45+
// Refetch is best-effort; ignore transient failures.
46+
});
47+
}, delay);
48+
}
49+
}
50+
3351
function openAdd() {
3452
editingId = null;
3553
editingData = null;
@@ -69,6 +87,10 @@ async function handleSave() {
6987
await friends.addAddress(friendId, data);
7088
}
7189
closeModal();
90+
// Geocoding runs asynchronously on the backend after the write commits.
91+
// Refetch the friend after a short delay so newly resolved coordinates
92+
// (and the resulting "Show map" button) appear without a manual reload.
93+
scheduleGeocodeRefresh();
7294
} catch (err) {
7395
editError = err instanceof Error ? err.message : $i18n.t('subresources.common.failedToSave');
7496
isEditLoading = false;
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<script lang="ts">
2+
import XMark from 'svelte-heros-v2/XMark.svelte';
3+
import { createI18n } from '$lib/i18n/index.js';
4+
import AddressMap from './address-map.svelte';
5+
6+
const i18n = createI18n();
7+
8+
interface Props {
9+
open: boolean;
10+
latitude: number;
11+
longitude: number;
12+
addressLabel: string;
13+
onClose: () => void;
14+
}
15+
16+
let { open, latitude, longitude, addressLabel, onClose }: Props = $props();
17+
18+
let dialogEl: HTMLDialogElement;
19+
20+
// Drive the native <dialog> element from the `open` prop. Using showModal()
21+
// gives us a real focus trap, ESC-to-close, and inert background for free.
22+
$effect(() => {
23+
if (!dialogEl) return;
24+
if (open && !dialogEl.open) {
25+
dialogEl.showModal();
26+
} else if (!open && dialogEl.open) {
27+
dialogEl.close();
28+
}
29+
});
30+
31+
function handleCancel(e: Event) {
32+
// Native ESC fires the cancel event — translate to onClose so parent state stays in sync.
33+
e.preventDefault();
34+
onClose();
35+
}
36+
37+
function handleBackdropClick(e: MouseEvent) {
38+
// <dialog> click target is itself when the user clicks outside the inner panel.
39+
if (e.target === dialogEl) {
40+
onClose();
41+
}
42+
}
43+
</script>
44+
45+
<dialog
46+
bind:this={dialogEl}
47+
oncancel={handleCancel}
48+
onclick={handleBackdropClick}
49+
aria-labelledby="address-map-modal-title"
50+
class="p-0 rounded-xl shadow-2xl backdrop:bg-black/50 bg-white"
51+
>
52+
<div class="flex items-center justify-between p-4 border-b border-gray-200 shrink-0">
53+
<h2 id="address-map-modal-title" class="text-lg font-heading text-gray-900 truncate pr-2">
54+
{addressLabel || $i18n.t('subresources.address.address')}
55+
</h2>
56+
<button
57+
type="button"
58+
onclick={onClose}
59+
class="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100 shrink-0"
60+
aria-label={$i18n.t('subresources.common.close')}
61+
>
62+
<XMark class="w-5 h-5" strokeWidth="2" />
63+
</button>
64+
</div>
65+
66+
<div class="p-4 flex-1 min-h-0">
67+
{#if open}
68+
<AddressMap {latitude} {longitude} {addressLabel} heightClass="h-full" />
69+
{/if}
70+
</div>
71+
</dialog>
72+
73+
<style>
74+
/* `display: flex` would override the UA's `display: none` on a closed
75+
<dialog>, so flex layout must only apply when the dialog is open.
76+
Tailwind preflight also zeros dialog margins, which breaks the UA's
77+
default centering for showModal() — restore it explicitly. */
78+
dialog[open] {
79+
display: flex;
80+
flex-direction: column;
81+
margin: auto;
82+
width: 90vw;
83+
height: 90vh;
84+
max-width: none;
85+
max-height: none;
86+
}
87+
</style>
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<script lang="ts">
2+
import type L from 'leaflet';
3+
import { onDestroy, onMount } from 'svelte';
4+
5+
interface Props {
6+
latitude: number;
7+
longitude: number;
8+
addressLabel?: string;
9+
/** Tailwind height utility (e.g. "h-48", "h-96"). Defaults to a compact 12rem. */
10+
heightClass?: string;
11+
}
12+
13+
let { latitude, longitude, addressLabel = '', heightClass = 'h-48' }: Props = $props();
14+
15+
// Resolved once at module load — these are static asset URLs and don't depend
16+
// on the live latitude/longitude props.
17+
const markerIconUrl = new URL('leaflet/dist/images/marker-icon.png', import.meta.url).href;
18+
const markerIcon2xUrl = new URL('leaflet/dist/images/marker-icon-2x.png', import.meta.url).href;
19+
const markerShadowUrl = new URL('leaflet/dist/images/marker-shadow.png', import.meta.url).href;
20+
21+
let mapContainer: HTMLDivElement;
22+
let map: L.Map | null = null;
23+
let marker: L.Marker | null = null;
24+
25+
onMount(async () => {
26+
// Dynamically import Leaflet and its CSS to keep the initial bundle small.
27+
const [leafletModule] = await Promise.all([
28+
import('leaflet'),
29+
import('leaflet/dist/leaflet.css'),
30+
]);
31+
32+
const defaultIcon = leafletModule.icon({
33+
iconUrl: markerIconUrl,
34+
iconRetinaUrl: markerIcon2xUrl,
35+
shadowUrl: markerShadowUrl,
36+
iconSize: [25, 41],
37+
iconAnchor: [12, 41],
38+
popupAnchor: [1, -34],
39+
shadowSize: [41, 41],
40+
});
41+
42+
map = leafletModule.map(mapContainer).setView([latitude, longitude], 16);
43+
44+
leafletModule
45+
.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
46+
attribution:
47+
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
48+
maxZoom: 19,
49+
})
50+
.addTo(map);
51+
52+
marker = leafletModule.marker([latitude, longitude], { icon: defaultIcon }).addTo(map);
53+
54+
if (addressLabel) {
55+
marker.bindPopup(addressLabel);
56+
}
57+
});
58+
59+
// Reactively update the view when coordinates or label change after mount.
60+
$effect(() => {
61+
if (!map || !marker) return;
62+
map.setView([latitude, longitude]);
63+
marker.setLatLng([latitude, longitude]);
64+
if (addressLabel) {
65+
marker.bindPopup(addressLabel);
66+
} else {
67+
marker.unbindPopup();
68+
}
69+
});
70+
71+
onDestroy(() => {
72+
if (map) {
73+
map.remove();
74+
map = null;
75+
marker = null;
76+
}
77+
});
78+
</script>
79+
80+
<div bind:this={mapContainer} class="w-full {heightClass} rounded-lg overflow-hidden border border-gray-200"></div>

apps/frontend/src/lib/components/friends/subresources/address-row.svelte

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
<script lang="ts">
2+
import MapPin from 'svelte-heros-v2/MapPin.svelte';
3+
import { createI18n } from '$lib/i18n/index.js';
24
import type { Address, AddressType } from '$shared';
5+
import AddressMapModal from './address-map-modal.svelte';
36
import SubresourceRow from './subresource-row.svelte';
47
58
interface Props {
@@ -11,6 +14,11 @@ interface Props {
1114
1215
let { address, onEdit, onDelete, isDeleting = false }: Props = $props();
1316
17+
const i18n = createI18n();
18+
let showMap = $state(false);
19+
20+
const hasCoordinates = $derived(address.latitude != null && address.longitude != null);
21+
1422
function formatAddressType(type: AddressType): string {
1523
const typeLabels: Record<AddressType, string> = {
1624
home: 'Home',
@@ -31,6 +39,10 @@ function formatAddress(addr: Address): string[] {
3139
return lines;
3240
}
3341
42+
function formatAddressLabel(addr: Address): string {
43+
return formatAddress(addr).join(', ');
44+
}
45+
3446
const addressLines = $derived(formatAddress(address));
3547
</script>
3648

@@ -52,5 +64,25 @@ const addressLines = $derived(formatAddress(address));
5264
{/if}
5365
</div>
5466
</div>
67+
{#if hasCoordinates}
68+
<button
69+
type="button"
70+
class="mt-3 inline-flex items-center gap-1.5 text-xs font-medium text-forest border border-forest/30 hover:bg-forest hover:text-white hover:border-forest px-2.5 py-1 rounded-full transition-colors"
71+
onclick={() => (showMap = true)}
72+
aria-haspopup="dialog"
73+
>
74+
<MapPin class="w-3.5 h-3.5" strokeWidth="2" />
75+
{$i18n.t('subresources.address.showMap')}
76+
</button>
77+
{/if}
78+
{#if address.latitude != null && address.longitude != null}
79+
<AddressMapModal
80+
open={showMap}
81+
latitude={address.latitude}
82+
longitude={address.longitude}
83+
addressLabel={formatAddressLabel(address)}
84+
onClose={() => (showMap = false)}
85+
/>
86+
{/if}
5587
</div>
5688
</SubresourceRow>

apps/frontend/src/lib/i18n/locales/de.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1219,6 +1219,8 @@
12191219
"address": "Adresse",
12201220
"thisAddress": "diese Adresse",
12211221
"primaryAddress": "Primäre Adresse",
1222+
"showMap": "Karte anzeigen",
1223+
"hideMap": "Karte ausblenden",
12221224
"types": {
12231225
"home": "Privat",
12241226
"work": "Arbeit",

apps/frontend/src/lib/i18n/locales/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1219,6 +1219,8 @@
12191219
"address": "Address",
12201220
"thisAddress": "this address",
12211221
"primaryAddress": "Primary address",
1222+
"showMap": "Show map",
1223+
"hideMap": "Hide map",
12221224
"types": {
12231225
"home": "Home",
12241226
"work": "Work",

pnpm-lock.yaml

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)