revamp weather UI: cards, meteograms, units in header, location favorites and range controls
This commit is contained in:
@@ -1,7 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onDestroy, tick } from 'svelte';
|
||||
|
||||
import { type GeoLocation } from '$lib/stores/settings';
|
||||
import {
|
||||
type GeoLocation,
|
||||
locationKey,
|
||||
storedFavoriteLocations,
|
||||
storedRecentLocations
|
||||
} from '$lib/stores/settings';
|
||||
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
@@ -30,11 +35,31 @@
|
||||
};
|
||||
|
||||
const selectLocation = (location: GeoLocation) => {
|
||||
addRecent(location);
|
||||
searchQuery = '';
|
||||
closePopover();
|
||||
dispatch('location', location);
|
||||
};
|
||||
|
||||
function addRecent(loc: GeoLocation) {
|
||||
const key = locationKey(loc);
|
||||
storedRecentLocations.update((list) =>
|
||||
[loc, ...list.filter((l) => locationKey(l) !== key)].slice(0, 8)
|
||||
);
|
||||
}
|
||||
|
||||
function toggleFavorite(loc: GeoLocation) {
|
||||
const key = locationKey(loc);
|
||||
storedFavoriteLocations.update((list) =>
|
||||
list.some((l) => locationKey(l) === key)
|
||||
? list.filter((l) => locationKey(l) !== key)
|
||||
: [loc, ...list].slice(0, 24)
|
||||
);
|
||||
}
|
||||
|
||||
$: favKeys = new Set($storedFavoriteLocations.map(locationKey));
|
||||
$: recentToShow = $storedRecentLocations.filter((l) => !favKeys.has(locationKey(l)));
|
||||
|
||||
async function focusInput() {
|
||||
await tick();
|
||||
searchInputEl?.focus();
|
||||
@@ -101,6 +126,55 @@
|
||||
})();
|
||||
</script>
|
||||
|
||||
{#snippet locationRow(location: GeoLocation)}
|
||||
{@const fav = favKeys.has(locationKey(location))}
|
||||
<div
|
||||
class="group flex items-center rounded-md border border-transparent transition-[background,border-color] duration-150 hover:border-border hover:bg-accent"
|
||||
>
|
||||
<button
|
||||
class="flex min-w-0 flex-1 cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-2 text-left"
|
||||
onclick={() => selectLocation(location)}
|
||||
>
|
||||
<img
|
||||
class="h-7 w-7 shrink-0 rounded-full"
|
||||
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
|
||||
alt={location.country}
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium text-foreground">{location.name}</div>
|
||||
<div class="truncate 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>
|
||||
</button>
|
||||
<button
|
||||
class="mr-1 flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-md hover:bg-background hover:text-amber-500 {fav
|
||||
? 'text-amber-500'
|
||||
: 'text-muted-foreground/50'}"
|
||||
onclick={() => toggleFavorite(location)}
|
||||
aria-label={fav ? 'Remove from favorites' : 'Add to favorites'}
|
||||
title={fav ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill={fav ? 'currentColor' : 'none'}
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 3.6l2.5 5.1 5.6.8-4 3.9 1 5.6-5.1-2.7-5 2.7 1-5.6-4-3.9 5.5-.8z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<Popover.Root bind:open={popoverOpen}>
|
||||
<Popover.Trigger
|
||||
class="flex h-10 w-full cursor-pointer items-center gap-2.5 rounded-full border-2 border-primary/30 bg-background px-4 text-[0.8125rem] font-medium text-muted-foreground shadow-xs transition-[border-color,box-shadow] duration-150 hover:border-primary/70 hover:shadow-md"
|
||||
@@ -178,8 +252,35 @@
|
||||
</div>
|
||||
</div>
|
||||
{:then results}
|
||||
{#if results.results && results.results.length === 0}
|
||||
{#if searchQuery.length < 2}
|
||||
{#if searchQuery.length < 2}
|
||||
{#if $storedFavoriteLocations.length > 0 || recentToShow.length > 0}
|
||||
{#if $storedFavoriteLocations.length > 0}
|
||||
<div
|
||||
class="mb-1 px-1 text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
|
||||
>
|
||||
Favorites
|
||||
</div>
|
||||
<div class="space-y-0.5">
|
||||
{#each $storedFavoriteLocations as loc (locationKey(loc))}
|
||||
{@render locationRow(loc)}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if recentToShow.length > 0}
|
||||
<div
|
||||
class="mb-1 px-1 text-[11px] font-semibold tracking-wide text-muted-foreground uppercase {$storedFavoriteLocations.length
|
||||
? 'mt-3'
|
||||
: ''}"
|
||||
>
|
||||
Recent
|
||||
</div>
|
||||
<div class="space-y-0.5">
|
||||
{#each recentToShow as loc (locationKey(loc))}
|
||||
{@render locationRow(loc)}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div
|
||||
class="flex items-start gap-2 rounded-md bg-primary/8 p-2.5 text-muted-foreground"
|
||||
>
|
||||
@@ -200,62 +301,25 @@
|
||||
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}
|
||||
{:else if results.results && results.results.length > 0}
|
||||
<div class="space-y-0.5">
|
||||
{#each results.results as location, i (i)}
|
||||
{@render locationRow(location)}
|
||||
{/each}
|
||||
</div>
|
||||
{:else if results.results}
|
||||
<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>
|
||||
{:else}
|
||||
<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">
|
||||
|
||||
Reference in New Issue
Block a user