mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-20 05:43:40 +02:00
feat(places): add self-hosted geocoding with Pelias (DACH)
New mana-geocoding service (port 3018) wraps a self-hosted Pelias instance with LRU caching and OSM→PlaceCategory auto-mapping. All geocoding queries stay within our infrastructure — no user location data leaves the network. Places module integration: - Address autocomplete search in ListView (creates place with name, coords, address, category in one step) - Address search + reverse geocoding button in DetailView - Auto-fill address via reverse geocoding during tracking - OSM category mapping (amenity:restaurant→food, shop:*→shopping, etc.) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f5ad492371
commit
a47a7bfdba
21 changed files with 1519 additions and 34 deletions
|
|
@ -7,7 +7,8 @@
|
|||
import { useAllPlaces } from './queries';
|
||||
import { placesStore } from './stores/places.svelte';
|
||||
import { trackingStore } from './stores/tracking.svelte';
|
||||
import { Star, MapPin, Plus, PencilSimple, Trash } from '@mana/shared-icons';
|
||||
import { searchAddress, formatAddress, type GeocodingResult } from './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';
|
||||
import { dropTarget, dragSource } from '@mana/shared-ui/dnd';
|
||||
|
|
@ -54,7 +55,58 @@
|
|||
other: 'Sonstiges',
|
||||
};
|
||||
|
||||
// Quick create
|
||||
// --- Address autocomplete ---
|
||||
let addressQuery = $state('');
|
||||
let suggestions = $state<GeocodingResult[]>([]);
|
||||
let showSuggestions = $state(false);
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function onAddressInput() {
|
||||
clearTimeout(debounceTimer);
|
||||
if (addressQuery.trim().length < 2) {
|
||||
suggestions = [];
|
||||
showSuggestions = false;
|
||||
return;
|
||||
}
|
||||
debounceTimer = setTimeout(async () => {
|
||||
const focusLat = trackingStore.currentPosition?.coords.latitude;
|
||||
const focusLon = trackingStore.currentPosition?.coords.longitude;
|
||||
suggestions = await searchAddress(addressQuery, {
|
||||
limit: 6,
|
||||
focusLat,
|
||||
focusLon,
|
||||
});
|
||||
showSuggestions = suggestions.length > 0;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async function selectSuggestion(result: GeocodingResult) {
|
||||
showSuggestions = false;
|
||||
addressQuery = '';
|
||||
const place = await placesStore.createPlace({
|
||||
name: result.name || result.label,
|
||||
latitude: result.latitude,
|
||||
longitude: result.longitude,
|
||||
address: formatAddress(result.address),
|
||||
category: result.category,
|
||||
});
|
||||
navigate('detail', { placeId: place.id });
|
||||
}
|
||||
|
||||
function onAddressKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
showSuggestions = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onAddressBlur() {
|
||||
// Delay to allow click on suggestion
|
||||
setTimeout(() => {
|
||||
showSuggestions = false;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
// Quick create (manual name)
|
||||
let newName = $state('');
|
||||
|
||||
async function createPlace() {
|
||||
|
|
@ -157,12 +209,51 @@
|
|||
<input class="search-input" type="text" placeholder="Orte suchen..." bind:value={search} />
|
||||
</div>
|
||||
|
||||
<!-- Quick Create -->
|
||||
<!-- Address Search (Geocoding) -->
|
||||
<div class="address-search">
|
||||
<div class="address-input-row">
|
||||
<MagnifyingGlass size={14} class="address-icon" />
|
||||
<input
|
||||
class="address-input"
|
||||
type="text"
|
||||
placeholder="Adresse suchen..."
|
||||
bind:value={addressQuery}
|
||||
oninput={onAddressInput}
|
||||
onkeydown={onAddressKeydown}
|
||||
onblur={onAddressBlur}
|
||||
onfocus={() => {
|
||||
if (suggestions.length > 0) showSuggestions = true;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{#if showSuggestions}
|
||||
<div class="suggestions">
|
||||
{#each suggestions as result}
|
||||
<button class="suggestion-item" onclick={() => selectSuggestion(result)}>
|
||||
<div class="suggestion-icon">
|
||||
<MapPin size={14} />
|
||||
</div>
|
||||
<div class="suggestion-info">
|
||||
<span class="suggestion-name">{result.name || result.label}</span>
|
||||
<span class="suggestion-address">{formatAddress(result.address)}</span>
|
||||
</div>
|
||||
{#if result.category !== 'other'}
|
||||
<span class="suggestion-category"
|
||||
>{CATEGORY_LABELS[result.category] ?? result.category}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Quick Create (manual) -->
|
||||
<div class="create-row">
|
||||
<input
|
||||
class="create-input"
|
||||
type="text"
|
||||
placeholder="Neuen Ort erstellen..."
|
||||
placeholder="Oder manuell erstellen..."
|
||||
bind:value={newName}
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
|
|
@ -344,6 +435,120 @@
|
|||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
/* ── Address Search ───────────────────────── */
|
||||
.address-search {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.address-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: transparent;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.address-input-row:focus-within {
|
||||
border-color: #0ea5e9;
|
||||
}
|
||||
|
||||
.address-input-row :global(.address-icon) {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.address-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.8125rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.address-input::placeholder {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.suggestion-item {
|
||||
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;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.suggestion-item:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
}
|
||||
|
||||
.suggestion-item + .suggestion-item {
|
||||
border-top: 1px solid hsl(var(--color-border) / 0.5);
|
||||
}
|
||||
|
||||
.suggestion-icon {
|
||||
color: #0ea5e9;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.suggestion-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.0625rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.suggestion-name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.suggestion-address {
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.suggestion-category {
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 9999px;
|
||||
background: rgba(14, 165, 233, 0.1);
|
||||
color: #0ea5e9;
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Quick Create ─────────────────────────── */
|
||||
.create-row {
|
||||
display: flex;
|
||||
|
|
|
|||
117
apps/mana/apps/web/src/lib/modules/places/geocoding.ts
Normal file
117
apps/mana/apps/web/src/lib/modules/places/geocoding.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* Geocoding client for the Places module.
|
||||
*
|
||||
* Talks to our self-hosted mana-geocoding service (Pelias-backed).
|
||||
* All queries stay within our infrastructure — no location data
|
||||
* leaves the network.
|
||||
*/
|
||||
|
||||
import type { PlaceCategory } from './types';
|
||||
|
||||
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;
|
||||
}
|
||||
return import.meta.env.PUBLIC_MANA_GEOCODING_URL ?? 'http://localhost:3018';
|
||||
};
|
||||
|
||||
export interface GeocodingResult {
|
||||
label: string;
|
||||
name: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
address: {
|
||||
street?: string;
|
||||
houseNumber?: string;
|
||||
postalCode?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
country?: string;
|
||||
};
|
||||
category: PlaceCategory;
|
||||
osmCategory?: string;
|
||||
osmType?: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
interface GeocodingResponse {
|
||||
results: GeocodingResult[];
|
||||
cached?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward geocoding / autocomplete.
|
||||
* Returns places matching the search query, biased towards the focus point.
|
||||
*/
|
||||
export async function searchAddress(
|
||||
query: string,
|
||||
options?: {
|
||||
limit?: number;
|
||||
focusLat?: number;
|
||||
focusLon?: number;
|
||||
lang?: string;
|
||||
}
|
||||
): Promise<GeocodingResult[]> {
|
||||
if (!query || query.trim().length < 2) return [];
|
||||
|
||||
const params = new URLSearchParams({ q: query.trim() });
|
||||
if (options?.limit) params.set('limit', String(options.limit));
|
||||
if (options?.lang) params.set('lang', options.lang);
|
||||
if (options?.focusLat != null) params.set('focus.lat', String(options.focusLat));
|
||||
if (options?.focusLon != null) params.set('focus.lon', String(options.focusLon));
|
||||
|
||||
try {
|
||||
const res = await fetch(`${GEOCODING_URL()}/api/v1/geocode/search?${params}`);
|
||||
if (!res.ok) return [];
|
||||
const data: GeocodingResponse = await res.json();
|
||||
return data.results;
|
||||
} catch {
|
||||
console.warn('Geocoding search failed — service may be offline');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse geocoding — resolve coordinates to an address and place type.
|
||||
*/
|
||||
export async function reverseGeocode(
|
||||
lat: number,
|
||||
lon: number,
|
||||
lang = 'de'
|
||||
): Promise<GeocodingResult | null> {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
lat: String(lat),
|
||||
lon: String(lon),
|
||||
lang,
|
||||
});
|
||||
const res = await fetch(`${GEOCODING_URL()}/api/v1/geocode/reverse?${params}`);
|
||||
if (!res.ok) return null;
|
||||
const data: GeocodingResponse = await res.json();
|
||||
return data.results[0] ?? null;
|
||||
} catch {
|
||||
console.warn('Reverse geocoding failed — service may be offline');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a structured address into a single-line string.
|
||||
*/
|
||||
export function formatAddress(address: GeocodingResult['address']): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (address.street) {
|
||||
parts.push(address.houseNumber ? `${address.street} ${address.houseNumber}` : address.street);
|
||||
}
|
||||
if (address.postalCode && address.city) {
|
||||
parts.push(`${address.postalCode} ${address.city}`);
|
||||
} else if (address.city) {
|
||||
parts.push(address.city);
|
||||
}
|
||||
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
|
@ -16,4 +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';
|
||||
export type { LocalPlace, LocalLocationLog, Place, LocationLog, PlaceCategory } from './types';
|
||||
|
|
|
|||
|
|
@ -9,6 +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 type { LocalLocationLog, LocalPlace } from '../types';
|
||||
|
||||
// ─── State ──────────────────────────────────────────────
|
||||
|
|
@ -139,11 +140,30 @@ async function logPosition(pos: GeolocationPosition) {
|
|||
if (nearest) {
|
||||
const local = await placeTable.get(nearest.id);
|
||||
if (local) {
|
||||
await placeTable.update(nearest.id, {
|
||||
const updates: Partial<LocalPlace> = {
|
||||
visitCount: (local.visitCount ?? 0) + 1,
|
||||
lastVisitedAt: log.timestamp,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
// Auto-fill address via reverse geocoding if the place has none
|
||||
if (!local.address) {
|
||||
reverseGeocode(lat, lng).then(async (result) => {
|
||||
if (result) {
|
||||
const addr = formatAddress(result.address);
|
||||
if (addr) {
|
||||
const rec: Partial<LocalPlace> = { address: addr };
|
||||
await encryptRecord('places', rec);
|
||||
await placeTable.update(nearest.id, {
|
||||
address: rec.address,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await placeTable.update(nearest.id, updates);
|
||||
|
||||
await createBlock({
|
||||
startDate: log.timestamp,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@
|
|||
import { useDetailEntity } from '$lib/data/detail-entity.svelte';
|
||||
import DetailViewShell from '$lib/components/DetailViewShell.svelte';
|
||||
import { placesStore } from '../stores/places.svelte';
|
||||
import { Star, MapPin, X } from '@mana/shared-icons';
|
||||
import { reverseGeocode, formatAddress, searchAddress, type GeocodingResult } from '../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';
|
||||
import { useAllTags, getTagsByIds } from '@mana/shared-stores';
|
||||
|
|
@ -64,6 +65,64 @@
|
|||
{ value: 'other', label: 'Sonstiges' },
|
||||
];
|
||||
|
||||
// --- Reverse geocoding (coords → address) ---
|
||||
let isResolving = $state(false);
|
||||
|
||||
async function resolveAddress() {
|
||||
const lat = parseFloat(editLatitude);
|
||||
const lng = parseFloat(editLongitude);
|
||||
if (isNaN(lat) || isNaN(lng) || (lat === 0 && lng === 0)) return;
|
||||
|
||||
isResolving = true;
|
||||
const result = await reverseGeocode(lat, lng);
|
||||
isResolving = false;
|
||||
|
||||
if (result) {
|
||||
editAddress = formatAddress(result.address);
|
||||
if (editCategory === 'other' && result.category !== 'other') {
|
||||
editCategory = result.category;
|
||||
}
|
||||
await saveField();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Address search in detail view ---
|
||||
let addressSearch = $state('');
|
||||
let addressSuggestions = $state<GeocodingResult[]>([]);
|
||||
let showAddressSuggestions = $state(false);
|
||||
let addressDebounce: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function onAddressSearchInput() {
|
||||
clearTimeout(addressDebounce);
|
||||
if (addressSearch.trim().length < 2) {
|
||||
addressSuggestions = [];
|
||||
showAddressSuggestions = false;
|
||||
return;
|
||||
}
|
||||
addressDebounce = setTimeout(async () => {
|
||||
addressSuggestions = await searchAddress(addressSearch, { limit: 5 });
|
||||
showAddressSuggestions = addressSuggestions.length > 0;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async function applyAddressResult(result: GeocodingResult) {
|
||||
showAddressSuggestions = false;
|
||||
addressSearch = '';
|
||||
editAddress = formatAddress(result.address);
|
||||
editLatitude = String(result.latitude);
|
||||
editLongitude = String(result.longitude);
|
||||
if (result.category !== 'other') {
|
||||
editCategory = result.category;
|
||||
}
|
||||
await saveField();
|
||||
}
|
||||
|
||||
function onAddressSearchBlur() {
|
||||
setTimeout(() => {
|
||||
showAddressSuggestions = false;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
async function removeTag(tagId: string) {
|
||||
await removeTagIdWithUndo(detail.entity?.tagIds ?? [], tagId, (next) =>
|
||||
placesStore.updateTagIds(placeId, next)
|
||||
|
|
@ -181,6 +240,37 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<!-- Address Search -->
|
||||
<div class="field-row address-search-row">
|
||||
<span class="field-label"></span>
|
||||
<div class="address-search-wrapper">
|
||||
<div class="address-search-input-row">
|
||||
<MagnifyingGlass size={12} />
|
||||
<input
|
||||
class="address-search-input"
|
||||
type="text"
|
||||
placeholder="Adresse suchen..."
|
||||
bind:value={addressSearch}
|
||||
oninput={onAddressSearchInput}
|
||||
onblur={onAddressSearchBlur}
|
||||
onfocus={() => {
|
||||
if (addressSuggestions.length > 0) showAddressSuggestions = true;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{#if showAddressSuggestions}
|
||||
<div class="address-suggestions">
|
||||
{#each addressSuggestions as result}
|
||||
<button class="address-suggestion" onclick={() => applyAddressResult(result)}>
|
||||
<span class="address-suggestion-name">{result.name || result.label}</span>
|
||||
<span class="address-suggestion-detail">{formatAddress(result.address)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<span class="field-label">Koordinaten</span>
|
||||
<div class="coords-row">
|
||||
|
|
@ -202,6 +292,14 @@
|
|||
type="number"
|
||||
step="any"
|
||||
/>
|
||||
<button
|
||||
class="resolve-btn"
|
||||
onclick={resolveAddress}
|
||||
disabled={isResolving}
|
||||
title="Adresse aus Koordinaten ermitteln"
|
||||
>
|
||||
<ArrowsClockwise size={14} class={isResolving ? 'spinning' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -359,6 +457,134 @@
|
|||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.resolve-btn {
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.resolve-btn:hover:not(:disabled) {
|
||||
color: #0ea5e9;
|
||||
border-color: hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.resolve-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.resolve-btn :global(.spinning) {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Address Search (Detail) ─────────────── */
|
||||
.address-search-row {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.address-search-wrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.address-search-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.1875rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.address-search-input-row:focus-within {
|
||||
border-color: hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.address-search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.75rem;
|
||||
outline: none;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.address-search-input::placeholder {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.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;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.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-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.address-suggestion-detail {
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tags-list {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue