This repository has been archived on 2026-08-10. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
drizzli/src/lib/components/location/location-search.svelte
T
2026-02-16 01:23:52 +01:00

267 lines
7.7 KiB
Svelte

<script lang="ts">
import { createEventDispatcher, onDestroy, tick } from 'svelte';
import { type GeoLocation } from '$lib/stores/settings';
import * as Alert from '$lib/components/ui/alert';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import * as Popover from '$lib/components/ui/popover';
export let label: string = 'Search location...';
export let placeholder: string = 'Enter city name...';
interface ResultSet {
results: GeoLocation[] | undefined;
}
const dispatch = createEventDispatcher();
let debounceTimeout: ReturnType<typeof setTimeout> | undefined;
let searchQuery = '';
let popoverOpen = false;
let searchInputEl: HTMLInputElement | null = null;
onDestroy(() => {
clearTimeout(debounceTimeout);
});
const closePopover = () => {
popoverOpen = false;
};
const selectLocation = (location: GeoLocation) => {
searchQuery = '';
closePopover();
dispatch('location', location);
};
async function focusInput() {
await tick();
searchInputEl?.focus();
}
$: if (popoverOpen) {
focusInput();
}
$: results = (async () => {
if (debounceTimeout) {
clearTimeout(debounceTimeout);
}
if (searchQuery.length < 2) {
return { results: [] };
}
await new Promise((resolve) => {
debounceTimeout = setTimeout(resolve, 300);
});
if (searchQuery.toLowerCase() == 'gps') {
let position: GeolocationPosition = await new Promise((resolve, reject) =>
navigator.geolocation.getCurrentPosition(resolve, reject, {})
);
const latitude = position.coords.latitude;
const longitude = position.coords.longitude;
return {
results: [
{
id: 100000000 + Math.floor(latitude * 100 + longitude + 1000),
name: `GPS ${latitude.toFixed(2)}°N ${longitude.toFixed(2)}°E`,
latitude: latitude,
longitude: longitude,
elevation: position.coords.altitude ?? NaN,
feature_code: '',
country_code: undefined,
admin1_id: undefined,
admin3_id: undefined,
admin4_id: undefined,
timezone: '',
population: undefined,
postcodes: undefined,
country_id: undefined,
country: undefined,
admin1: undefined,
admin3: undefined,
admin4: undefined
}
]
};
}
const url = 'https://geocoding-api.open-meteo.com/v1/search';
const fetchUrl = `${url}?${new URLSearchParams({ name: searchQuery })}`;
const result = await fetch(fetchUrl);
if (!result.ok) {
throw new Error(await result.text());
}
return (await result.json()) as ResultSet;
})();
</script>
<Popover.Root bind:open={popoverOpen}>
<Popover.Trigger
class="flex h-9 w-full cursor-pointer items-center gap-2 rounded-md border border-border bg-background px-3 text-[0.8125rem] text-muted-foreground transition-[border-color,box-shadow] duration-150 hover:border-primary"
>
<svg
class="h-3.5 w-3.5 shrink-0 opacity-50"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<span class="overflow-hidden text-ellipsis whitespace-nowrap">{label}</span>
</Popover.Trigger>
<Popover.Content
class="popover-dropdown w-(--bits-popover-anchor-width) min-w-[320px] p-0"
side="bottom"
align="start"
sideOffset={4}
onOpenAutoFocus={(e) => {
e.preventDefault();
focusInput();
}}
>
<div class="flex flex-col">
<div class="p-3">
<div class="flex gap-2">
<div class="flex-1">
<Input
type="search"
{placeholder}
class="h-9"
autocomplete="off"
spellcheck="false"
aria-label="Search Location"
bind:value={searchQuery}
bind:ref={searchInputEl}
/>
</div>
<Button
variant="outline"
size="default"
class="h-9 px-2.5"
title="Use GPS Location"
onclick={() => (searchQuery = 'GPS')}
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</Button>
</div>
</div>
<div class="max-h-[min(400px,50vh)] overflow-y-auto px-3 pb-3">
{#await results}
<div class="flex h-20 items-center justify-center">
<div class="flex items-center space-x-2">
<div class="h-4 w-4 animate-spin rounded-full border-b-2 border-primary"></div>
<span class="text-sm text-muted-foreground">Searching...</span>
</div>
</div>
{:then results}
{#if results.results && results.results.length === 0}
{#if searchQuery.length < 2}
<div
class="flex items-start gap-2 rounded-md bg-primary/8 p-2.5 text-muted-foreground"
>
<svg
class="mt-0.5 h-3.5 w-3.5 shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="text-xs">
Start typing to search or use GPS to detect your position
</span>
</div>
{:else}
<Alert.Root
class="border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-900/20"
>
<Alert.Description class="text-orange-700 dark:text-orange-300">
No locations found for "{searchQuery}". Try a different term.
</Alert.Description>
</Alert.Root>
{/if}
{:else if !results.results}
<Alert.Root variant="destructive">
<Alert.Description>No locations found</Alert.Description>
</Alert.Root>
{:else}
<div class="space-y-0.5">
{#each results.results || [] as location, i (i)}
<button
class="group block w-full cursor-pointer rounded-md border border-transparent bg-transparent px-2.5 py-2 transition-[background,border-color] duration-150 hover:border-border hover:bg-accent"
onclick={() => selectLocation(location)}
>
<div class="flex items-center gap-2.5">
<img
class="h-7 w-7 rounded-full"
src="/images/country-flags/{(
location.country_code || 'united_nations'
).toLowerCase()}.svg"
alt={location.country}
/>
<div class="flex-1 text-left">
<div class="text-sm font-medium text-foreground">
{location.name}
</div>
<div class="text-xs text-muted-foreground">
{location.admin1 || ''}
{location.country || ''}
· {location.latitude.toFixed(2)}°N {location.longitude.toFixed(2)}°E
{#if location.elevation}· {location.elevation.toFixed(0)}m{/if}
</div>
</div>
<svg
class="h-3.5 w-3.5 text-muted-foreground opacity-0 transition-opacity duration-150 group-hover:opacity-100"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</button>
{/each}
</div>
{/if}
{:catch error}
<Alert.Root variant="destructive">
<Alert.Description>Error: {error.message}</Alert.Description>
</Alert.Root>
{/await}
</div>
</div>
</Popover.Content>
</Popover.Root>