mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
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:
parent
f7835471f4
commit
0ba97672b1
14 changed files with 470 additions and 25 deletions
|
|
@ -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 ?? '';
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue