mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
feat(places): clickable tracking label + full address + browser proxy
Three related fixes for the workbench tracking overlay: 1. **Same-origin proxy at /api/v1/geocode/[...path]/+server.ts.** mana-geocoding is intentionally NOT exposed via Cloudflare, so the browser can't reach it directly — localhost:3018 is unreachable from a visitor's device. Same-origin proxy fixes this: the browser talks to https://mana.how/api/v1/geocode/*, SvelteKit forwards to http://mana-geocoding:3018 over the docker network. Pattern copied from the existing /api/v1/who/[...path] proxy. 2. **`formatFullAddress()` in $lib/geocoding** builds a compact line with street+housenumber, postal code, city, and 2-letter country code (DE/AT/CH) — e.g. "Hafenstraße 2, 78462 Konstanz, DE". Maps German and English OSM country names to ISO 3166-1 alpha-2. 3. **Clickable, inline-editable tracking label in ListView.** The tracking overlay used to show "47.6630, 9.1750" while tracking was active. Now it shows the venue name + full address with ISO country code, tapping it switches to an autocomplete input so the user can fix the location when GPS snaps to the wrong building. Debounced reverse-geocode on position change (1.5 s + 10 m precision), edits are kept local — the current tracking position drives the label but user corrections override until the next significant move. The client lib now uses relative URLs in the browser (same-origin proxy) and absolute URLs only from Node/SSR (via env var or localhost fallback). geocoding unit tests still pass (42/42 green). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0c1eb623bb
commit
f3cc853e08
3 changed files with 375 additions and 21 deletions
|
|
@ -14,15 +14,42 @@
|
|||
|
||||
export type PlaceCategory = 'home' | 'work' | 'food' | 'shopping' | 'transit' | 'leisure' | 'other';
|
||||
|
||||
/**
|
||||
* Where to send geocoding requests:
|
||||
* - **Browser**: same-origin proxy at `/api/v1/geocode/*` (handled by the
|
||||
* SvelteKit `+server.ts` under `routes/api/v1/geocode/[...path]`). This
|
||||
* keeps geocoding off the public internet — it's explicitly NOT exposed
|
||||
* via Cloudflare — and saves us an auth round-trip.
|
||||
* - **Node / SSR**: direct hit on the wrapper via `PUBLIC_MANA_GEOCODING_URL`
|
||||
* or `MANA_GEOCODING_INTERNAL_URL`, with a localhost fallback for dev.
|
||||
*
|
||||
* Because `PlacesListView` is client-only (reads `navigator.geolocation`),
|
||||
* browser → same-origin is by far the common path.
|
||||
*/
|
||||
const GEOCODING_URL = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const injected = (window as unknown as { __PUBLIC_MANA_GEOCODING_URL__?: string })
|
||||
.__PUBLIC_MANA_GEOCODING_URL__;
|
||||
if (injected) return injected;
|
||||
// Same-origin proxy — nothing to configure, nothing to leak.
|
||||
return '';
|
||||
}
|
||||
return import.meta.env.PUBLIC_MANA_GEOCODING_URL ?? 'http://localhost:3018';
|
||||
return (
|
||||
process.env.MANA_GEOCODING_INTERNAL_URL ??
|
||||
process.env.PUBLIC_MANA_GEOCODING_URL ??
|
||||
'http://localhost:3018'
|
||||
);
|
||||
};
|
||||
|
||||
/** Build a request URL that works in both the browser (relative) and Node (absolute). */
|
||||
function geocodeUrl(path: string, params: URLSearchParams): string {
|
||||
const base = GEOCODING_URL();
|
||||
const query = params.toString();
|
||||
if (base) {
|
||||
// Node / SSR path — absolute
|
||||
return `${base}/api/v1/geocode/${path}${query ? '?' + query : ''}`;
|
||||
}
|
||||
// Browser path — same-origin proxy
|
||||
return `/api/v1/geocode/${path}${query ? '?' + query : ''}`;
|
||||
}
|
||||
|
||||
export interface GeocodingAddress {
|
||||
street?: string;
|
||||
houseNumber?: string;
|
||||
|
|
@ -72,7 +99,7 @@ export async function searchAddress(
|
|||
if (options?.focusLon != null) params.set('focus.lon', String(options.focusLon));
|
||||
|
||||
try {
|
||||
const res = await fetch(`${GEOCODING_URL()}/api/v1/geocode/search?${params}`);
|
||||
const res = await fetch(geocodeUrl('search', params));
|
||||
if (!res.ok) return [];
|
||||
const data: GeocodingResponse = await res.json();
|
||||
return data.results;
|
||||
|
|
@ -96,7 +123,7 @@ export async function reverseGeocode(
|
|||
lon: String(lon),
|
||||
lang,
|
||||
});
|
||||
const res = await fetch(`${GEOCODING_URL()}/api/v1/geocode/reverse?${params}`);
|
||||
const res = await fetch(geocodeUrl('reverse', params));
|
||||
if (!res.ok) return null;
|
||||
const data: GeocodingResponse = await res.json();
|
||||
return data.results[0] ?? null;
|
||||
|
|
@ -136,3 +163,39 @@ export function formatLocality(result: GeocodingResult): string {
|
|||
if (a.city && a.country) return `${a.city}, ${a.country}`;
|
||||
return a.city ?? a.country ?? result.label ?? '';
|
||||
}
|
||||
|
||||
/** Map OSM country names to 2-letter codes (DE/AT/CH focus). */
|
||||
const COUNTRY_CODE: Record<string, string> = {
|
||||
Germany: 'DE',
|
||||
Deutschland: 'DE',
|
||||
Austria: 'AT',
|
||||
Österreich: 'AT',
|
||||
Switzerland: 'CH',
|
||||
Schweiz: 'CH',
|
||||
};
|
||||
|
||||
/**
|
||||
* Compact address label with street, house number, postal code and 2-letter
|
||||
* country. Used by the places tracking overlay where horizontal space is
|
||||
* tight.
|
||||
*
|
||||
* Examples:
|
||||
* "Hafenstraße 2, 78462 Konstanz, DE"
|
||||
* "Marienplatz 26, 80331 München, DE"
|
||||
* "78462 Konstanz, DE" (when no street is available)
|
||||
*/
|
||||
export function formatFullAddress(result: GeocodingResult): string {
|
||||
const a = result.address;
|
||||
const countryCode = a.country ? (COUNTRY_CODE[a.country] ?? a.country) : undefined;
|
||||
|
||||
const streetLine = a.street ? (a.houseNumber ? `${a.street} ${a.houseNumber}` : a.street) : '';
|
||||
|
||||
const cityLine = a.postalCode && a.city ? `${a.postalCode} ${a.city}` : (a.city ?? '');
|
||||
|
||||
const parts: string[] = [];
|
||||
if (streetLine) parts.push(streetLine);
|
||||
if (cityLine) parts.push(cityLine);
|
||||
if (countryCode) parts.push(countryCode);
|
||||
|
||||
return parts.join(', ') || result.label || '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
reverseGeocode,
|
||||
formatAddress,
|
||||
formatLocality,
|
||||
formatFullAddress,
|
||||
type GeocodingResult,
|
||||
} from '$lib/geocoding';
|
||||
import { Star, MapPin, Plus, PencilSimple, Trash, MagnifyingGlass } from '@mana/shared-icons';
|
||||
|
|
@ -66,17 +67,37 @@
|
|||
// the GeolocationPosition object is replaced on every update. We debounce
|
||||
// by ~1.5 s and round to ~10 m precision so we only hit the geocoding
|
||||
// service when the user has actually moved, not on every micro-jitter.
|
||||
let currentLocationLabel = $state<string | null>(null);
|
||||
let currentLocationResult = $state<GeocodingResult | null>(null);
|
||||
let lastReverseKey = '';
|
||||
let reverseDebounce: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
// Inline editing: user can tap the location label, type a different
|
||||
// address, and pick an autocomplete suggestion. Useful when GPS snaps
|
||||
// to a nearby building but the user is actually next door.
|
||||
let editingLocation = $state(false);
|
||||
let locationDraft = $state('');
|
||||
let locationSuggestions = $state<GeocodingResult[]>([]);
|
||||
let showLocationSuggestions = $state(false);
|
||||
let locationDebounce: ReturnType<typeof setTimeout> | undefined;
|
||||
let locationInputEl = $state<HTMLInputElement | null>(null);
|
||||
|
||||
const currentLocationFull = $derived(
|
||||
currentLocationResult ? formatFullAddress(currentLocationResult) : null
|
||||
);
|
||||
const currentLocationName = $derived(
|
||||
currentLocationResult
|
||||
? currentLocationResult.name || formatLocality(currentLocationResult)
|
||||
: null
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
const pos = trackingStore.currentPosition;
|
||||
if (!pos) {
|
||||
currentLocationLabel = null;
|
||||
currentLocationResult = null;
|
||||
lastReverseKey = '';
|
||||
return;
|
||||
}
|
||||
if (editingLocation) return; // don't clobber user typing
|
||||
|
||||
// Round to ~10 m precision (4 decimal places) so we don't re-fetch
|
||||
// on every tiny coordinate drift while standing still.
|
||||
|
|
@ -90,11 +111,57 @@
|
|||
reverseDebounce = setTimeout(async () => {
|
||||
const result = await reverseGeocode(pos.coords.latitude, pos.coords.longitude);
|
||||
if (result) {
|
||||
currentLocationLabel = formatLocality(result);
|
||||
currentLocationResult = result;
|
||||
}
|
||||
}, 1500);
|
||||
});
|
||||
|
||||
function startEditLocation() {
|
||||
locationDraft = currentLocationFull ?? '';
|
||||
editingLocation = true;
|
||||
queueMicrotask(() => {
|
||||
locationInputEl?.focus();
|
||||
locationInputEl?.select();
|
||||
});
|
||||
}
|
||||
|
||||
function cancelEditLocation() {
|
||||
editingLocation = false;
|
||||
showLocationSuggestions = false;
|
||||
locationSuggestions = [];
|
||||
locationDraft = '';
|
||||
}
|
||||
|
||||
function onLocationDraftInput() {
|
||||
clearTimeout(locationDebounce);
|
||||
const q = locationDraft.trim();
|
||||
if (q.length < 2) {
|
||||
locationSuggestions = [];
|
||||
showLocationSuggestions = false;
|
||||
return;
|
||||
}
|
||||
locationDebounce = setTimeout(async () => {
|
||||
const pos = trackingStore.currentPosition;
|
||||
locationSuggestions = await searchAddress(q, {
|
||||
limit: 6,
|
||||
focusLat: pos?.coords.latitude,
|
||||
focusLon: pos?.coords.longitude,
|
||||
});
|
||||
showLocationSuggestions = locationSuggestions.length > 0;
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function selectLocationSuggestion(result: GeocodingResult) {
|
||||
currentLocationResult = result;
|
||||
editingLocation = false;
|
||||
showLocationSuggestions = false;
|
||||
locationSuggestions = [];
|
||||
}
|
||||
|
||||
function onLocationKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') cancelEditLocation();
|
||||
}
|
||||
|
||||
// --- Address autocomplete ---
|
||||
let addressQuery = $state('');
|
||||
let suggestions = $state<GeocodingResult[]>([]);
|
||||
|
|
@ -232,18 +299,62 @@
|
|||
</button>
|
||||
{#if trackingStore.currentPosition}
|
||||
<div class="tracking-location">
|
||||
{#if currentLocationLabel}
|
||||
<span class="tracking-label">
|
||||
<MapPin size={10} />
|
||||
{currentLocationLabel}
|
||||
</span>
|
||||
{#if editingLocation}
|
||||
<div class="tracking-edit-wrapper">
|
||||
<input
|
||||
bind:this={locationInputEl}
|
||||
class="tracking-edit-input"
|
||||
type="text"
|
||||
bind:value={locationDraft}
|
||||
oninput={onLocationDraftInput}
|
||||
onkeydown={onLocationKeydown}
|
||||
onblur={() => setTimeout(cancelEditLocation, 200)}
|
||||
placeholder="Adresse suchen..."
|
||||
/>
|
||||
{#if showLocationSuggestions}
|
||||
<div class="tracking-suggestions">
|
||||
{#each locationSuggestions as result}
|
||||
<button
|
||||
type="button"
|
||||
class="tracking-suggestion"
|
||||
onclick={() => selectLocationSuggestion(result)}
|
||||
>
|
||||
<div class="tracking-suggestion-name">
|
||||
{result.name || formatLocality(result)}
|
||||
</div>
|
||||
<div class="tracking-suggestion-full">
|
||||
{formatFullAddress(result)}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="tracking-display"
|
||||
onclick={startEditLocation}
|
||||
title="Adresse bearbeiten"
|
||||
>
|
||||
{#if currentLocationName}
|
||||
<span class="tracking-label">
|
||||
<MapPin size={10} weight="fill" />
|
||||
{currentLocationName}
|
||||
</span>
|
||||
{/if}
|
||||
{#if currentLocationFull}
|
||||
<span class="tracking-address">{currentLocationFull}</span>
|
||||
{:else}
|
||||
<span class="tracking-coords">
|
||||
{formatCoords(
|
||||
trackingStore.currentPosition.coords.latitude,
|
||||
trackingStore.currentPosition.coords.longitude
|
||||
)}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<span class="tracking-coords">
|
||||
{formatCoords(
|
||||
trackingStore.currentPosition.coords.latitude,
|
||||
trackingStore.currentPosition.coords.longitude
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -449,11 +560,34 @@
|
|||
}
|
||||
|
||||
.tracking-location {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tracking-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.0625rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: 0.25rem 0.375rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: right;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
|
||||
.tracking-display:hover {
|
||||
background: rgba(14, 165, 233, 0.06);
|
||||
border-color: rgba(14, 165, 233, 0.2);
|
||||
}
|
||||
|
||||
.tracking-label {
|
||||
|
|
@ -462,19 +596,99 @@
|
|||
gap: 0.1875rem;
|
||||
font-size: 0.75rem;
|
||||
color: #0ea5e9;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.tracking-address {
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.tracking-coords {
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tracking-edit-wrapper {
|
||||
position: relative;
|
||||
min-width: 240px;
|
||||
max-width: 320px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tracking-edit-input {
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid #0ea5e9;
|
||||
background: hsl(var(--color-background));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.75rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tracking-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.18);
|
||||
z-index: 60;
|
||||
overflow: hidden;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tracking-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;
|
||||
}
|
||||
|
||||
.tracking-suggestion:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
}
|
||||
|
||||
.tracking-suggestion + .tracking-suggestion {
|
||||
border-top: 1px solid hsl(var(--color-border) / 0.5);
|
||||
}
|
||||
|
||||
.tracking-suggestion-name {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tracking-suggestion-full {
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tracking-error {
|
||||
font-size: 0.6875rem;
|
||||
color: #ef4444;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* Same-origin proxy for /api/v1/geocode/* → mana-geocoding:3018
|
||||
*
|
||||
* Why this proxy exists
|
||||
* ---------------------
|
||||
* mana-geocoding is intentionally NOT exposed via Cloudflare — we decided
|
||||
* early on that geocoding queries (which leak "where the user is looking")
|
||||
* should never leave our infrastructure. That decision means the browser
|
||||
* can't reach the wrapper directly: `http://localhost:3018` is unreachable
|
||||
* in production and `http://mana-geocoding:3018` is only valid inside the
|
||||
* docker network.
|
||||
*
|
||||
* Same-origin proxy fixes this: the browser calls
|
||||
* `https://mana.how/api/v1/geocode/search?q=...`, SvelteKit (running in
|
||||
* the mana-web container on the same docker network as mana-geocoding)
|
||||
* forwards the request to `http://mana-geocoding:3018/api/v1/geocode/*`,
|
||||
* and the response comes back through the same path. No new Cloudflare
|
||||
* route, no geocoding traffic on the public internet.
|
||||
*
|
||||
* The shared client lib at `$lib/geocoding` points at `/api/v1/geocode`
|
||||
* (relative URL) so both server-side rendering and browser-side calls
|
||||
* land on this handler.
|
||||
*
|
||||
* No auth required — geocoding is pure lookup and has no per-user data.
|
||||
* If we ever want to rate-limit by user we can add JWT verification here
|
||||
* without touching the upstream service.
|
||||
*
|
||||
* Also proxies /health and /health/pelias so the SvelteKit status page
|
||||
* (/status) can check the service from its server side.
|
||||
*/
|
||||
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
const UPSTREAM = process.env.MANA_GEOCODING_INTERNAL_URL || 'http://mana-geocoding:3018';
|
||||
const PROXY_TIMEOUT_MS = 15_000;
|
||||
|
||||
async function forward(request: Request, pathSegments: string): Promise<Response> {
|
||||
const upstreamUrl = `${UPSTREAM}/api/v1/geocode/${pathSegments}`;
|
||||
const incomingUrl = new URL(request.url);
|
||||
const finalUrl = incomingUrl.search ? `${upstreamUrl}${incomingUrl.search}` : upstreamUrl;
|
||||
|
||||
const headers = new Headers();
|
||||
const accept = request.headers.get('accept');
|
||||
if (accept) headers.set('accept', accept);
|
||||
|
||||
const init: RequestInit = {
|
||||
method: request.method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(PROXY_TIMEOUT_MS),
|
||||
};
|
||||
|
||||
let upstreamRes: Response;
|
||||
try {
|
||||
upstreamRes = await fetch(finalUrl, init);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw error(502, `geocoding proxy: ${message}`);
|
||||
}
|
||||
|
||||
const responseHeaders = new Headers();
|
||||
const upstreamContentType = upstreamRes.headers.get('content-type');
|
||||
if (upstreamContentType) responseHeaders.set('content-type', upstreamContentType);
|
||||
// Allow the browser to cache identical geocoding results for a minute.
|
||||
// The wrapper also has its own 24h LRU, so this is just a hint.
|
||||
responseHeaders.set('cache-control', 'private, max-age=60');
|
||||
|
||||
const body = await upstreamRes.text();
|
||||
return new Response(body, {
|
||||
status: upstreamRes.status,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ request, params }) => {
|
||||
return forward(request, params.path ?? '');
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue