feat: extend geocoding to events, contacts, photos

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-11 16:01:20 +02:00
parent f7835471f4
commit 0ba97672b1
14 changed files with 470 additions and 25 deletions

View file

@ -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 ?? '';
}

View file

@ -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,

View file

@ -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;

View file

@ -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;

View file

@ -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<number | null>(null);
let editLongitude = $state<number | null>(null);
let editBirthday = $state('');
let editWebsite = $state('');
let editNotes = $state('');
// Address autocomplete
let addressSearchQuery = $state('');
let addressSuggestions = $state<GeocodingResult[]>([]);
let showAddressSuggestions = $state(false);
let addressDebounce: ReturnType<typeof setTimeout> | 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 @@
<div class="field-row">
<span class="field-icon"><MapPin size={14} /></span>
<div class="field-group">
<div class="address-search-wrapper">
<div class="address-search-row">
<MagnifyingGlass size={12} />
<input
class="field-input address-search-input"
type="text"
placeholder="Adresse suchen..."
bind:value={addressSearchQuery}
oninput={onAddressSearchInput}
onblur={onAddressSearchBlur}
onfocus={() => {
if (addressSuggestions.length > 0) showAddressSuggestions = true;
}}
/>
</div>
{#if showAddressSuggestions}
<div class="address-suggestions">
{#each addressSuggestions as result}
<button
type="button"
class="address-suggestion"
onclick={() => applyAddressSuggestion(result)}
>
<span class="address-suggestion-name">{result.name || result.label}</span>
<span class="address-suggestion-addr">{formatAddress(result.address)}</span>
</button>
{/each}
</div>
{/if}
</div>
<input
class="field-input"
bind:value={editStreet}
@ -385,6 +469,63 @@
display: flex;
gap: 0.25rem;
}
.address-search-wrapper {
position: relative;
margin-bottom: 0.25rem;
}
.address-search-row {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0 0.375rem;
border: 1px dashed hsl(var(--color-border));
border-radius: 0.25rem;
color: hsl(var(--color-muted-foreground));
}
.address-search-input {
border: none !important;
background: transparent !important;
padding: 0.25rem !important;
}
.address-suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.125rem;
background: hsl(var(--color-background));
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 50;
overflow: hidden;
}
.address-suggestion {
display: flex;
flex-direction: column;
gap: 0.0625rem;
padding: 0.375rem 0.5rem;
width: 100%;
border: none;
background: transparent;
color: hsl(var(--color-foreground));
cursor: pointer;
text-align: left;
}
.address-suggestion:hover {
background: hsl(var(--color-muted));
}
.address-suggestion + .address-suggestion {
border-top: 1px solid hsl(var(--color-border) / 0.5);
}
.address-suggestion-name {
font-size: 0.75rem;
font-weight: 500;
}
.address-suggestion-addr {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
}
.tags-list {
display: flex;

View file

@ -29,6 +29,8 @@ export function toSocialEvent(local: LocalSocialEvent, block: LocalTimeBlock | n
description: local.description ?? null,
location: local.location ?? null,
locationUrl: local.locationUrl ?? null,
locationLat: local.locationLat ?? null,
locationLon: local.locationLon ?? null,
hostContactId: local.hostContactId ?? null,
coverImage: local.coverImage ?? null,
color: local.color ?? null,

View file

@ -87,6 +87,8 @@ export const eventsStore = {
description?: string | null;
location?: string | null;
locationUrl?: string | null;
locationLat?: number | null;
locationLon?: number | null;
startTime?: string;
endTime?: string;
isAllDay?: boolean;
@ -120,6 +122,8 @@ export const eventsStore = {
if (input.description !== undefined) localData.description = input.description;
if (input.location !== undefined) localData.location = input.location;
if (input.locationUrl !== undefined) localData.locationUrl = input.locationUrl;
if (input.locationLat !== undefined) localData.locationLat = input.locationLat;
if (input.locationLon !== undefined) localData.locationLon = input.locationLon;
if (input.color !== undefined) localData.color = input.color;
if (input.capacity !== undefined) localData.capacity = input.capacity;
if (input.status !== undefined) localData.status = input.status;

View file

@ -22,6 +22,10 @@ export interface LocalSocialEvent extends BaseRecord {
description?: string | null;
location?: string | null;
locationUrl?: string | null;
/** Geocoded latitude — plaintext (coordinates stay unencrypted for map rendering). */
locationLat?: number | null;
/** Geocoded longitude — plaintext. */
locationLon?: number | null;
hostContactId?: string | null;
coverImage?: string | null;
color?: string | null;
@ -73,6 +77,8 @@ export interface SocialEvent {
description: string | null;
location: string | null;
locationUrl: string | null;
locationLat: number | null;
locationLon: number | null;
hostContactId: string | null;
coverImage: string | null;
color: string | null;

View file

@ -6,6 +6,8 @@
import PublicRsvpList from '../components/PublicRsvpList.svelte';
import BringListEditor from '../components/BringListEditor.svelte';
import type { ViewProps } from '$lib/app-registry';
import { searchAddress, formatAddress, type GeocodingResult } from '$lib/geocoding';
import { MapPin } from '@mana/shared-icons';
let { navigate, goBack, params }: ViewProps = $props();
@ -31,27 +33,71 @@
let titleDraft = $state('');
let descDraft = $state('');
let locationDraft = $state('');
let locationLatDraft = $state<number | null>(null);
let locationLonDraft = $state<number | null>(null);
let startDraft = $state('');
let endDraft = $state('');
let allDayDraft = $state(false);
// Address autocomplete state
let addressSuggestions = $state<GeocodingResult[]>([]);
let showAddressSuggestions = $state(false);
let addressDebounce: ReturnType<typeof setTimeout> | 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 @@
<input class="title-input" bind:value={titleDraft} placeholder="Event-Titel" />
<textarea class="desc-input" bind:value={descDraft} rows="3" placeholder="Beschreibung"
></textarea>
<input class="loc-input" bind:value={locationDraft} placeholder="Ort" />
<div class="loc-wrapper">
<input
class="loc-input"
bind:value={locationDraft}
oninput={onLocationInput}
onblur={onLocationBlur}
onfocus={() => {
if (addressSuggestions.length > 0) showAddressSuggestions = true;
}}
placeholder="Ort — tippe eine Adresse..."
/>
{#if locationLatDraft && locationLonDraft}
<span class="loc-pinned" title="Koordinaten gesetzt">
<MapPin size={12} weight="fill" />
</span>
{/if}
{#if showAddressSuggestions}
<div class="loc-suggestions">
{#each addressSuggestions as result}
<button
type="button"
class="loc-suggestion"
onclick={() => selectAddressSuggestion(result)}
>
<MapPin size={12} />
<div class="loc-suggestion-text">
<span class="loc-suggestion-name">{result.name || result.label}</span>
<span class="loc-suggestion-addr">{formatAddress(result.address)}</span>
</div>
</button>
{/each}
</div>
{/if}
</div>
<div class="time-row">
<label>
<span>Start</span>
@ -150,6 +237,26 @@
{#if event.description}
<p class="description">{event.description}</p>
{/if}
{#if mapUrl}
<div class="event-map">
<iframe
title="Event-Ort auf Karte"
src={mapUrl}
width="100%"
height="180"
frameborder="0"
loading="lazy"
></iframe>
<a
class="map-open-link"
href={`https://www.openstreetmap.org/?mlat=${event.locationLat}&mlon=${event.locationLon}#map=17/${event.locationLat}/${event.locationLon}`}
target="_blank"
rel="noopener noreferrer"
>
In OpenStreetMap öffnen →
</a>
</div>
{/if}
</div>
{/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;

View file

@ -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<string | null>(null);
let locationResult = $state<GeocodingResult | null>(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}
<div class="info-section">
<h4 class="info-label">Standort</h4>
{#if locationLabel}
<p class="info-value location-line">
<MapPin size={12} />
<span>{locationLabel}</span>
</p>
{#if locationResult?.address.city && locationResult.address.country}
<p class="info-value location-sub">
{[locationResult.address.city, locationResult.address.country]
.filter(Boolean)
.join(', ')}
</p>
{/if}
{:else}
<p class="info-value location-loading">Wird ermittelt…</p>
{/if}
<a
class="info-value text-primary hover:underline"
href={`https://www.google.com/maps?q=${photo.exif.gpsLatitude},${photo.exif.gpsLongitude}`}
class="info-value location-map-link"
href={`https://www.openstreetmap.org/?mlat=${photo.exif.gpsLatitude}&mlon=${photo.exif.gpsLongitude}#map=17/${photo.exif.gpsLatitude}/${photo.exif.gpsLongitude}`}
target="_blank"
rel="noopener noreferrer"
>
Auf Karte anzeigen
In OpenStreetMap öffnen →
</a>
</div>
{/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%;

View file

@ -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';

View file

@ -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';

View file

@ -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 ──────────────────────────────────────────────

View file

@ -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';