feat: move nav bar to the side (#6)
Co-authored-by: terraputix <terraputix@mailbox.org> Reviewed-on: useweb/open-meteo-blue#6
This commit was merged in pull request #6.
This commit is contained in:
+1
-1
@@ -14,7 +14,7 @@
|
|||||||
"lint": "prettier --check . && eslint .",
|
"lint": "prettier --check . && eslint .",
|
||||||
"test:unit": "vitest",
|
"test:unit": "vitest",
|
||||||
"test": "npm run test:unit -- --run",
|
"test": "npm run test:unit -- --run",
|
||||||
"upgrade:ui": "npx shadcn-svelte@latest add alert button card checkbox dialog input label select separator switch -y -o && prettier --write src/lib/components/ui"
|
"upgrade:ui": "npx shadcn-svelte@latest add alert button card checkbox dialog input label popover select separator switch -y -o && prettier --write src/lib/components/ui"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^1.4.0",
|
"@eslint/compat": "^1.4.0",
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher, onDestroy } from 'svelte';
|
import { createEventDispatcher, onDestroy, tick } from 'svelte';
|
||||||
|
|
||||||
import { type GeoLocation } from '$lib/stores/settings';
|
import { type GeoLocation } from '$lib/stores/settings';
|
||||||
|
|
||||||
import * as Alert from '$lib/components/ui/alert';
|
import * as Alert from '$lib/components/ui/alert';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Dialog from '$lib/components/ui/dialog';
|
|
||||||
import { Input } from '$lib/components/ui/input';
|
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...';
|
export let placeholder: string = 'Enter city name...';
|
||||||
|
|
||||||
interface ResultSet {
|
interface ResultSet {
|
||||||
@@ -18,26 +18,32 @@
|
|||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
let debounceTimeout: ReturnType<typeof setTimeout> | undefined;
|
let debounceTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
let searchQuery = '';
|
let searchQuery = '';
|
||||||
|
let popoverOpen = false;
|
||||||
|
let searchInputEl: HTMLInputElement | null = null;
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
clearInterval(debounceTimeout);
|
clearTimeout(debounceTimeout);
|
||||||
});
|
});
|
||||||
|
|
||||||
let scrollY: number | undefined;
|
const closePopover = () => {
|
||||||
|
popoverOpen = false;
|
||||||
const closeModal = () => {
|
|
||||||
dialogOpen = false;
|
|
||||||
if (scrollY) {
|
|
||||||
window.scrollTo({ top: scrollY, behavior: 'instant' });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectLocation = (location: GeoLocation) => {
|
const selectLocation = (location: GeoLocation) => {
|
||||||
searchQuery = '';
|
searchQuery = '';
|
||||||
closeModal();
|
closePopover();
|
||||||
dispatch('location', location);
|
dispatch('location', location);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function focusInput() {
|
||||||
|
await tick();
|
||||||
|
searchInputEl?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if (popoverOpen) {
|
||||||
|
focusInput();
|
||||||
|
}
|
||||||
|
|
||||||
$: results = (async () => {
|
$: results = (async () => {
|
||||||
if (debounceTimeout) {
|
if (debounceTimeout) {
|
||||||
clearTimeout(debounceTimeout);
|
clearTimeout(debounceTimeout);
|
||||||
@@ -91,187 +97,170 @@
|
|||||||
|
|
||||||
return (await result.json()) as ResultSet;
|
return (await result.json()) as ResultSet;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
let dialogOpen = false;
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog.Root bind:open={dialogOpen}>
|
<Popover.Root bind:open={popoverOpen}>
|
||||||
<Dialog.Trigger
|
<Popover.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"
|
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"
|
||||||
onclick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
dialogOpen = !dialogOpen;
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<svg
|
<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"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
>
|
>
|
||||||
<circle cx="11" cy="11" r="8" />
|
<circle cx="11" cy="11" r="8" />
|
||||||
<path d="m21 21-4.3-4.3" />
|
<path d="m21 21-4.3-4.3" />
|
||||||
</svg>
|
</svg>
|
||||||
<span
|
<span class="overflow-hidden text-ellipsis whitespace-nowrap">{label}</span>
|
||||||
class="text-gray-600 transition-colors group-hover:text-gray-900 dark:text-gray-300 dark:group-hover:text-white"
|
</Popover.Trigger>
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
</Dialog.Trigger>
|
|
||||||
|
|
||||||
<Dialog.Portal>
|
<Popover.Content
|
||||||
<Dialog.Overlay class="bg-black/20 backdrop-blur-sm" />
|
class="popover-dropdown w-(--bits-popover-anchor-width) min-w-[320px] p-0"
|
||||||
|
side="bottom"
|
||||||
<Dialog.Content
|
align="start"
|
||||||
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"
|
sideOffset={4}
|
||||||
>
|
onOpenAutoFocus={(e) => {
|
||||||
<Dialog.Header class="pb-6">
|
e.preventDefault();
|
||||||
<Dialog.Title class="text-center text-2xl font-bold">Find Your Location</Dialog.Title>
|
focusInput();
|
||||||
<p class="text-center text-gray-600 dark:text-gray-300">
|
}}
|
||||||
Search for a city or use GPS to detect your location
|
>
|
||||||
</p>
|
<div class="flex flex-col">
|
||||||
</Dialog.Header>
|
<div class="p-3">
|
||||||
|
<div class="flex gap-2">
|
||||||
<div class="flex-1 overflow-hidden">
|
<div class="flex-1">
|
||||||
<div class="px-6">
|
<Input
|
||||||
<div class="mb-6 flex gap-3">
|
type="search"
|
||||||
<div class="flex-1">
|
{placeholder}
|
||||||
<Input
|
class="h-9"
|
||||||
type="search"
|
autocomplete="off"
|
||||||
{placeholder}
|
spellcheck="false"
|
||||||
class="h-12 text-lg"
|
aria-label="Search Location"
|
||||||
autocomplete="off"
|
bind:value={searchQuery}
|
||||||
spellcheck="false"
|
bind:ref={searchInputEl}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Button
|
||||||
|
variant="outline"
|
||||||
<div class="flex-1 overflow-y-auto px-6 pb-6">
|
size="default"
|
||||||
{#await results}
|
class="h-9 px-2.5"
|
||||||
<div class="flex h-32 items-center justify-center">
|
title="Use GPS Location"
|
||||||
<div class="flex items-center space-x-3">
|
onclick={() => (searchQuery = 'GPS')}
|
||||||
<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>
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</div>
|
<path
|
||||||
</div>
|
stroke-linecap="round"
|
||||||
{:then results}
|
stroke-linejoin="round"
|
||||||
{#if results.results && results.results.length === 0}
|
stroke-width="2"
|
||||||
{#if searchQuery.length < 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"
|
||||||
<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
|
||||||
<path
|
stroke-linecap="round"
|
||||||
stroke-linecap="round"
|
stroke-linejoin="round"
|
||||||
stroke-linejoin="round"
|
stroke-width="2"
|
||||||
stroke-width="2"
|
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
/>
|
||||||
/>
|
</svg>
|
||||||
</svg>
|
</Button>
|
||||||
<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}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Portal>
|
<div class="max-h-[min(400px,50vh)] overflow-y-auto px-3 pb-3">
|
||||||
</Dialog.Root>
|
{#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>
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import { buildLocationRoute } from '$lib/utils/location';
|
||||||
|
|
||||||
|
import LocationSearch from '$lib/components/location/location-search.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onMenuToggle?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onMenuToggle }: Props = $props();
|
||||||
|
|
||||||
|
let location = $state(get(storedLocation));
|
||||||
|
|
||||||
|
storedLocation.subscribe((value) => {
|
||||||
|
location = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
function navigateToLocation(newLocation: GeoLocation) {
|
||||||
|
storedLocation.set(newLocation);
|
||||||
|
const locationRoute = buildLocationRoute(newLocation);
|
||||||
|
const currentPath = get(page).url.pathname;
|
||||||
|
|
||||||
|
if (currentPath.startsWith('/weather/compare')) {
|
||||||
|
goto(resolve('/weather/compare/[location]', { location: locationRoute }));
|
||||||
|
} else if (currentPath.startsWith('/weather/14-day')) {
|
||||||
|
goto(resolve('/weather/14-day/[location]', { location: locationRoute }));
|
||||||
|
} else {
|
||||||
|
goto(resolve('/weather/week/[location]', { location: locationRoute }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header
|
||||||
|
class="topbar flex h-14 shrink-0 items-center gap-3 border-b border-topbar-border bg-topbar px-3 md:px-4"
|
||||||
|
>
|
||||||
|
<!-- Mobile menu toggle -->
|
||||||
|
<button
|
||||||
|
class="flex h-8 w-8 items-center justify-center rounded-md transition-colors hover:bg-black/5 md:hidden dark:hover:bg-white/10"
|
||||||
|
onclick={onMenuToggle}
|
||||||
|
aria-label="Toggle menu"
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.75">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Current location display -->
|
||||||
|
{#if location}
|
||||||
|
<div class="hidden items-center gap-2 sm:flex">
|
||||||
|
<img
|
||||||
|
class="h-5 w-5 rounded-full"
|
||||||
|
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
|
||||||
|
alt={location.country}
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium text-foreground">
|
||||||
|
{location.name}
|
||||||
|
</span>
|
||||||
|
{#if location.admin1 || location.country}
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
{#if location.admin1}{location.admin1},{/if}
|
||||||
|
{location.country}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Spacer -->
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
|
<!-- Location search -->
|
||||||
|
<div class="w-full max-w-sm md:max-w-md">
|
||||||
|
<LocationSearch
|
||||||
|
label="Search location..."
|
||||||
|
on:location={(event) => {
|
||||||
|
navigateToLocation(event.detail);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.topbar {
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,30 +2,32 @@
|
|||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
interface Props {
|
||||||
|
collapsed?: boolean;
|
||||||
|
onToggle?: () => void;
|
||||||
|
onMobileClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { collapsed = false, onToggle, onMobileClose }: Props = $props();
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{
|
{
|
||||||
title: 'Current Weather',
|
title: '7-Day Forecast',
|
||||||
url: '/weather/week',
|
url: '/weather/week' as const,
|
||||||
description: 'Current conditions and overview',
|
icon: 'calendar'
|
||||||
icon: '🌡️'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Model Comparison',
|
title: 'Model Comparison',
|
||||||
url: '/weather/compare',
|
url: '/weather/compare' as const,
|
||||||
description: 'Compare multiple weather models',
|
icon: 'chart'
|
||||||
icon: '📊'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '14 Day Forecast',
|
title: '14-Day Forecast',
|
||||||
url: '/weather/14-day',
|
url: '/weather/14-day' as const,
|
||||||
description: 'Extended forecast with uncertainty',
|
icon: 'extended'
|
||||||
icon: '📅'
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
let mobileNavOpened = $state(false);
|
|
||||||
let currentPath = $derived($page.url.pathname);
|
let currentPath = $derived($page.url.pathname);
|
||||||
|
|
||||||
const isActive = (url: string) => {
|
const isActive = (url: string) => {
|
||||||
@@ -33,111 +35,164 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav
|
<aside
|
||||||
class="sticky top-0 z-50 border-b border-gray-200/50 bg-white/90 shadow-sm backdrop-blur-lg dark:border-gray-700/50 dark:bg-gray-900/90"
|
class="flex h-full flex-col border-r border-sidebar-border bg-sidebar transition-all duration-200"
|
||||||
|
class:w-55={!collapsed}
|
||||||
|
class:w-14={collapsed}
|
||||||
>
|
>
|
||||||
<div class="container mx-auto px-6">
|
<!-- Sidebar header -->
|
||||||
<div class="flex h-16 items-center justify-between">
|
<div class="flex items-center border-b border-sidebar-border px-3 py-4">
|
||||||
<!-- Logo -->
|
{#if !collapsed}
|
||||||
<div class="flex items-center space-x-3">
|
<a
|
||||||
<div class="rounded-lg bg-gradient-to-r from-blue-600 to-purple-600 p-2">
|
href={resolve('/weather/week')}
|
||||||
<svg class="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
class="flex items-center gap-2.5 px-1"
|
||||||
|
onclick={onMobileClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4.5 w-4.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
stroke-width="2"
|
|
||||||
d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z"
|
d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<span class="text-sm font-semibold whitespace-nowrap text-sidebar-foreground">
|
||||||
href={resolve('/')}
|
Open-Meteo
|
||||||
class="text-xl font-bold text-gray-900 transition-colors hover:text-blue-600 dark:text-white"
|
</span>
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<a
|
||||||
|
href={resolve('/weather/week')}
|
||||||
|
class="mx-auto flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground"
|
||||||
|
onclick={onMobileClose}
|
||||||
|
aria-label="Open-Meteo Home"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4.5 w-4.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
>
|
>
|
||||||
Open-Meteo Weather
|
<path
|
||||||
</a>
|
stroke-linecap="round"
|
||||||
</div>
|
stroke-linejoin="round"
|
||||||
|
d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z"
|
||||||
<!-- Desktop Navigation -->
|
/>
|
||||||
<div class="hidden items-center space-x-2 md:flex">
|
</svg>
|
||||||
{#each links as link (link.title)}
|
</a>
|
||||||
<Button
|
|
||||||
href={link.url}
|
|
||||||
variant={isActive(link.url) ? 'default' : 'ghost'}
|
|
||||||
size="sm"
|
|
||||||
class="group relative px-4 py-2 {isActive(link.url)
|
|
||||||
? 'bg-blue-600 text-white shadow-md'
|
|
||||||
: 'hover:bg-blue-50 dark:hover:bg-blue-900/20'}"
|
|
||||||
>
|
|
||||||
<span class="mr-1">{link.icon}</span>
|
|
||||||
{link.title}
|
|
||||||
</Button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mobile menu button -->
|
|
||||||
<div class="md:hidden">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="rounded-lg"
|
|
||||||
onclick={() => (mobileNavOpened = !mobileNavOpened)}
|
|
||||||
>
|
|
||||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
{#if mobileNavOpened}
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M4 6h16M4 12h16M4 18h16"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mobile Navigation -->
|
|
||||||
{#if mobileNavOpened}
|
|
||||||
<div class="border-t border-gray-200 py-4 md:hidden dark:border-gray-700">
|
|
||||||
<div class="space-y-2">
|
|
||||||
{#each links as link (link.title)}
|
|
||||||
<Button
|
|
||||||
href={link.url}
|
|
||||||
variant={isActive(link.url) ? 'default' : 'ghost'}
|
|
||||||
class="w-full justify-start py-3 {isActive(link.url) ? 'bg-blue-600 text-white' : ''}"
|
|
||||||
onclick={() => (mobileNavOpened = false)}
|
|
||||||
>
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<span class="text-lg">{link.icon}</span>
|
|
||||||
<div class="text-left">
|
|
||||||
<div class="font-medium">{link.title}</div>
|
|
||||||
<div
|
|
||||||
class="text-xs {isActive(link.url)
|
|
||||||
? 'text-blue-100'
|
|
||||||
: 'text-gray-500 dark:text-gray-400'}"
|
|
||||||
>
|
|
||||||
{link.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
|
||||||
|
<!-- Navigation links -->
|
||||||
|
<nav class="flex-1 space-y-1 px-2 py-3">
|
||||||
|
{#each links as link (link.title)}
|
||||||
|
{@const active = isActive(link.url)}
|
||||||
|
<a
|
||||||
|
href={resolve(link.url)}
|
||||||
|
class="relative flex items-center rounded-md px-2.5 py-2 text-sm font-medium text-sidebar-foreground opacity-70 transition-colors duration-150 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:opacity-100 {active
|
||||||
|
? 'bg-sidebar-accent text-sidebar-primary! opacity-100! font-semibold! nav-active'
|
||||||
|
: ''}"
|
||||||
|
title={collapsed ? link.title : undefined}
|
||||||
|
onclick={onMobileClose}
|
||||||
|
>
|
||||||
|
<div class="flex h-5 w-5 shrink-0 items-center justify-center">
|
||||||
|
{#if link.icon === 'calendar'}
|
||||||
|
<svg
|
||||||
|
class="h-4.5 w-4.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.75"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else if link.icon === 'chart'}
|
||||||
|
<svg
|
||||||
|
class="h-4.5 w-4.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.75"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else if link.icon === 'extended'}
|
||||||
|
<svg
|
||||||
|
class="h-4.5 w-4.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.75"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if !collapsed}
|
||||||
|
<span class="ml-2.5 whitespace-nowrap">{link.title}</span>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Collapse toggle -->
|
||||||
|
<div class="border-t border-sidebar-border px-2 py-3">
|
||||||
|
<button
|
||||||
|
class="relative flex w-full items-center rounded-md px-2.5 py-2 text-sm font-medium text-sidebar-foreground opacity-70 transition-colors duration-150 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:opacity-100"
|
||||||
|
onclick={onToggle}
|
||||||
|
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
|
>
|
||||||
|
<div class="flex h-5 w-5 shrink-0 items-center justify-center">
|
||||||
|
<svg
|
||||||
|
class="h-4.5 w-4.5 transition-transform duration-200"
|
||||||
|
class:rotate-180={!collapsed}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.75"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{#if !collapsed}
|
||||||
|
<span class="ml-2.5 whitespace-nowrap">Collapse</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
:global(.container) {
|
.nav-active::before {
|
||||||
max-width: 1200px;
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 3px;
|
||||||
|
height: 60%;
|
||||||
|
border-radius: 0 3px 3px 0;
|
||||||
|
background: var(--sidebar-primary);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import Close from './popover-close.svelte';
|
||||||
|
import Content from './popover-content.svelte';
|
||||||
|
import Portal from './popover-portal.svelte';
|
||||||
|
import Trigger from './popover-trigger.svelte';
|
||||||
|
import Root from './popover.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Content,
|
||||||
|
Trigger,
|
||||||
|
Close,
|
||||||
|
Portal,
|
||||||
|
//
|
||||||
|
Root as Popover,
|
||||||
|
Content as PopoverContent,
|
||||||
|
Trigger as PopoverTrigger,
|
||||||
|
Close as PopoverClose,
|
||||||
|
Portal as PopoverPortal
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PopoverPrimitive.Close bind:ref data-slot="popover-close" {...restProps} />
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
|
import { type WithoutChildrenOrChild, cn } from '$lib/utils/ui.js';
|
||||||
|
|
||||||
|
import PopoverPortal from './popover-portal.svelte';
|
||||||
|
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
sideOffset = 4,
|
||||||
|
align = 'center',
|
||||||
|
portalProps,
|
||||||
|
...restProps
|
||||||
|
}: PopoverPrimitive.ContentProps & {
|
||||||
|
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof PopoverPortal>>;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PopoverPortal {...portalProps}>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
data-slot="popover-content"
|
||||||
|
{sideOffset}
|
||||||
|
{align}
|
||||||
|
class={cn(
|
||||||
|
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
</PopoverPortal>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
|
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PopoverPrimitive.Portal {...restProps} />
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
|
import { cn } from '$lib/utils/ui.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: PopoverPrimitive.TriggerProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PopoverPrimitive.Trigger
|
||||||
|
bind:ref
|
||||||
|
data-slot="popover-trigger"
|
||||||
|
class={cn('', className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
|
let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PopoverPrimitive.Root bind:open {...restProps} />
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import { geoLocationNameToRoute } from '$lib/utils/meteo';
|
||||||
|
|
||||||
|
export function buildLocationRoute(location: GeoLocation): string {
|
||||||
|
const locationRoute = geoLocationNameToRoute(location.name);
|
||||||
|
if (location.population && location.population > 543000) {
|
||||||
|
return locationRoute;
|
||||||
|
}
|
||||||
|
return locationRoute + '_' + location.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolveLocationOptions {
|
||||||
|
urlLocation: string;
|
||||||
|
routePrefix: string;
|
||||||
|
event: {
|
||||||
|
fetch: typeof fetch;
|
||||||
|
url: URL;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveLocationFromRoute({
|
||||||
|
urlLocation,
|
||||||
|
routePrefix,
|
||||||
|
event
|
||||||
|
}: ResolveLocationOptions): Promise<GeoLocation> {
|
||||||
|
let location: GeoLocation;
|
||||||
|
|
||||||
|
if (urlLocation.includes('N') && urlLocation.includes('E')) {
|
||||||
|
const parts = urlLocation.split(/N|E/);
|
||||||
|
const latitude = parseFloat(parts[0]);
|
||||||
|
const longitude = parseFloat(parts[1]);
|
||||||
|
|
||||||
|
location = {
|
||||||
|
id: 0,
|
||||||
|
name: `${latitude}N° ${longitude}E°`,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
elevation: 0,
|
||||||
|
feature_code: 'COORD',
|
||||||
|
country_code: undefined,
|
||||||
|
admin1_id: undefined,
|
||||||
|
admin3_id: undefined,
|
||||||
|
admin4_id: undefined,
|
||||||
|
timezone: 'UTC',
|
||||||
|
population: undefined,
|
||||||
|
postcodes: undefined,
|
||||||
|
country_id: undefined,
|
||||||
|
country: undefined,
|
||||||
|
admin1: undefined,
|
||||||
|
admin3: undefined,
|
||||||
|
admin4: undefined
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
let urlLocationName: string;
|
||||||
|
let urlLocationId: string | undefined;
|
||||||
|
|
||||||
|
if (urlLocation.includes('_')) {
|
||||||
|
const split = urlLocation.split('_');
|
||||||
|
urlLocationName = split[0];
|
||||||
|
urlLocationId = split[1];
|
||||||
|
} else if (/^\d+$/.test(urlLocation)) {
|
||||||
|
urlLocationName = '';
|
||||||
|
urlLocationId = urlLocation;
|
||||||
|
} else {
|
||||||
|
urlLocationName = urlLocation;
|
||||||
|
urlLocationId = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urlLocationId) {
|
||||||
|
const res = await event.fetch(
|
||||||
|
`https://geocoding-api.open-meteo.com/v1/get?id=${urlLocationId}`
|
||||||
|
);
|
||||||
|
location = await res.json();
|
||||||
|
} else {
|
||||||
|
const res = await event.fetch(
|
||||||
|
`https://geocoding-api.open-meteo.com/v1/search?name=${urlLocationName}&count=1&language=en&format=json`
|
||||||
|
);
|
||||||
|
const geocodingResponse = await res.json();
|
||||||
|
if (geocodingResponse.results) {
|
||||||
|
location = geocodingResponse.results[0];
|
||||||
|
} else {
|
||||||
|
error(404, 'Location not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationRoute = geoLocationNameToRoute(location.name);
|
||||||
|
const canonicalSuffix =
|
||||||
|
location.population && location.population > 543000
|
||||||
|
? locationRoute
|
||||||
|
: locationRoute + '_' + location.id;
|
||||||
|
const canonicalPath = `${routePrefix}${canonicalSuffix}`;
|
||||||
|
|
||||||
|
if (event.url.pathname !== canonicalPath) {
|
||||||
|
throw redirect(303, canonicalPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
storedLocation.set(location);
|
||||||
|
return location;
|
||||||
|
}
|
||||||
@@ -1,9 +1,27 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Topbar from '$lib/components/navigation/topbar.svelte';
|
||||||
|
import WeatherNav from '$lib/components/navigation/weather-nav.svelte';
|
||||||
|
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
|
|
||||||
import './layout.css';
|
import './layout.css';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
|
let sidebarCollapsed = $state(false);
|
||||||
|
let mobileMenuOpen = $state(false);
|
||||||
|
|
||||||
|
const toggleSidebar = () => {
|
||||||
|
sidebarCollapsed = !sidebarCollapsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMobileMenu = () => {
|
||||||
|
mobileMenuOpen = !mobileMenuOpen;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeMobileMenu = () => {
|
||||||
|
mobileMenuOpen = false;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -12,6 +30,33 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<main class="min-h-screen">
|
<div class="flex h-screen overflow-hidden bg-background text-foreground">
|
||||||
{@render children()}
|
<!-- Desktop sidebar -->
|
||||||
</main>
|
<div class="hidden h-full shrink-0 md:block">
|
||||||
|
<WeatherNav collapsed={sidebarCollapsed} onToggle={toggleSidebar} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile overlay -->
|
||||||
|
{#if mobileMenuOpen}
|
||||||
|
<div class="fixed inset-0 z-50 md:hidden" role="presentation">
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-black/30"
|
||||||
|
onclick={closeMobileMenu}
|
||||||
|
onkeydown={closeMobileMenu}
|
||||||
|
></div>
|
||||||
|
<div class="relative z-1 h-full w-55 shadow-lg">
|
||||||
|
<WeatherNav collapsed={false} onMobileClose={closeMobileMenu} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Main area: topbar + content -->
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col h-full">
|
||||||
|
<Topbar onMenuToggle={toggleMobileMenu} />
|
||||||
|
|
||||||
|
<main class="flex-1 overflow-y-auto p-5 md:px-8 md:py-6">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
+1
-268
@@ -1,270 +1,3 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
import { fade, fly } from 'svelte/transition';
|
|
||||||
|
|
||||||
import { storedLocation } from '$lib/stores/settings';
|
|
||||||
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle
|
|
||||||
} from '$lib/components/ui/card';
|
|
||||||
|
|
||||||
let mounted = $state(false);
|
|
||||||
let location = get(storedLocation);
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
mounted = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const features = [
|
|
||||||
{
|
|
||||||
title: 'Week Prediction',
|
|
||||||
description:
|
|
||||||
'Get detailed hourly weather forecasts for the next 7 days with interactive charts and temperature gradients.',
|
|
||||||
href: `/weather`,
|
|
||||||
icon: 'calendar',
|
|
||||||
gradient: 'from-blue-500 to-cyan-500'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Model Comparison',
|
|
||||||
description:
|
|
||||||
'Compare multiple weather models side-by-side to understand forecast uncertainty and accuracy.',
|
|
||||||
href: '/weather/compare',
|
|
||||||
icon: 'trending-up',
|
|
||||||
gradient: 'from-purple-500 to-pink-500'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '14 Day Weather',
|
|
||||||
description:
|
|
||||||
'Extended forecast with ensemble model spreads showing temperature ranges and uncertainty.',
|
|
||||||
href: '/weather/14-day',
|
|
||||||
icon: 'cloud',
|
|
||||||
gradient: 'from-green-500 to-blue-500'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Open-Meteo Weather - Advanced Weather Forecasting</title>
|
<title>Open-Meteo Weather</title>
|
||||||
<meta
|
|
||||||
name="description"
|
|
||||||
content="Professional weather forecasting with multiple models, extended forecasts, and detailed comparisons. Powered by Open-Meteo API."
|
|
||||||
/>
|
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div
|
|
||||||
class="min-h-screen bg-gradient-to-br from-blue-50 via-white to-cyan-50 dark:from-gray-900 dark:via-gray-800 dark:to-blue-900"
|
|
||||||
>
|
|
||||||
<!-- Hero Section -->
|
|
||||||
<div class="container mx-auto px-6 pt-20 pb-16">
|
|
||||||
{#if mounted}
|
|
||||||
<div class="mb-16 text-center" in:fade={{ duration: 800, delay: 200 }}>
|
|
||||||
<h1
|
|
||||||
class="mb-6 bg-gradient-to-r from-blue-600 via-purple-600 to-cyan-600 bg-clip-text text-5xl font-bold text-transparent md:text-7xl"
|
|
||||||
>
|
|
||||||
Open-Meteo Weather
|
|
||||||
</h1>
|
|
||||||
<p class="mx-auto mb-8 max-w-3xl text-xl text-gray-600 md:text-2xl dark:text-gray-300">
|
|
||||||
Professional weather forecasting with advanced models, detailed comparisons, and extended
|
|
||||||
predictions
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Quick Access Button -->
|
|
||||||
<div in:fly={{ y: 20, duration: 600, delay: 600 }}>
|
|
||||||
<Button
|
|
||||||
href="/weather"
|
|
||||||
size="lg"
|
|
||||||
class="bg-gradient-to-r from-blue-600 to-purple-600 px-8 py-3 text-lg text-white shadow-lg transition-all duration-300 hover:from-blue-700 hover:to-purple-700 hover:shadow-xl"
|
|
||||||
>
|
|
||||||
View Current Weather
|
|
||||||
<svg class="ml-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M13 7l5 5m0 0l-5 5m5-5H6"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Features Grid -->
|
|
||||||
<div class="mb-20 grid gap-8 md:grid-cols-3">
|
|
||||||
{#each features as feature, index (feature.title)}
|
|
||||||
{#if mounted}
|
|
||||||
<div in:fly={{ y: 30, duration: 600, delay: 300 + index * 150 }}>
|
|
||||||
<Card
|
|
||||||
class="h-full border-0 bg-white/80 shadow-lg backdrop-blur-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-xl dark:bg-gray-800/80"
|
|
||||||
>
|
|
||||||
<CardHeader>
|
|
||||||
<div
|
|
||||||
class="h-12 w-12 rounded-xl bg-gradient-to-r {feature.gradient} mb-4 flex items-center justify-center"
|
|
||||||
>
|
|
||||||
{#if feature.icon === 'calendar'}
|
|
||||||
<svg
|
|
||||||
class="h-6 w-6 text-white"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{:else if feature.icon === 'trending-up'}
|
|
||||||
<svg
|
|
||||||
class="h-6 w-6 text-white"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{:else if feature.icon === 'cloud'}
|
|
||||||
<svg
|
|
||||||
class="h-6 w-6 text-white"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<CardTitle class="mb-2 text-xl">{feature.title}</CardTitle>
|
|
||||||
<CardDescription class="text-gray-600 dark:text-gray-300">
|
|
||||||
{feature.description}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="pt-0">
|
|
||||||
<Button
|
|
||||||
href={feature.href}
|
|
||||||
variant="outline"
|
|
||||||
class="w-full hover:bg-gradient-to-r hover:{feature.gradient} transition-all duration-300 hover:border-transparent hover:text-white"
|
|
||||||
>
|
|
||||||
Explore {feature.title}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Current Weather for Selected Location -->
|
|
||||||
{#if mounted && location}
|
|
||||||
<div class="mx-auto max-w-2xl" in:fade={{ duration: 600, delay: 800 }}>
|
|
||||||
<Card
|
|
||||||
class="border-blue-200 bg-gradient-to-r from-blue-500/10 to-purple-500/10 dark:border-blue-700"
|
|
||||||
>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle class="text-center text-2xl">
|
|
||||||
Current Location: {location.name}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription class="text-center">
|
|
||||||
{location.country} • {location.latitude.toFixed(2)}°, {location.longitude.toFixed(2)}°
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="text-center">
|
|
||||||
<Button href="/weather" variant="default" class="bg-blue-600 hover:bg-blue-700">
|
|
||||||
View Detailed Forecast
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Features Overview Section -->
|
|
||||||
<div class="bg-white/50 py-20 dark:bg-gray-800/50">
|
|
||||||
<div class="container mx-auto px-6">
|
|
||||||
{#if mounted}
|
|
||||||
<div class="mb-16 text-center" in:fade={{ duration: 600, delay: 1000 }}>
|
|
||||||
<h2 class="mb-4 text-4xl font-bold text-gray-800 dark:text-white">
|
|
||||||
Why Choose Our Weather Service?
|
|
||||||
</h2>
|
|
||||||
<p class="mx-auto max-w-2xl text-xl text-gray-600 dark:text-gray-300">
|
|
||||||
Powered by Open-Meteo API with multiple weather models and advanced visualization
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
|
|
||||||
{#each [{ title: 'Multiple Models', desc: 'Compare ECMWF, GFS, and more', icon: 'layers' }, { title: 'Extended Forecasts', desc: 'Up to 14 days ahead', icon: 'clock' }, { title: 'Interactive Charts', desc: 'Apache ECharts visualization', icon: 'bar-chart' }, { title: 'Real-time Data', desc: 'Always up-to-date', icon: 'refresh' }] as feature, index (feature.title)}
|
|
||||||
<div class="text-center" in:fly={{ y: 20, duration: 500, delay: 1200 + index * 100 }}>
|
|
||||||
<div
|
|
||||||
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gradient-to-r from-blue-500 to-purple-500"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-8 w-8 text-white"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
{#if feature.icon === 'layers'}
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
|
||||||
/>
|
|
||||||
{:else if feature.icon === 'clock'}
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/>
|
|
||||||
{:else if feature.icon === 'bar-chart'}
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
|
||||||
/>
|
|
||||||
{:else if feature.icon === 'refresh'}
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 class="mb-2 text-lg font-semibold text-gray-800 dark:text-white">
|
|
||||||
{feature.title}
|
|
||||||
</h3>
|
|
||||||
<p class="text-gray-600 dark:text-gray-300">{feature.desc}</p>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
:global(.container) {
|
|
||||||
max-width: 1200px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
export const load = (async () => {
|
||||||
|
throw redirect(303, '/weather/week/');
|
||||||
|
}) satisfies PageLoad;
|
||||||
+61
-55
@@ -6,71 +6,75 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(0.985 0.002 90);
|
||||||
--foreground: oklch(0.129 0.042 264.695);
|
--foreground: oklch(0.205 0.02 60);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
--card-foreground: oklch(0.129 0.042 264.695);
|
--card-foreground: oklch(0.205 0.02 60);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.129 0.042 264.695);
|
--popover-foreground: oklch(0.205 0.02 60);
|
||||||
--primary: oklch(0.208 0.042 265.755);
|
--primary: oklch(0.65 0.17 55);
|
||||||
--primary-foreground: oklch(0.984 0.003 247.858);
|
--primary-foreground: oklch(1 0 0);
|
||||||
--secondary: oklch(0.968 0.007 247.896);
|
--secondary: oklch(0.965 0.01 85);
|
||||||
--secondary-foreground: oklch(0.208 0.042 265.755);
|
--secondary-foreground: oklch(0.25 0.02 60);
|
||||||
--muted: oklch(0.968 0.007 247.896);
|
--muted: oklch(0.96 0.008 85);
|
||||||
--muted-foreground: oklch(0.554 0.046 257.417);
|
--muted-foreground: oklch(0.5 0.02 60);
|
||||||
--accent: oklch(0.968 0.007 247.896);
|
--accent: oklch(0.96 0.008 85);
|
||||||
--accent-foreground: oklch(0.208 0.042 265.755);
|
--accent-foreground: oklch(1 0 0);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.929 0.013 255.508);
|
--border: oklch(0.91 0.01 80);
|
||||||
--input: oklch(0.929 0.013 255.508);
|
--input: oklch(0.91 0.01 80);
|
||||||
--ring: oklch(0.704 0.04 256.788);
|
--ring: oklch(0.65 0.17 55);
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--chart-1: oklch(0.65 0.17 55);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--chart-2: oklch(0.62 0.16 250);
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--chart-3: oklch(0.75 0.15 75);
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
--chart-4: oklch(0.55 0.12 250);
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
--chart-5: oklch(0.8 0.14 65);
|
||||||
--sidebar: oklch(0.984 0.003 247.858);
|
--sidebar-foreground: oklch(0.3 0.02 60);
|
||||||
--sidebar-foreground: oklch(0.129 0.042 264.695);
|
--sidebar-primary: oklch(0.65 0.17 55);
|
||||||
--sidebar-primary: oklch(0.208 0.042 265.755);
|
--sidebar-primary-foreground: oklch(1 0 0);
|
||||||
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
--sidebar-accent: oklch(0.95 0.03 70);
|
||||||
--sidebar-accent: oklch(0.968 0.007 247.896);
|
--sidebar-accent-foreground: oklch(0.3 0.02 60);
|
||||||
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
|
--sidebar-border: oklch(0.92 0.01 80);
|
||||||
--sidebar-border: oklch(0.929 0.013 255.508);
|
--sidebar-ring: oklch(0.65 0.17 55);
|
||||||
--sidebar-ring: oklch(0.704 0.04 256.788);
|
--sidebar: oklch(0.99 0.003 85);
|
||||||
|
--topbar-bg: oklch(1 0 0);
|
||||||
|
--topbar-border: oklch(0.92 0.01 80);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.129 0.042 264.695);
|
--background: oklch(0.17 0.015 60);
|
||||||
--foreground: oklch(0.984 0.003 247.858);
|
--foreground: oklch(0.96 0.005 85);
|
||||||
--card: oklch(0.208 0.042 265.755);
|
--card: oklch(0.22 0.015 60);
|
||||||
--card-foreground: oklch(0.984 0.003 247.858);
|
--card-foreground: oklch(0.96 0.005 85);
|
||||||
--popover: oklch(0.208 0.042 265.755);
|
--popover: oklch(0.22 0.015 60);
|
||||||
--popover-foreground: oklch(0.984 0.003 247.858);
|
--popover-foreground: oklch(0.96 0.005 85);
|
||||||
--primary: oklch(0.929 0.013 255.508);
|
--primary: oklch(0.72 0.17 55);
|
||||||
--primary-foreground: oklch(0.208 0.042 265.755);
|
--primary-foreground: oklch(0.15 0.02 60);
|
||||||
--secondary: oklch(0.279 0.041 260.031);
|
--secondary: oklch(0.26 0.015 60);
|
||||||
--secondary-foreground: oklch(0.984 0.003 247.858);
|
--secondary-foreground: oklch(0.96 0.005 85);
|
||||||
--muted: oklch(0.279 0.041 260.031);
|
--muted: oklch(0.26 0.015 60);
|
||||||
--muted-foreground: oklch(0.704 0.04 256.788);
|
--muted-foreground: oklch(0.65 0.02 60);
|
||||||
--accent: oklch(0.279 0.041 260.031);
|
--accent: oklch(0.65 0.16 250);
|
||||||
--accent-foreground: oklch(0.984 0.003 247.858);
|
--accent-foreground: oklch(0.96 0.005 85);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: oklch(0.551 0.027 264.364);
|
--ring: oklch(0.72 0.17 55);
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
--chart-1: oklch(0.72 0.17 55);
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
--chart-2: oklch(0.65 0.16 250);
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-3: oklch(0.8 0.14 65);
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
--chart-4: oklch(0.6 0.2 300);
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--chart-5: oklch(0.7 0.22 20);
|
||||||
--sidebar: oklch(0.208 0.042 265.755);
|
--sidebar-foreground: oklch(0.96 0.005 85);
|
||||||
--sidebar-foreground: oklch(0.984 0.003 247.858);
|
--sidebar-primary: oklch(0.72 0.17 55);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary-foreground: oklch(0.15 0.02 60);
|
||||||
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
--sidebar-accent: oklch(0.26 0.02 55);
|
||||||
--sidebar-accent: oklch(0.279 0.041 260.031);
|
--sidebar-accent-foreground: oklch(0.96 0.005 85);
|
||||||
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
|
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.551 0.027 264.364);
|
--sidebar-ring: oklch(0.72 0.17 55);
|
||||||
|
--sidebar: oklch(0.19 0.015 60);
|
||||||
|
--topbar-bg: oklch(0.2 0.015 60);
|
||||||
|
--topbar-border: oklch(1 0 0 / 10%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -109,6 +113,8 @@
|
|||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-topbar: var(--topbar-bg);
|
||||||
|
--color-topbar-border: var(--topbar-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { render } from 'vitest-browser-svelte';
|
import { render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
|
||||||
|
|
||||||
import Page from './+page.svelte';
|
import Page from './+page.svelte';
|
||||||
|
|
||||||
describe('/+page.svelte', () => {
|
describe('/+page.svelte', () => {
|
||||||
it('should render h1', async () => {
|
it('should render the redirect page with correct title', async () => {
|
||||||
render(Page);
|
render(Page);
|
||||||
|
|
||||||
const heading = page.getByRole('heading', { level: 1 });
|
const title = document.querySelector('title');
|
||||||
await expect.element(heading).toBeInTheDocument();
|
expect(title?.textContent).toBe('Open-Meteo Weather');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,216 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
import { fade, fly } from 'svelte/transition';
|
|
||||||
|
|
||||||
import { page } from '$app/stores';
|
|
||||||
|
|
||||||
import { storedLocation } from '$lib/stores/settings';
|
|
||||||
|
|
||||||
import LocationSearch from '$lib/components/location/location-search.svelte';
|
|
||||||
import WeatherNav from '$lib/components/navigation/weather-nav.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children?: import('svelte').Snippet;
|
children?: import('svelte').Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { children }: Props = $props();
|
let { children }: Props = $props();
|
||||||
|
|
||||||
interface CurrentWeather {
|
|
||||||
current: {
|
|
||||||
temperature_2m: number;
|
|
||||||
weather_code: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let location = $state(get(storedLocation));
|
|
||||||
let mounted = $state(false);
|
|
||||||
let currentWeather = $state<CurrentWeather | null>(null);
|
|
||||||
|
|
||||||
// Subscribe to location changes
|
|
||||||
storedLocation.subscribe((value) => {
|
|
||||||
location = value;
|
|
||||||
if (mounted) {
|
|
||||||
loadCurrentWeather();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
mounted = true;
|
|
||||||
loadCurrentWeather();
|
|
||||||
});
|
|
||||||
|
|
||||||
const loadCurrentWeather = async () => {
|
|
||||||
if (!location?.latitude) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}¤t=temperature_2m,weather_code&forecast_days=1`
|
|
||||||
);
|
|
||||||
const data = await response.json();
|
|
||||||
currentWeather = data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load current weather:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getWeatherIcon = (code: number): string => {
|
|
||||||
const iconMap: Record<number, string> = {
|
|
||||||
0: '☀️',
|
|
||||||
1: '🌤️',
|
|
||||||
2: '⛅',
|
|
||||||
3: '☁️',
|
|
||||||
45: '🌫️',
|
|
||||||
48: '🌫️',
|
|
||||||
51: '🌦️',
|
|
||||||
53: '🌦️',
|
|
||||||
55: '🌦️',
|
|
||||||
61: '🌧️',
|
|
||||||
63: '🌧️',
|
|
||||||
65: '🌧️',
|
|
||||||
71: '🌨️',
|
|
||||||
73: '🌨️',
|
|
||||||
75: '❄️',
|
|
||||||
95: '⛈️'
|
|
||||||
};
|
|
||||||
return iconMap[code] || '☁️';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPageTitle = () => {
|
|
||||||
const path = $page.url.pathname;
|
|
||||||
if (path.includes('/compare')) return 'Model Comparison';
|
|
||||||
if (path.includes('/14-day')) return '14 Day Forecast';
|
|
||||||
if (path.includes('/week')) return 'Week Prediction';
|
|
||||||
return 'Weather Forecast';
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<WeatherNav />
|
{@render children?.()}
|
||||||
|
|
||||||
<div
|
|
||||||
class="min-h-screen bg-gradient-to-br from-blue-50 via-white to-cyan-50 dark:from-gray-900 dark:via-gray-800 dark:to-blue-900"
|
|
||||||
>
|
|
||||||
<!-- Hero Header Section -->
|
|
||||||
<div class="relative overflow-hidden bg-gradient-to-r from-blue-600 via-purple-600 to-cyan-600">
|
|
||||||
<div class="absolute inset-0 bg-black/10"></div>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="container mx-auto px-6 py-12">
|
|
||||||
{#if mounted}
|
|
||||||
<div class="flex flex-col items-center space-y-6" in:fade={{ duration: 800 }}>
|
|
||||||
<!-- Page Title -->
|
|
||||||
<div class="text-center" in:fly={{ y: -20, duration: 600, delay: 200 }}>
|
|
||||||
<h1 class="mb-2 text-3xl font-bold text-white md:text-4xl">
|
|
||||||
{getPageTitle()}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Location Display & Search -->
|
|
||||||
<div class="w-full max-w-2xl" in:fly={{ y: 20, duration: 600, delay: 400 }}>
|
|
||||||
<div
|
|
||||||
class="rounded-2xl bg-white/95 p-6 shadow-2xl backdrop-blur-md dark:bg-gray-800/95"
|
|
||||||
>
|
|
||||||
<!-- Current Location Display -->
|
|
||||||
{#if location}
|
|
||||||
<div
|
|
||||||
class="flex flex-col items-center justify-between space-y-4 md:flex-row md:space-y-0 md:space-x-6"
|
|
||||||
>
|
|
||||||
<!-- Location Info -->
|
|
||||||
<div class="flex flex-1 items-center space-x-4">
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<img
|
|
||||||
class="h-12 w-12 rounded-full shadow-lg"
|
|
||||||
src="/images/country-flags/{(
|
|
||||||
location.country_code || 'united_nations'
|
|
||||||
).toLowerCase()}.svg"
|
|
||||||
alt={location.country}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 text-left">
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
|
||||||
{location.name}
|
|
||||||
</h2>
|
|
||||||
<p class="text-gray-600 dark:text-gray-300">
|
|
||||||
{#if location.admin1}{location.admin1},
|
|
||||||
{/if}{location.country}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm 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>
|
|
||||||
|
|
||||||
<!-- Current Weather -->
|
|
||||||
{#if currentWeather}
|
|
||||||
<div class="flex items-center space-x-3" in:fade={{ delay: 800 }}>
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="mb-1 text-3xl">
|
|
||||||
{getWeatherIcon(currentWeather.current.weather_code)}
|
|
||||||
</div>
|
|
||||||
<div class="text-2xl font-bold text-gray-900 dark:text-white">
|
|
||||||
{Math.round(currentWeather.current.temperature_2m)}°C
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Location Search -->
|
|
||||||
<div class="mt-6 border-t border-gray-200 pt-6 dark:border-gray-600">
|
|
||||||
<LocationSearch
|
|
||||||
label="🔍 Change location or search for a new city..."
|
|
||||||
on:location={(event) => {
|
|
||||||
storedLocation.set(event.detail);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stats Row (if location has population) -->
|
|
||||||
{#if location?.population}
|
|
||||||
<div
|
|
||||||
class="flex justify-center space-x-8 text-white/90"
|
|
||||||
in:fly={{ y: 20, duration: 600, delay: 600 }}
|
|
||||||
>
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-sm font-medium">Population</div>
|
|
||||||
<div class="text-lg font-bold">{location.population.toLocaleString()}</div>
|
|
||||||
</div>
|
|
||||||
{#if location.timezone}
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-sm font-medium">Timezone</div>
|
|
||||||
<div class="text-lg font-bold">{location.timezone}</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Decorative elements -->
|
|
||||||
<div class="pointer-events-none absolute top-0 left-0 h-full w-full overflow-hidden">
|
|
||||||
<div class="absolute -top-4 -right-4 h-24 w-24 rounded-full bg-white/10"></div>
|
|
||||||
<div class="absolute top-1/3 -left-8 h-16 w-16 rounded-full bg-white/5"></div>
|
|
||||||
<div class="absolute bottom-8 left-1/4 h-12 w-12 rounded-full bg-white/10"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main Content -->
|
|
||||||
<div class="container mx-auto px-6 py-8">
|
|
||||||
{#if mounted}
|
|
||||||
<div in:fade={{ duration: 600, delay: 400 }}>
|
|
||||||
{@render children?.()}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
:global(.container) {
|
|
||||||
max-width: 1200px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ import { storedLocation } from '$lib/stores/settings';
|
|||||||
|
|
||||||
import type { LayoutLoad } from './$types';
|
import type { LayoutLoad } from './$types';
|
||||||
|
|
||||||
const location = get(storedLocation);
|
|
||||||
|
|
||||||
export const load: LayoutLoad = async () => {
|
export const load: LayoutLoad = async () => {
|
||||||
|
const location = get(storedLocation);
|
||||||
return {
|
return {
|
||||||
title: `Weather ${location.name}`,
|
title: `Weather ${location.name}`,
|
||||||
location: location
|
location: location
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ import { storedLocation } from '$lib/stores/settings';
|
|||||||
|
|
||||||
import type { LayoutLoad } from './$types';
|
import type { LayoutLoad } from './$types';
|
||||||
|
|
||||||
const location = get(storedLocation);
|
|
||||||
|
|
||||||
export const load: LayoutLoad = async () => {
|
export const load: LayoutLoad = async () => {
|
||||||
|
const location = get(storedLocation);
|
||||||
return {
|
return {
|
||||||
heroTitle: `14 Day Weather ${location.name}`,
|
heroTitle: `14 Day Weather ${location.name}`,
|
||||||
heroDescription: location.admin1 ?? '' + ' ' + location.country
|
heroDescription: location.admin1 ?? '' + ' ' + location.country
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
import { storedLocation } from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import { buildLocationRoute } from '$lib/utils/location';
|
||||||
|
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
export const load = (async () => {
|
||||||
|
const location = get(storedLocation);
|
||||||
|
const locationRoute = buildLocationRoute(location);
|
||||||
|
throw redirect(303, '/weather/14-day/' + locationRoute);
|
||||||
|
}) satisfies PageLoad;
|
||||||
+10
-8
@@ -1,8 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { get } from 'svelte/store';
|
|
||||||
|
|
||||||
import { storedLocation } from '$lib/stores/settings';
|
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildAverageSeries,
|
buildAverageSeries,
|
||||||
@@ -24,7 +23,7 @@
|
|||||||
fetchEnsembleForecast
|
fetchEnsembleForecast
|
||||||
} from '$lib/services/weather';
|
} from '$lib/services/weather';
|
||||||
|
|
||||||
import { defaultParameters } from '../options';
|
import { defaultParameters } from '../../options';
|
||||||
|
|
||||||
import type * as echarts from 'echarts';
|
import type * as echarts from 'echarts';
|
||||||
|
|
||||||
@@ -40,11 +39,12 @@
|
|||||||
let mounted = $state(false);
|
let mounted = $state(false);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
|
||||||
const location = get(storedLocation);
|
let location = $state<GeoLocation>($storedLocation);
|
||||||
|
storedLocation.subscribe((value) => {
|
||||||
|
location = value;
|
||||||
|
});
|
||||||
|
|
||||||
let params = $state({
|
let params = $state({
|
||||||
latitude: [52.52],
|
|
||||||
longitude: [13.41],
|
|
||||||
...defaultParameters,
|
...defaultParameters,
|
||||||
hourly: ['temperature_2m'],
|
hourly: ['temperature_2m'],
|
||||||
models: ['gfs_seamless']
|
models: ['gfs_seamless']
|
||||||
@@ -87,14 +87,16 @@
|
|||||||
|
|
||||||
if (!mounted || !hourlyVars?.length || !modelList?.length) return;
|
if (!mounted || !hourlyVars?.length || !modelList?.length) return;
|
||||||
|
|
||||||
|
const loc = location;
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
loading = true;
|
loading = true;
|
||||||
chartInstances = [];
|
chartInstances = [];
|
||||||
chartComponents = [];
|
chartComponents = [];
|
||||||
|
|
||||||
const result: EnsembleForecastResult = await fetchEnsembleForecast({
|
const result: EnsembleForecastResult = await fetchEnsembleForecast({
|
||||||
latitude: location.latitude!,
|
latitude: loc.latitude!,
|
||||||
longitude: location.longitude!,
|
longitude: loc.longitude!,
|
||||||
hourlyVariables: hourlyVars,
|
hourlyVariables: hourlyVars,
|
||||||
models: modelList,
|
models: modelList,
|
||||||
forecast_days: 14,
|
forecast_days: 14,
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { resolveLocationFromRoute } from '$lib/utils/location';
|
||||||
|
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageLoad = async (event) => {
|
||||||
|
const location = await resolveLocationFromRoute({
|
||||||
|
urlLocation: event.params.location,
|
||||||
|
routePrefix: '/weather/14-day/',
|
||||||
|
event
|
||||||
|
});
|
||||||
|
|
||||||
|
return { location };
|
||||||
|
};
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { get } from 'svelte/store';
|
|
||||||
|
|
||||||
import { storedLocation } from '$lib/stores/settings';
|
|
||||||
|
|
||||||
import type { LayoutLoad } from './$types';
|
|
||||||
|
|
||||||
const location = get(storedLocation);
|
|
||||||
|
|
||||||
export const load: LayoutLoad = async () => {
|
|
||||||
return {
|
|
||||||
heroTitle: `Model Compare ${location.name}`,
|
|
||||||
heroDescription: location.admin1 ?? '' + ' ' + location.country
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
import { storedLocation } from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import { buildLocationRoute } from '$lib/utils/location';
|
||||||
|
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
export const load = (async () => {
|
||||||
|
const location = get(storedLocation);
|
||||||
|
const locationRoute = buildLocationRoute(location);
|
||||||
|
throw redirect(303, '/weather/compare/' + locationRoute);
|
||||||
|
}) satisfies PageLoad;
|
||||||
+11
-9
@@ -1,9 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { get } from 'svelte/store';
|
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
import { storedLocation } from '$lib/stores/settings';
|
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildAverageSeries,
|
buildAverageSeries,
|
||||||
@@ -28,8 +27,8 @@
|
|||||||
fetchModelComparison
|
fetchModelComparison
|
||||||
} from '$lib/services/weather';
|
} from '$lib/services/weather';
|
||||||
|
|
||||||
import { hourly, models as modelsFlat } from '../options';
|
import { hourly, models as modelsFlat } from '../../options';
|
||||||
import { defaultParameters } from '../options';
|
import { defaultParameters } from '../../options';
|
||||||
|
|
||||||
import type * as echarts from 'echarts';
|
import type * as echarts from 'echarts';
|
||||||
|
|
||||||
@@ -47,11 +46,12 @@
|
|||||||
let mounted = $state(false);
|
let mounted = $state(false);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
|
||||||
const location = get(storedLocation);
|
let location = $state<GeoLocation>($storedLocation);
|
||||||
|
storedLocation.subscribe((value) => {
|
||||||
|
location = value;
|
||||||
|
});
|
||||||
|
|
||||||
let params = $state({
|
let params = $state({
|
||||||
latitude: [52.52],
|
|
||||||
longitude: [13.41],
|
|
||||||
...defaultParameters,
|
...defaultParameters,
|
||||||
hourly: ['temperature_2m', 'rain', 'relative_humidity_2m'],
|
hourly: ['temperature_2m', 'rain', 'relative_humidity_2m'],
|
||||||
models: [
|
models: [
|
||||||
@@ -101,14 +101,16 @@
|
|||||||
|
|
||||||
if (!mounted || !hourlyVars?.length || !modelList?.length) return;
|
if (!mounted || !hourlyVars?.length || !modelList?.length) return;
|
||||||
|
|
||||||
|
const loc = location;
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
loading = true;
|
loading = true;
|
||||||
chartInstances = [];
|
chartInstances = [];
|
||||||
chartComponents = [];
|
chartComponents = [];
|
||||||
|
|
||||||
const result: ModelCompareResult = await fetchModelComparison({
|
const result: ModelCompareResult = await fetchModelComparison({
|
||||||
latitude: location.latitude!,
|
latitude: loc.latitude!,
|
||||||
longitude: location.longitude!,
|
longitude: loc.longitude!,
|
||||||
hourlyVariables: hourlyVars,
|
hourlyVariables: hourlyVars,
|
||||||
models: modelList,
|
models: modelList,
|
||||||
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
|
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { resolveLocationFromRoute } from '$lib/utils/location';
|
||||||
|
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageLoad = async (event) => {
|
||||||
|
const location = await resolveLocationFromRoute({
|
||||||
|
urlLocation: event.params.location,
|
||||||
|
routePrefix: '/weather/compare/',
|
||||||
|
event
|
||||||
|
});
|
||||||
|
|
||||||
|
return { location };
|
||||||
|
};
|
||||||
@@ -4,20 +4,12 @@ import { redirect } from '@sveltejs/kit';
|
|||||||
|
|
||||||
import { storedLocation } from '$lib/stores/settings';
|
import { storedLocation } from '$lib/stores/settings';
|
||||||
|
|
||||||
import { geoLocationNameToRoute } from '$lib/utils/meteo';
|
import { buildLocationRoute } from '$lib/utils/location';
|
||||||
|
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const load = (async () => {
|
export const load = (async () => {
|
||||||
const location = get(storedLocation);
|
const location = get(storedLocation);
|
||||||
const locationRoute = geoLocationNameToRoute(location.name);
|
const locationRoute = buildLocationRoute(location);
|
||||||
throw redirect(
|
throw redirect(303, '/weather/week/' + locationRoute);
|
||||||
303,
|
|
||||||
'/weather/week/' +
|
|
||||||
(location.population
|
|
||||||
? location.population > 543000
|
|
||||||
? locationRoute
|
|
||||||
: locationRoute + '_' + location.id
|
|
||||||
: locationRoute + '_' + location.id)
|
|
||||||
);
|
|
||||||
}) satisfies PageLoad;
|
}) satisfies PageLoad;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { SvelteDate } from 'svelte/reactivity';
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
|
|
||||||
import { storedLocation } from '$lib/stores/settings';
|
import { storedLocation } from '$lib/stores/settings';
|
||||||
@@ -31,38 +31,19 @@
|
|||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
|
||||||
const selectedDay = new SvelteDate();
|
const selectedDay = new SvelteDate();
|
||||||
let selectedDayIndex = $state(1);
|
|
||||||
|
|
||||||
let fetchedHourly: FetchedHourly | null = $state(null);
|
let fetchedHourly: FetchedHourly | null = $state(null);
|
||||||
let fetchedDaily: FetchedDaily | null = $state(null);
|
let fetchedDaily: FetchedDaily | null = $state(null);
|
||||||
|
|
||||||
let meteogramCharts: MeteogramCharts | undefined = $state();
|
let meteogramCharts: MeteogramCharts | undefined = $state();
|
||||||
|
|
||||||
const switchDay = (date: Date, index: number) => {
|
const switchDay = (date: Date) => {
|
||||||
selectedDay.setTime(date.getTime());
|
selectedDay.setTime(date.getTime());
|
||||||
selectedDayIndex = index;
|
|
||||||
meteogramCharts?.scrollToDay(date);
|
meteogramCharts?.scrollToDay(date);
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
mounted = true;
|
mounted = true;
|
||||||
|
|
||||||
document.onkeydown = (e) => {
|
|
||||||
if (!fetchedDaily) return;
|
|
||||||
const days = fetchedDaily.dailyDates;
|
|
||||||
|
|
||||||
if (e.key === 'ArrowLeft') {
|
|
||||||
const i = selectedDayIndex - 1;
|
|
||||||
if (i >= 0 && i < days.length) switchDay(days[i], i);
|
|
||||||
} else if (e.key === 'ArrowRight') {
|
|
||||||
const i = selectedDayIndex + 1;
|
|
||||||
if (i >= 0 && i < days.length) switchDay(days[i], i);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
document.onkeydown = null;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|||||||
@@ -1,89 +1,13 @@
|
|||||||
import { error, redirect } from '@sveltejs/kit';
|
import { resolveLocationFromRoute } from '$lib/utils/location';
|
||||||
|
|
||||||
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
|
|
||||||
|
|
||||||
import { geoLocationNameToRoute } from '$lib/utils/meteo';
|
|
||||||
|
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageLoad = async (event) => {
|
export const load: PageLoad = async (event) => {
|
||||||
const urlLocation = event.params.location;
|
const location = await resolveLocationFromRoute({
|
||||||
let urlLocationSplit, urlLocationName, urlLocationId;
|
urlLocation: event.params.location,
|
||||||
|
routePrefix: '/weather/week/',
|
||||||
|
event
|
||||||
|
});
|
||||||
|
|
||||||
if (urlLocation.includes('_')) {
|
return { location };
|
||||||
urlLocationSplit = urlLocation.split('_');
|
|
||||||
urlLocationName = urlLocationSplit[0];
|
|
||||||
urlLocationId = urlLocationSplit[1];
|
|
||||||
} else if (/^\d+$/.test(urlLocation)) {
|
|
||||||
// only numbers in location, must be geonames id
|
|
||||||
urlLocationName = '';
|
|
||||||
urlLocationId = urlLocation;
|
|
||||||
} else if (/^[a-zA-Z]/.test(urlLocation)) {
|
|
||||||
// only letters in location, must be geonames query
|
|
||||||
urlLocationName = urlLocation;
|
|
||||||
urlLocationId = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
let location: GeoLocation;
|
|
||||||
|
|
||||||
// lat, long coordinates
|
|
||||||
if (urlLocation.includes('N') && urlLocation.includes('E')) {
|
|
||||||
urlLocationSplit = urlLocation.split(/N|E/);
|
|
||||||
const latitude = parseFloat(urlLocationSplit[0]);
|
|
||||||
const longitude = parseFloat(urlLocationSplit[1]);
|
|
||||||
|
|
||||||
location = {
|
|
||||||
id: 0,
|
|
||||||
name: `${latitude}N° ${longitude}E°`,
|
|
||||||
latitude: latitude,
|
|
||||||
longitude: longitude,
|
|
||||||
elevation: 0,
|
|
||||||
feature_code: 'COORD',
|
|
||||||
country_code: undefined,
|
|
||||||
admin1_id: undefined,
|
|
||||||
admin3_id: undefined,
|
|
||||||
admin4_id: undefined,
|
|
||||||
timezone: 'UTC',
|
|
||||||
population: undefined,
|
|
||||||
postcodes: undefined,
|
|
||||||
country_id: undefined,
|
|
||||||
country: undefined,
|
|
||||||
admin1: undefined,
|
|
||||||
admin3: undefined,
|
|
||||||
admin4: undefined
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
if (urlLocationId) {
|
|
||||||
const res = await event.fetch(
|
|
||||||
`https://geocoding-api.open-meteo.com/v1/get?id=${urlLocationId}`
|
|
||||||
);
|
|
||||||
location = await res.json();
|
|
||||||
} else {
|
|
||||||
const res = await event.fetch(
|
|
||||||
`https://geocoding-api.open-meteo.com/v1/search?name=${urlLocationName}&count=1&language=en&format=json`
|
|
||||||
);
|
|
||||||
const geocodingResponse = await res.json();
|
|
||||||
if (geocodingResponse.results) {
|
|
||||||
location = geocodingResponse.results[0];
|
|
||||||
} else {
|
|
||||||
error(404, 'Location not found');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const locationRoute = geoLocationNameToRoute(location.name);
|
|
||||||
|
|
||||||
if (location.population && location.population > 543000) {
|
|
||||||
// 1000 biggest cities
|
|
||||||
if (event.url.pathname !== `/weather/week/${locationRoute}`) {
|
|
||||||
throw redirect(303, `/weather/week/${locationRoute}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (event.url.pathname !== `/weather/week/${locationRoute + '_' + location.id}`) {
|
|
||||||
throw redirect(303, `/weather/week/${locationRoute + '_' + location.id}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
storedLocation.set(location);
|
|
||||||
return { location: location };
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user