From 0ba97672b1088a83fd182a99027a00d229b462cb Mon Sep 17 00:00:00 2001 From: Till JS Date: Sat, 11 Apr 2026 16:01:20 +0200 Subject: [PATCH] feat: extend geocoding to events, contacts, photos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the geocoding client from the places module into a shared lib at $lib/geocoding so all modules can use it, then wire it into three new consumers: - **Events** — Address autocomplete in the edit form. When a suggestion is picked, locationLat/locationLon are stored alongside the plaintext location string. The view mode now shows an embedded OpenStreetMap iframe centered on the event location. Coordinates are plaintext for map rendering; the location text stays encrypted. - **Contacts** — Adds a secondary "Adresse suchen…" input above the existing street/PLZ/city/country fields. Picking a suggestion fills all four fields at once and captures plaintext lat/lon on the contact. Enables future "contacts near me" features. - **Photos** — Replaces the static "Auf Karte anzeigen" Google Maps link with a reverse-geocoded human label ("Konzil Restaurant, Konstanz") computed from EXIF gpsLatitude/gpsLongitude on the fly. Falls back to "Wird ermittelt…" during the lookup and keeps the OpenStreetMap link as a secondary action. All three modules import from $lib/geocoding; the places module's internal geocoding.ts is deleted in favor of the shared location. Type-check: 0 errors across 6514 files. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../geocoding.ts => geocoding/index.ts} | 49 +++-- .../web/src/lib/modules/contacts/queries.ts | 2 + .../contacts/stores/contacts.svelte.ts | 2 + .../web/src/lib/modules/contacts/types.ts | 6 + .../modules/contacts/views/DetailView.svelte | 143 ++++++++++++- .../web/src/lib/modules/events/queries.ts | 2 + .../modules/events/stores/events.svelte.ts | 4 + .../apps/web/src/lib/modules/events/types.ts | 6 + .../modules/events/views/DetailView.svelte | 190 +++++++++++++++++- .../gallery/PhotoDetailModal.svelte | 76 ++++++- .../src/lib/modules/places/ListView.svelte | 2 +- .../apps/web/src/lib/modules/places/index.ts | 4 +- .../modules/places/stores/tracking.svelte.ts | 2 +- .../modules/places/views/DetailView.svelte | 7 +- 14 files changed, 470 insertions(+), 25 deletions(-) rename apps/mana/apps/web/src/lib/{modules/places/geocoding.ts => geocoding/index.ts} (67%) diff --git a/apps/mana/apps/web/src/lib/modules/places/geocoding.ts b/apps/mana/apps/web/src/lib/geocoding/index.ts similarity index 67% rename from apps/mana/apps/web/src/lib/modules/places/geocoding.ts rename to apps/mana/apps/web/src/lib/geocoding/index.ts index 480187665..661a8e896 100644 --- a/apps/mana/apps/web/src/lib/modules/places/geocoding.ts +++ b/apps/mana/apps/web/src/lib/geocoding/index.ts @@ -1,12 +1,18 @@ /** - * Geocoding client for the Places module. + * Shared geocoding client for all modules in the unified Mana app. * - * Talks to our self-hosted mana-geocoding service (Pelias-backed). - * All queries stay within our infrastructure — no location data - * leaves the network. + * Talks to our self-hosted mana-geocoding service (Pelias-backed, port 3018). + * All queries stay within our infrastructure — no user location data leaves + * the network. + * + * Used by: places, events, contacts, photos, … + * + * The `PlaceCategory` type is defined here (rather than imported from the + * places module) because geocoding is the source of truth for categories — + * places just happens to be the first consumer. */ -import type { PlaceCategory } from './types'; +export type PlaceCategory = 'home' | 'work' | 'food' | 'shopping' | 'transit' | 'leisure' | 'other'; const GEOCODING_URL = () => { if (typeof window !== 'undefined') { @@ -17,19 +23,21 @@ const GEOCODING_URL = () => { return import.meta.env.PUBLIC_MANA_GEOCODING_URL ?? 'http://localhost:3018'; }; +export interface GeocodingAddress { + street?: string; + houseNumber?: string; + postalCode?: string; + city?: string; + state?: string; + country?: string; +} + export interface GeocodingResult { label: string; name: string; latitude: number; longitude: number; - address: { - street?: string; - houseNumber?: string; - postalCode?: string; - city?: string; - state?: string; - country?: string; - }; + address: GeocodingAddress; category: PlaceCategory; /** Raw Pelias categories (food, retail, transport, …) */ peliasCategories?: string[]; @@ -101,7 +109,7 @@ export async function reverseGeocode( /** * Format a structured address into a single-line string. */ -export function formatAddress(address: GeocodingResult['address']): string { +export function formatAddress(address: GeocodingAddress): string { const parts: string[] = []; if (address.street) { @@ -115,3 +123,16 @@ export function formatAddress(address: GeocodingResult['address']): string { return parts.join(', '); } + +/** + * Build a short locality label ("Konstanz", "Konstanz, Germany") from a result. + * Useful for photos / journal / memoro where you just want to know the rough + * place, not the full street address. + */ +export function formatLocality(result: GeocodingResult): string { + const a = result.address; + // Prefer the name for venues (e.g. "Konzil Restaurant") + if (result.name && result.name !== a.city) return result.name; + if (a.city && a.country) return `${a.city}, ${a.country}`; + return a.city ?? a.country ?? result.label ?? ''; +} diff --git a/apps/mana/apps/web/src/lib/modules/contacts/queries.ts b/apps/mana/apps/web/src/lib/modules/contacts/queries.ts index e1763d96a..fd1f59b04 100644 --- a/apps/mana/apps/web/src/lib/modules/contacts/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/contacts/queries.ts @@ -28,6 +28,8 @@ export function toContact(local: LocalContact): Contact { city: local.city || null, postalCode: local.postalCode || null, country: local.country || null, + latitude: local.latitude ?? null, + longitude: local.longitude ?? null, notes: local.notes || null, photoUrl: local.photoUrl || null, birthday: local.birthday || null, diff --git a/apps/mana/apps/web/src/lib/modules/contacts/stores/contacts.svelte.ts b/apps/mana/apps/web/src/lib/modules/contacts/stores/contacts.svelte.ts index 268c7f47a..428918425 100644 --- a/apps/mana/apps/web/src/lib/modules/contacts/stores/contacts.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/contacts/stores/contacts.svelte.ts @@ -65,6 +65,8 @@ export const contactsStore = { if (data.postalCode !== undefined) updateData.postalCode = data.postalCode as string | undefined; if (data.country !== undefined) updateData.country = data.country as string | undefined; + if (data.latitude !== undefined) updateData.latitude = data.latitude as number | undefined; + if (data.longitude !== undefined) updateData.longitude = data.longitude as number | undefined; if (data.notes !== undefined) updateData.notes = data.notes ?? undefined; if (data.photoUrl !== undefined) updateData.photoUrl = data.photoUrl ?? undefined; if (data.birthday !== undefined) updateData.birthday = data.birthday ?? undefined; diff --git a/apps/mana/apps/web/src/lib/modules/contacts/types.ts b/apps/mana/apps/web/src/lib/modules/contacts/types.ts index f41d01c8f..f1edeb832 100644 --- a/apps/mana/apps/web/src/lib/modules/contacts/types.ts +++ b/apps/mana/apps/web/src/lib/modules/contacts/types.ts @@ -16,6 +16,10 @@ export interface LocalContact extends BaseRecord { city?: string; postalCode?: string; country?: string; + /** Geocoded latitude — plaintext (coordinates stay unencrypted for map rendering). */ + latitude?: number; + /** Geocoded longitude — plaintext. */ + longitude?: number; address?: string; notes?: string; photoUrl?: string; @@ -47,6 +51,8 @@ export interface Contact { city?: string | null; postalCode?: string | null; country?: string | null; + latitude?: number | null; + longitude?: number | null; notes?: string | null; photoUrl?: string | null; birthday?: string | null; diff --git a/apps/mana/apps/web/src/lib/modules/contacts/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/contacts/views/DetailView.svelte index a50d0a440..5dbd3c7bb 100644 --- a/apps/mana/apps/web/src/lib/modules/contacts/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/contacts/views/DetailView.svelte @@ -6,12 +6,22 @@ import { useDetailEntity } from '$lib/data/detail-entity.svelte'; import DetailViewShell from '$lib/components/DetailViewShell.svelte'; import { contactsStore } from '../stores/contacts.svelte'; - import { Star, EnvelopeSimple, Phone, MapPin, Briefcase, Globe, X } from '@mana/shared-icons'; + import { + Star, + EnvelopeSimple, + Phone, + MapPin, + Briefcase, + Globe, + X, + MagnifyingGlass, + } from '@mana/shared-icons'; import type { ViewProps } from '$lib/app-registry'; import type { LocalContact } from '../types'; import { useAllTags, getTagsByIds } from '@mana/shared-stores'; import LinkedItems from '$lib/components/links/LinkedItems.svelte'; import { removeTagIdWithUndo } from '$lib/data/tag-mutations'; + import { searchAddress, formatAddress, type GeocodingResult } from '$lib/geocoding'; let { navigate, params, goBack }: ViewProps = $props(); let contactId = $derived(params.contactId as string); @@ -27,10 +37,50 @@ let editCity = $state(''); let editPostalCode = $state(''); let editCountry = $state(''); + let editLatitude = $state(null); + let editLongitude = $state(null); let editBirthday = $state(''); let editWebsite = $state(''); let editNotes = $state(''); + // Address autocomplete + let addressSearchQuery = $state(''); + let addressSuggestions = $state([]); + let showAddressSuggestions = $state(false); + let addressDebounce: ReturnType | undefined; + + function onAddressSearchInput() { + clearTimeout(addressDebounce); + if (addressSearchQuery.trim().length < 2) { + addressSuggestions = []; + showAddressSuggestions = false; + return; + } + addressDebounce = setTimeout(async () => { + addressSuggestions = await searchAddress(addressSearchQuery, { limit: 5 }); + showAddressSuggestions = addressSuggestions.length > 0; + }, 300); + } + + async function applyAddressSuggestion(result: GeocodingResult) { + showAddressSuggestions = false; + addressSearchQuery = ''; + const a = result.address; + editStreet = [a.street, a.houseNumber].filter(Boolean).join(' '); + editCity = a.city ?? ''; + editPostalCode = a.postalCode ?? ''; + editCountry = a.country ?? ''; + editLatitude = result.latitude; + editLongitude = result.longitude; + await saveField(); + } + + function onAddressSearchBlur() { + setTimeout(() => { + showAddressSuggestions = false; + }, 200); + } + const tagsQuery = useAllTags(); let allTags = $derived(tagsQuery.value ?? []); @@ -49,6 +99,8 @@ editCity = c.city ?? ''; editPostalCode = c.postalCode ?? ''; editCountry = c.country ?? ''; + editLatitude = c.latitude ?? null; + editLongitude = c.longitude ?? null; editBirthday = c.birthday ?? ''; editWebsite = c.website ?? ''; editNotes = c.notes ?? ''; @@ -83,6 +135,8 @@ city: editCity.trim() || null, postalCode: editPostalCode.trim() || null, country: editCountry.trim() || null, + latitude: editLatitude, + longitude: editLongitude, birthday: editBirthday || null, website: editWebsite.trim() || null, notes: editNotes.trim() || null, @@ -193,6 +247,36 @@
+
+
+ + { + if (addressSuggestions.length > 0) showAddressSuggestions = true; + }} + /> +
+ {#if showAddressSuggestions} +
+ {#each addressSuggestions as result} + + {/each} +
+ {/if} +
(null); + let locationLonDraft = $state(null); let startDraft = $state(''); let endDraft = $state(''); let allDayDraft = $state(false); + // Address autocomplete state + let addressSuggestions = $state([]); + let showAddressSuggestions = $state(false); + let addressDebounce: ReturnType | undefined; + function startEdit() { if (!event) return; titleDraft = event.title; descDraft = event.description ?? ''; locationDraft = event.location ?? ''; + locationLatDraft = event.locationLat; + locationLonDraft = event.locationLon; startDraft = toLocalDatetime(event.startTime); endDraft = toLocalDatetime(event.endTime); allDayDraft = event.isAllDay; editing = true; } + function onLocationInput() { + clearTimeout(addressDebounce); + // User is typing a custom location — clear coordinates until they pick a suggestion + if (locationDraft !== event?.location) { + locationLatDraft = null; + locationLonDraft = null; + } + if (locationDraft.trim().length < 2) { + addressSuggestions = []; + showAddressSuggestions = false; + return; + } + addressDebounce = setTimeout(async () => { + addressSuggestions = await searchAddress(locationDraft, { limit: 5 }); + showAddressSuggestions = addressSuggestions.length > 0; + }, 300); + } + + function selectAddressSuggestion(result: GeocodingResult) { + showAddressSuggestions = false; + const addr = formatAddress(result.address); + locationDraft = result.name ? `${result.name}${addr ? ', ' + addr : ''}` : addr || result.label; + locationLatDraft = result.latitude; + locationLonDraft = result.longitude; + } + + function onLocationBlur() { + // Delay so suggestion clicks register first + setTimeout(() => { + showAddressSuggestions = false; + }, 200); + } + async function saveEdit() { if (!event) return; await eventsStore.updateEvent(event.id, { title: titleDraft, description: descDraft || null, location: locationDraft || null, + locationLat: locationLatDraft, + locationLon: locationLonDraft, startTime: fromLocalDatetime(startDraft), endTime: fromLocalDatetime(endDraft), isAllDay: allDayDraft, @@ -59,6 +105,14 @@ editing = false; } + let mapUrl = $derived.by(() => { + if (!event?.locationLat || !event?.locationLon) return ''; + const lat = event.locationLat; + const lng = event.locationLon; + const bbox = `${lng - 0.005},${lat - 0.003},${lng + 0.005},${lat + 0.003}`; + return `https://www.openstreetmap.org/export/embed.html?bbox=${bbox}&layer=mapnik&marker=${lat},${lng}`; + }); + function toLocalDatetime(iso: string): string { const d = new Date(iso); const pad = (n: number) => n.toString().padStart(2, '0'); @@ -109,7 +163,40 @@ - +
+ { + if (addressSuggestions.length > 0) showAddressSuggestions = true; + }} + placeholder="Ort — tippe eine Adresse..." + /> + {#if locationLatDraft && locationLonDraft} + + + + {/if} + {#if showAddressSuggestions} +
+ {#each addressSuggestions as result} + + {/each} +
+ {/if} +
{/if} @@ -287,7 +394,11 @@ } .title-input, .desc-input, + .loc-wrapper { + position: relative; + } .loc-input { + width: 100%; padding: 0.625rem 0.875rem; border: 1px solid hsl(var(--color-border)); border-radius: 0.5rem; @@ -296,6 +407,83 @@ color: hsl(var(--color-foreground)); font-family: inherit; } + .loc-pinned { + position: absolute; + right: 0.625rem; + top: 50%; + transform: translateY(-50%); + color: #0ea5e9; + pointer-events: none; + } + .loc-suggestions { + position: absolute; + top: 100%; + left: 0; + right: 0; + margin-top: 0.25rem; + background: hsl(var(--color-background)); + border: 1px solid hsl(var(--color-border)); + border-radius: 0.5rem; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 50; + overflow: hidden; + } + .loc-suggestion { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.625rem; + width: 100%; + border: none; + background: transparent; + color: hsl(var(--color-foreground)); + cursor: pointer; + text-align: left; + } + .loc-suggestion:hover { + background: hsl(var(--color-muted)); + } + .loc-suggestion + .loc-suggestion { + border-top: 1px solid hsl(var(--color-border) / 0.5); + } + .loc-suggestion-text { + display: flex; + flex-direction: column; + gap: 0.0625rem; + min-width: 0; + } + .loc-suggestion-name { + font-size: 0.8125rem; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .loc-suggestion-addr { + font-size: 0.6875rem; + color: hsl(var(--color-muted-foreground)); + } + .event-map { + margin-top: 0.75rem; + border-radius: 0.5rem; + overflow: hidden; + border: 1px solid hsl(var(--color-border)); + } + .event-map iframe { + display: block; + } + .map-open-link { + display: block; + padding: 0.375rem 0.625rem; + font-size: 0.75rem; + color: hsl(var(--color-muted-foreground)); + text-decoration: none; + text-align: right; + background: hsl(var(--color-muted)); + } + .map-open-link:hover { + color: #0ea5e9; + } .title-input { font-size: 1.25rem; font-weight: 600; diff --git a/apps/mana/apps/web/src/lib/modules/photos/components/gallery/PhotoDetailModal.svelte b/apps/mana/apps/web/src/lib/modules/photos/components/gallery/PhotoDetailModal.svelte index 69fddfee7..3f344ff0d 100644 --- a/apps/mana/apps/web/src/lib/modules/photos/components/gallery/PhotoDetailModal.svelte +++ b/apps/mana/apps/web/src/lib/modules/photos/components/gallery/PhotoDetailModal.svelte @@ -2,8 +2,9 @@ import { _ } from 'svelte-i18n'; import type { Photo } from '$lib/modules/photos/types'; import { photoStore } from '$lib/modules/photos/stores/photos.svelte'; - import { CaretRight, DownloadSimple, Heart, X } from '@mana/shared-icons'; + import { CaretRight, DownloadSimple, Heart, MapPin, X } from '@mana/shared-icons'; import { TagChip } from '@mana/shared-ui'; + import { reverseGeocode, formatLocality, type GeocodingResult } from '$lib/geocoding'; interface Props { photo: Photo; @@ -14,6 +15,28 @@ let showInfo = $state(true); + // Reverse geocoding for GPS coordinates + let locationLabel = $state(null); + let locationResult = $state(null); + + $effect(() => { + const lat = photo.exif?.gpsLatitude; + const lon = photo.exif?.gpsLongitude; + if (lat && lon) { + locationLabel = null; + locationResult = null; + reverseGeocode(lat, lon).then((result) => { + if (result) { + locationResult = result; + locationLabel = formatLocality(result); + } + }); + } else { + locationLabel = null; + locationResult = null; + } + }); + function handleKeydown(e: KeyboardEvent) { if (e.key === 'Escape') onClose(); } @@ -128,13 +151,28 @@ {#if photo.exif.gpsLatitude && photo.exif.gpsLongitude}

Standort

+ {#if locationLabel} +

+ + {locationLabel} +

+ {#if locationResult?.address.city && locationResult.address.country} +

+ {[locationResult.address.city, locationResult.address.country] + .filter(Boolean) + .join(', ')} +

+ {/if} + {:else} +

Wird ermittelt…

+ {/if} - Auf Karte anzeigen + In OpenStreetMap öffnen →
{/if} @@ -270,6 +308,36 @@ font-size: 0.875rem; } + .location-line { + display: flex; + align-items: center; + gap: 0.375rem; + font-weight: 500; + } + + .location-sub { + color: hsl(var(--color-muted-foreground)); + font-size: 0.75rem; + margin-top: 0.125rem; + } + + .location-loading { + color: hsl(var(--color-muted-foreground)); + font-style: italic; + } + + .location-map-link { + display: inline-block; + margin-top: 0.375rem; + font-size: 0.75rem; + color: hsl(var(--color-muted-foreground)); + text-decoration: none; + } + + .location-map-link:hover { + color: #0ea5e9; + } + .icon-btn { padding: 0.25rem; border-radius: 50%; diff --git a/apps/mana/apps/web/src/lib/modules/places/ListView.svelte b/apps/mana/apps/web/src/lib/modules/places/ListView.svelte index a328f13b8..1cc3f6bd9 100644 --- a/apps/mana/apps/web/src/lib/modules/places/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/places/ListView.svelte @@ -7,7 +7,7 @@ import { useAllPlaces } from './queries'; import { placesStore } from './stores/places.svelte'; import { trackingStore } from './stores/tracking.svelte'; - import { searchAddress, formatAddress, type GeocodingResult } from './geocoding'; + import { searchAddress, formatAddress, type GeocodingResult } from '$lib/geocoding'; import { Star, MapPin, Plus, PencilSimple, Trash, MagnifyingGlass } from '@mana/shared-icons'; import type { ViewProps } from '$lib/app-registry'; import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui'; diff --git a/apps/mana/apps/web/src/lib/modules/places/index.ts b/apps/mana/apps/web/src/lib/modules/places/index.ts index 880fd7883..f9d694249 100644 --- a/apps/mana/apps/web/src/lib/modules/places/index.ts +++ b/apps/mana/apps/web/src/lib/modules/places/index.ts @@ -16,6 +16,6 @@ export { findNearestPlace, } from './queries'; export { placeTable, locationLogTable, PLACES_GUEST_SEED } from './collections'; -export { searchAddress, reverseGeocode, formatAddress } from './geocoding'; -export type { GeocodingResult } from './geocoding'; +// Geocoding moved to $lib/geocoding (shared across modules). +// Import directly from $lib/geocoding instead of from this barrel. export type { LocalPlace, LocalLocationLog, Place, LocationLog, PlaceCategory } from './types'; diff --git a/apps/mana/apps/web/src/lib/modules/places/stores/tracking.svelte.ts b/apps/mana/apps/web/src/lib/modules/places/stores/tracking.svelte.ts index ad386e79b..6caace052 100644 --- a/apps/mana/apps/web/src/lib/modules/places/stores/tracking.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/places/stores/tracking.svelte.ts @@ -9,7 +9,7 @@ import { decryptRecords, encryptRecord } from '$lib/data/crypto'; import { createBlock } from '$lib/data/time-blocks/service'; import { locationLogTable, placeTable } from '../collections'; import { getDistanceKm, findNearestPlace, toPlace } from '../queries'; -import { reverseGeocode, formatAddress } from '../geocoding'; +import { reverseGeocode, formatAddress } from '$lib/geocoding'; import type { LocalLocationLog, LocalPlace } from '../types'; // ─── State ────────────────────────────────────────────── diff --git a/apps/mana/apps/web/src/lib/modules/places/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/places/views/DetailView.svelte index f7c36cf20..70e2b7b04 100644 --- a/apps/mana/apps/web/src/lib/modules/places/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/places/views/DetailView.svelte @@ -8,7 +8,12 @@ import { useDetailEntity } from '$lib/data/detail-entity.svelte'; import DetailViewShell from '$lib/components/DetailViewShell.svelte'; import { placesStore } from '../stores/places.svelte'; - import { reverseGeocode, formatAddress, searchAddress, type GeocodingResult } from '../geocoding'; + import { + reverseGeocode, + formatAddress, + searchAddress, + type GeocodingResult, + } from '$lib/geocoding'; import { Star, MapPin, X, MagnifyingGlass, ArrowsClockwise } from '@mana/shared-icons'; import type { ViewProps } from '$lib/app-registry'; import type { LocalPlace, PlaceCategory, LocalLocationLog } from '../types';