feat: move nav bar to the side (#6)

Co-authored-by: terraputix <terraputix@mailbox.org>
Reviewed-on: useweb/ombrella#6
This commit is contained in:
2026-02-16 01:23:52 +01:00
co-authored by terraputix
parent 53e5fb6538
commit 9a7e66d5d9
29 changed files with 831 additions and 975 deletions
+169 -180
View File
@@ -1,14 +1,14 @@
<script lang="ts">
import { createEventDispatcher, onDestroy } from 'svelte';
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 * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import * as Popover from '$lib/components/ui/popover';
export let label: string = 'Search Locations...';
export let label: string = 'Search location...';
export let placeholder: string = 'Enter city name...';
interface ResultSet {
@@ -18,26 +18,32 @@
const dispatch = createEventDispatcher();
let debounceTimeout: ReturnType<typeof setTimeout> | undefined;
let searchQuery = '';
let popoverOpen = false;
let searchInputEl: HTMLInputElement | null = null;
onDestroy(() => {
clearInterval(debounceTimeout);
clearTimeout(debounceTimeout);
});
let scrollY: number | undefined;
const closeModal = () => {
dialogOpen = false;
if (scrollY) {
window.scrollTo({ top: scrollY, behavior: 'instant' });
}
const closePopover = () => {
popoverOpen = false;
};
const selectLocation = (location: GeoLocation) => {
searchQuery = '';
closeModal();
closePopover();
dispatch('location', location);
};
async function focusInput() {
await tick();
searchInputEl?.focus();
}
$: if (popoverOpen) {
focusInput();
}
$: results = (async () => {
if (debounceTimeout) {
clearTimeout(debounceTimeout);
@@ -91,187 +97,170 @@
return (await result.json()) as ResultSet;
})();
let dialogOpen = false;
</script>
<Dialog.Root bind:open={dialogOpen}>
<Dialog.Trigger
class="group flex h-14 w-full cursor-pointer items-center justify-start rounded-xl border-2 border-gray-200 bg-white px-6 transition-all duration-200 hover:border-blue-400 hover:shadow-md focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700"
onclick={(e) => {
e.preventDefault();
dialogOpen = !dialogOpen;
}}
<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="mr-3 h-5 w-5 text-gray-400 transition-colors group-hover:text-blue-500"
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="text-gray-600 transition-colors group-hover:text-gray-900 dark:text-gray-300 dark:group-hover:text-white"
>
{label}
</span>
</Dialog.Trigger>
<span class="overflow-hidden text-ellipsis whitespace-nowrap">{label}</span>
</Popover.Trigger>
<Dialog.Portal>
<Dialog.Overlay class="bg-black/20 backdrop-blur-sm" />
<Dialog.Content
class="top-[10%] flex max-h-[calc(100vh-10%)] min-h-[500px] translate-y-0 flex-col overflow-hidden rounded-2xl border-border bg-white shadow-2xl sm:max-w-[700px] dark:bg-gray-800"
>
<Dialog.Header class="pb-6">
<Dialog.Title class="text-center text-2xl font-bold">Find Your Location</Dialog.Title>
<p class="text-center text-gray-600 dark:text-gray-300">
Search for a city or use GPS to detect your location
</p>
</Dialog.Header>
<div class="flex-1 overflow-hidden">
<div class="px-6">
<div class="mb-6 flex gap-3">
<div class="flex-1">
<Input
type="search"
{placeholder}
class="h-12 text-lg"
autocomplete="off"
spellcheck="false"
aria-label="Search Location"
bind:value={searchQuery}
/>
</div>
<Button
variant="outline"
size="lg"
class="px-4"
title="Use GPS Location"
onclick={() => (searchQuery = 'GPS')}
>
<svg class="h-5 w-5" 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>
<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>
</div>
<div class="flex-1 overflow-y-auto px-6 pb-6">
{#await results}
<div class="flex h-32 items-center justify-center">
<div class="flex items-center space-x-3">
<div class="h-6 w-6 animate-spin rounded-full border-b-2 border-blue-600"></div>
<span class="text-gray-600 dark:text-gray-300">Searching...</span>
</div>
</div>
{:then results}
{#if results.results && results.results.length === 0}
{#if searchQuery.length < 2}
<Alert.Root class="border-blue-200 bg-blue-50 dark:bg-blue-900/20">
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<Alert.Description class="text-blue-700 dark:text-blue-300">
Start typing to search for locations or use GPS to detect your current position
</Alert.Description>
</Alert.Root>
{:else}
<Alert.Root class="border-orange-200 bg-orange-50 dark:bg-orange-900/20">
<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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.728-.833-2.498 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
<Alert.Description class="text-orange-700 dark:text-orange-300">
No locations found for "{searchQuery}". Try a different search 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-2">
{#each results.results || [] as location, i (i)}
<button
class="group w-full rounded-xl border border-gray-200 p-4 text-left transition-all duration-200 hover:border-blue-400 hover:bg-blue-50 dark:border-gray-600 dark:hover:bg-blue-900/20"
onclick={() => selectLocation(location)}
>
<div class="flex items-center justify-between">
<div class="flex flex-1 items-center space-x-4">
<img
class="h-10 w-10 rounded-full shadow-md"
src="/images/country-flags/{(
location.country_code || 'united_nations'
).toLowerCase()}.svg"
alt={location.country}
/>
<div class="flex-1">
<h3
class="font-semibold text-gray-900 group-hover:text-blue-600 dark:text-white dark:group-hover:text-blue-400"
>
{location.name}
</h3>
<p class="text-sm text-gray-600 dark:text-gray-300">
{location.admin1 || ''}
{location.country || ''}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{location.latitude.toFixed(2)}°N {location.longitude.toFixed(2)}°E
{#if location.elevation}{location.elevation.toFixed(0)}m{/if}
</p>
</div>
</div>
<svg
class="h-5 w-5 text-gray-400 group-hover:text-blue-500"
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}
<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>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<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>