feat: move nav bar to the side #6

Merged
frederic merged 2 commits from navigation-bar-on-the-side into main 2026-02-16 01:23:53 +01:00
10 changed files with 444 additions and 736 deletions
Showing only changes of commit 852ae21a82 - Show all commits
@@ -8,7 +8,7 @@
import * as Dialog from '$lib/components/ui/dialog'; import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
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 {
@@ -97,50 +97,43 @@
<Dialog.Root bind:open={dialogOpen}> <Dialog.Root bind:open={dialogOpen}>
<Dialog.Trigger <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" 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) => { onclick={(e) => {
e.preventDefault(); e.preventDefault();
dialogOpen = !dialogOpen; 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"
>
{label}
</span>
</Dialog.Trigger> </Dialog.Trigger>
<Dialog.Portal> <Dialog.Portal>
<Dialog.Overlay class="bg-black/20 backdrop-blur-sm" /> <Dialog.Overlay class="dialog-overlay" />
<Dialog.Content <Dialog.Content class="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-4">
> <Dialog.Title class="text-lg font-semibold">Find Location</Dialog.Title>
<Dialog.Header class="pb-6"> <p class="text-sm text-muted-foreground">Search for a city or use GPS</p>
<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> </Dialog.Header>
<div class="flex-1 overflow-hidden"> <div class="flex-1 overflow-hidden">
<div class="px-6"> <div class="px-5">
<div class="mb-6 flex gap-3"> <div class="mb-4 flex gap-2">
<div class="flex-1"> <div class="flex-1">
<Input <Input
type="search" type="search"
{placeholder} {placeholder}
class="h-12 text-lg" class="h-10"
autocomplete="off" autocomplete="off"
spellcheck="false" spellcheck="false"
aria-label="Search Location" aria-label="Search Location"
@@ -149,12 +142,12 @@
</div> </div>
<Button <Button
variant="outline" variant="outline"
size="lg" size="default"
class="px-4" class="px-3"
title="Use GPS Location" title="Use GPS Location"
onclick={() => (searchQuery = 'GPS')} onclick={() => (searchQuery = 'GPS')}
> >
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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"
@@ -172,42 +165,43 @@
</div> </div>
</div> </div>
<div class="flex-1 overflow-y-auto px-6 pb-6"> <div class="flex-1 overflow-y-auto px-5 pb-5">
{#await results} {#await results}
<div class="flex h-32 items-center justify-center"> <div class="flex h-24 items-center justify-center">
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-2">
<div class="h-6 w-6 animate-spin rounded-full border-b-2 border-blue-600"></div> <div class="h-5 w-5 animate-spin rounded-full border-b-2 border-primary"></div>
<span class="text-gray-600 dark:text-gray-300">Searching...</span> <span class="text-sm text-muted-foreground">Searching...</span>
</div> </div>
</div> </div>
{:then results} {:then results}
{#if results.results && results.results.length === 0} {#if results.results && results.results.length === 0}
{#if searchQuery.length < 2} {#if searchQuery.length < 2}
<Alert.Root class="border-blue-200 bg-blue-50 dark:bg-blue-900/20"> <div
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> class="flex items-start gap-2 rounded-md bg-primary/8 p-3 text-muted-foreground"
>
<svg
class="h-4 w-4 shrink-0"
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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/> />
</svg> </svg>
<Alert.Description class="text-blue-700 dark:text-blue-300"> <span class="text-sm">
Start typing to search for locations or use GPS to detect your current position Start typing to search or use GPS to detect your position
</Alert.Description> </span>
</Alert.Root> </div>
{:else} {:else}
<Alert.Root class="border-orange-200 bg-orange-50 dark:bg-orange-900/20"> <Alert.Root
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> class="border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-900/20"
<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"> <Alert.Description class="text-orange-700 dark:text-orange-300">
No locations found for "{searchQuery}". Try a different search term. No locations found for "{searchQuery}". Try a different term.
</Alert.Description> </Alert.Description>
</Alert.Root> </Alert.Root>
{/if} {/if}
@@ -216,39 +210,33 @@
<Alert.Description>No locations found</Alert.Description> <Alert.Description>No locations found</Alert.Description>
</Alert.Root> </Alert.Root>
{:else} {:else}
<div class="space-y-2"> <div class="space-y-1">
{#each results.results || [] as location, i (i)} {#each results.results || [] as location, i (i)}
<button <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" class="group block w-full cursor-pointer rounded-md border border-transparent bg-transparent px-3 py-2.5 transition-[background,border-color] duration-150 hover:border-border hover:bg-accent"
onclick={() => selectLocation(location)} onclick={() => selectLocation(location)}
> >
<div class="flex items-center justify-between"> <div class="flex items-center gap-3">
<div class="flex flex-1 items-center space-x-4"> <img
<img class="h-8 w-8 rounded-full"
class="h-10 w-10 rounded-full shadow-md" src="/images/country-flags/{(
src="/images/country-flags/{( location.country_code || 'united_nations'
location.country_code || 'united_nations' ).toLowerCase()}.svg"
).toLowerCase()}.svg" alt={location.country}
alt={location.country} />
/> <div class="flex-1 text-left">
<div class="flex-1"> <div class="text-sm font-medium text-foreground">
<h3 {location.name}
class="font-semibold text-gray-900 group-hover:text-blue-600 dark:text-white dark:group-hover:text-blue-400" </div>
> <div class="text-xs text-muted-foreground">
{location.name} {location.admin1 || ''}
</h3> {location.country || ''}
<p class="text-sm text-gray-600 dark:text-gray-300"> · {location.latitude.toFixed(2)}°N {location.longitude.toFixed(2)}°E
{location.admin1 || ''} {#if location.elevation}· {location.elevation.toFixed(0)}m{/if}
{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>
</div> </div>
<svg <svg
class="h-5 w-5 text-gray-400 group-hover:text-blue-500" class="h-4 w-4 text-muted-foreground opacity-0 transition-opacity duration-150 group-hover:opacity-100"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -275,3 +263,31 @@
</Dialog.Content> </Dialog.Content>
</Dialog.Portal> </Dialog.Portal>
</Dialog.Root> </Dialog.Root>
<style>
:global(.dialog-overlay) {
background: rgba(0, 0, 0, 0.2);
backdrop-filter: blur(2px);
}
:global(.dialog-content) {
position: fixed;
top: 10% !important;
left: 50% !important;
transform: translateX(-50%) !important;
display: flex;
flex-direction: column;
max-height: calc(100vh - 10%);
min-height: 400px;
width: calc(100% - 2rem);
max-width: 560px;
overflow: hidden;
border-radius: 0.75rem;
border: 1px solid var(--border);
background: var(--card);
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 8px 10px -6px rgba(0, 0, 0, 0.1);
padding-top: 1.25rem;
}
</style>
@@ -0,0 +1,73 @@
<script lang="ts">
import { get } from 'svelte/store';
import { storedLocation } from '$lib/stores/settings';
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;
});
</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) => {
storedLocation.set(event.detail);
}}
/>
</div>
</header>
<style>
.topbar {
z-index: 40;
}
</style>
+161 -106
View File
@@ -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>
+48 -3
View File
@@ -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
View File
@@ -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>
+7
View File
@@ -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
View File
@@ -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 {
+3 -4
View File
@@ -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 -208
View File
@@ -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}&current=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>
@@ -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(() => {