-
-
-
-
+
+
-
-
-
- {#each links as link (link.title)}
-
- {link.icon}
- {link.title}
-
- {/each}
-
-
-
-
-
(mobileNavOpened = !mobileNavOpened)}
- >
-
- {#if mobileNavOpened}
-
- {:else}
-
- {/if}
-
-
-
-
-
-
- {#if mobileNavOpened}
-
-
- {#each links as link (link.title)}
-
(mobileNavOpened = false)}
- >
-
-
{link.icon}
-
-
{link.title}
-
- {link.description}
-
-
-
-
- {/each}
-
-
+
+
+
{/if}
-
+
+
+
+ {#each links as link (link.title)}
+ {@const active = isActive(link.url)}
+
+ {/each}
+
+
+
+
+
diff --git a/src/lib/components/ui/popover/index.ts b/src/lib/components/ui/popover/index.ts
new file mode 100644
index 0000000..63e21bf
--- /dev/null
+++ b/src/lib/components/ui/popover/index.ts
@@ -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
+};
diff --git a/src/lib/components/ui/popover/popover-close.svelte b/src/lib/components/ui/popover/popover-close.svelte
new file mode 100644
index 0000000..dc4dec4
--- /dev/null
+++ b/src/lib/components/ui/popover/popover-close.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/lib/components/ui/popover/popover-content.svelte b/src/lib/components/ui/popover/popover-content.svelte
new file mode 100644
index 0000000..3f62623
--- /dev/null
+++ b/src/lib/components/ui/popover/popover-content.svelte
@@ -0,0 +1,34 @@
+
+
+
+
+
diff --git a/src/lib/components/ui/popover/popover-portal.svelte b/src/lib/components/ui/popover/popover-portal.svelte
new file mode 100644
index 0000000..25efb87
--- /dev/null
+++ b/src/lib/components/ui/popover/popover-portal.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/lib/components/ui/popover/popover-trigger.svelte b/src/lib/components/ui/popover/popover-trigger.svelte
new file mode 100644
index 0000000..b3dbc2c
--- /dev/null
+++ b/src/lib/components/ui/popover/popover-trigger.svelte
@@ -0,0 +1,18 @@
+
+
+
diff --git a/src/lib/components/ui/popover/popover.svelte b/src/lib/components/ui/popover/popover.svelte
new file mode 100644
index 0000000..f39b867
--- /dev/null
+++ b/src/lib/components/ui/popover/popover.svelte
@@ -0,0 +1,7 @@
+
+
+
diff --git a/src/lib/utils/location.ts b/src/lib/utils/location.ts
new file mode 100644
index 0000000..4a2c3d7
--- /dev/null
+++ b/src/lib/utils/location.ts
@@ -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
{
+ 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;
+}
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 1ff71bb..331afe5 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -1,9 +1,27 @@
@@ -12,6 +30,33 @@
-
- {@render children()}
-
+
+
+
+
+
+
+
+ {#if mobileMenuOpen}
+
+ {/if}
+
+
+
+
+
+
+ {@render children()}
+
+
+
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index af09426..33e9f0e 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -1,270 +1,3 @@
-
-
- Open-Meteo Weather - Advanced Weather Forecasting
-
+ Open-Meteo Weather
-
-
-
-
- {#if mounted}
-
-
- Open-Meteo Weather
-
-
- Professional weather forecasting with advanced models, detailed comparisons, and extended
- predictions
-
-
-
-
-
- View Current Weather
-
-
-
-
-
-
- {/if}
-
-
-
- {#each features as feature, index (feature.title)}
- {#if mounted}
-
-
-
-
- {#if feature.icon === 'calendar'}
-
-
-
- {:else if feature.icon === 'trending-up'}
-
-
-
- {:else if feature.icon === 'cloud'}
-
-
-
- {/if}
-
- {feature.title}
-
- {feature.description}
-
-
-
-
- Explore {feature.title}
-
-
-
-
- {/if}
- {/each}
-
-
-
- {#if mounted && location}
-
-
-
-
- Current Location: {location.name}
-
-
- {location.country} • {location.latitude.toFixed(2)}°, {location.longitude.toFixed(2)}°
-
-
-
-
- View Detailed Forecast
-
-
-
-
- {/if}
-
-
-
-
-
- {#if mounted}
-
-
- Why Choose Our Weather Service?
-
-
- Powered by Open-Meteo API with multiple weather models and advanced visualization
-
-
-
-
- {#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)}
-
-
-
- {#if feature.icon === 'layers'}
-
- {:else if feature.icon === 'clock'}
-
- {:else if feature.icon === 'bar-chart'}
-
- {:else if feature.icon === 'refresh'}
-
- {/if}
-
-
-
- {feature.title}
-
-
{feature.desc}
-
- {/each}
-
- {/if}
-
-
-
-
-
diff --git a/src/routes/+page.ts b/src/routes/+page.ts
new file mode 100644
index 0000000..db2ed9b
--- /dev/null
+++ b/src/routes/+page.ts
@@ -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;
diff --git a/src/routes/layout.css b/src/routes/layout.css
index b37c9b3..33a587d 100644
--- a/src/routes/layout.css
+++ b/src/routes/layout.css
@@ -6,71 +6,75 @@
:root {
--radius: 0.625rem;
- --background: oklch(1 0 0);
- --foreground: oklch(0.129 0.042 264.695);
+ --background: oklch(0.985 0.002 90);
+ --foreground: oklch(0.205 0.02 60);
--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-foreground: oklch(0.129 0.042 264.695);
- --primary: oklch(0.208 0.042 265.755);
- --primary-foreground: oklch(0.984 0.003 247.858);
- --secondary: oklch(0.968 0.007 247.896);
- --secondary-foreground: oklch(0.208 0.042 265.755);
- --muted: oklch(0.968 0.007 247.896);
- --muted-foreground: oklch(0.554 0.046 257.417);
- --accent: oklch(0.968 0.007 247.896);
- --accent-foreground: oklch(0.208 0.042 265.755);
+ --popover-foreground: oklch(0.205 0.02 60);
+ --primary: oklch(0.65 0.17 55);
+ --primary-foreground: oklch(1 0 0);
+ --secondary: oklch(0.965 0.01 85);
+ --secondary-foreground: oklch(0.25 0.02 60);
+ --muted: oklch(0.96 0.008 85);
+ --muted-foreground: oklch(0.5 0.02 60);
+ --accent: oklch(0.96 0.008 85);
+ --accent-foreground: oklch(1 0 0);
--destructive: oklch(0.577 0.245 27.325);
- --border: oklch(0.929 0.013 255.508);
- --input: oklch(0.929 0.013 255.508);
- --ring: oklch(0.704 0.04 256.788);
- --chart-1: oklch(0.646 0.222 41.116);
- --chart-2: oklch(0.6 0.118 184.704);
- --chart-3: oklch(0.398 0.07 227.392);
- --chart-4: oklch(0.828 0.189 84.429);
- --chart-5: oklch(0.769 0.188 70.08);
- --sidebar: oklch(0.984 0.003 247.858);
- --sidebar-foreground: oklch(0.129 0.042 264.695);
- --sidebar-primary: oklch(0.208 0.042 265.755);
- --sidebar-primary-foreground: oklch(0.984 0.003 247.858);
- --sidebar-accent: oklch(0.968 0.007 247.896);
- --sidebar-accent-foreground: oklch(0.208 0.042 265.755);
- --sidebar-border: oklch(0.929 0.013 255.508);
- --sidebar-ring: oklch(0.704 0.04 256.788);
+ --border: oklch(0.91 0.01 80);
+ --input: oklch(0.91 0.01 80);
+ --ring: oklch(0.65 0.17 55);
+ --chart-1: oklch(0.65 0.17 55);
+ --chart-2: oklch(0.62 0.16 250);
+ --chart-3: oklch(0.75 0.15 75);
+ --chart-4: oklch(0.55 0.12 250);
+ --chart-5: oklch(0.8 0.14 65);
+ --sidebar-foreground: oklch(0.3 0.02 60);
+ --sidebar-primary: oklch(0.65 0.17 55);
+ --sidebar-primary-foreground: oklch(1 0 0);
+ --sidebar-accent: oklch(0.95 0.03 70);
+ --sidebar-accent-foreground: oklch(0.3 0.02 60);
+ --sidebar-border: oklch(0.92 0.01 80);
+ --sidebar-ring: oklch(0.65 0.17 55);
+ --sidebar: oklch(0.99 0.003 85);
+ --topbar-bg: oklch(1 0 0);
+ --topbar-border: oklch(0.92 0.01 80);
}
.dark {
- --background: oklch(0.129 0.042 264.695);
- --foreground: oklch(0.984 0.003 247.858);
- --card: oklch(0.208 0.042 265.755);
- --card-foreground: oklch(0.984 0.003 247.858);
- --popover: oklch(0.208 0.042 265.755);
- --popover-foreground: oklch(0.984 0.003 247.858);
- --primary: oklch(0.929 0.013 255.508);
- --primary-foreground: oklch(0.208 0.042 265.755);
- --secondary: oklch(0.279 0.041 260.031);
- --secondary-foreground: oklch(0.984 0.003 247.858);
- --muted: oklch(0.279 0.041 260.031);
- --muted-foreground: oklch(0.704 0.04 256.788);
- --accent: oklch(0.279 0.041 260.031);
- --accent-foreground: oklch(0.984 0.003 247.858);
+ --background: oklch(0.17 0.015 60);
+ --foreground: oklch(0.96 0.005 85);
+ --card: oklch(0.22 0.015 60);
+ --card-foreground: oklch(0.96 0.005 85);
+ --popover: oklch(0.22 0.015 60);
+ --popover-foreground: oklch(0.96 0.005 85);
+ --primary: oklch(0.72 0.17 55);
+ --primary-foreground: oklch(0.15 0.02 60);
+ --secondary: oklch(0.26 0.015 60);
+ --secondary-foreground: oklch(0.96 0.005 85);
+ --muted: oklch(0.26 0.015 60);
+ --muted-foreground: oklch(0.65 0.02 60);
+ --accent: oklch(0.65 0.16 250);
+ --accent-foreground: oklch(0.96 0.005 85);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
- --ring: oklch(0.551 0.027 264.364);
- --chart-1: oklch(0.488 0.243 264.376);
- --chart-2: oklch(0.696 0.17 162.48);
- --chart-3: oklch(0.769 0.188 70.08);
- --chart-4: oklch(0.627 0.265 303.9);
- --chart-5: oklch(0.645 0.246 16.439);
- --sidebar: oklch(0.208 0.042 265.755);
- --sidebar-foreground: oklch(0.984 0.003 247.858);
- --sidebar-primary: oklch(0.488 0.243 264.376);
- --sidebar-primary-foreground: oklch(0.984 0.003 247.858);
- --sidebar-accent: oklch(0.279 0.041 260.031);
- --sidebar-accent-foreground: oklch(0.984 0.003 247.858);
+ --ring: oklch(0.72 0.17 55);
+ --chart-1: oklch(0.72 0.17 55);
+ --chart-2: oklch(0.65 0.16 250);
+ --chart-3: oklch(0.8 0.14 65);
+ --chart-4: oklch(0.6 0.2 300);
+ --chart-5: oklch(0.7 0.22 20);
+ --sidebar-foreground: oklch(0.96 0.005 85);
+ --sidebar-primary: oklch(0.72 0.17 55);
+ --sidebar-primary-foreground: oklch(0.15 0.02 60);
+ --sidebar-accent: oklch(0.26 0.02 55);
+ --sidebar-accent-foreground: oklch(0.96 0.005 85);
--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 {
@@ -109,6 +113,8 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
+ --color-topbar: var(--topbar-bg);
+ --color-topbar-border: var(--topbar-border);
}
@layer base {
diff --git a/src/routes/page.svelte.spec.ts b/src/routes/page.svelte.spec.ts
index 5b110b3..08ddddc 100644
--- a/src/routes/page.svelte.spec.ts
+++ b/src/routes/page.svelte.spec.ts
@@ -1,14 +1,13 @@
import { describe, expect, it } from 'vitest';
import { render } from 'vitest-browser-svelte';
-import { page } from 'vitest/browser';
import Page from './+page.svelte';
describe('/+page.svelte', () => {
- it('should render h1', async () => {
+ it('should render the redirect page with correct title', async () => {
render(Page);
- const heading = page.getByRole('heading', { level: 1 });
- await expect.element(heading).toBeInTheDocument();
+ const title = document.querySelector('title');
+ expect(title?.textContent).toBe('Open-Meteo Weather');
});
});
diff --git a/src/routes/weather/+layout.svelte b/src/routes/weather/+layout.svelte
index c6723cb..8e8c880 100644
--- a/src/routes/weather/+layout.svelte
+++ b/src/routes/weather/+layout.svelte
@@ -1,216 +1,9 @@
-
-
-
-
-
-
-
-
- {#if mounted}
-
-
-
-
- {getPageTitle()}
-
-
-
-
-
-
-
- {#if location}
-
-
-
-
-
-
-
-
- {location.name}
-
-
- {#if location.admin1}{location.admin1},
- {/if}{location.country}
-
-
- {location.latitude.toFixed(2)}°N, {location.longitude.toFixed(2)}°E
- {#if location.elevation}• {location.elevation.toFixed(0)}m{/if}
-
-
-
-
-
- {#if currentWeather}
-
-
-
- {getWeatherIcon(currentWeather.current.weather_code)}
-
-
- {Math.round(currentWeather.current.temperature_2m)}°C
-
-
-
- {/if}
-
- {/if}
-
-
-
- {
- storedLocation.set(event.detail);
- }}
- />
-
-
-
-
-
- {#if location?.population}
-
-
-
Population
-
{location.population.toLocaleString()}
-
- {#if location.timezone}
-
-
Timezone
-
{location.timezone}
-
- {/if}
-
- {/if}
-
- {/if}
-
-
-
-
-
-
-
-
-
- {#if mounted}
-
- {@render children?.()}
-
- {/if}
-
-
-
-
+{@render children?.()}
diff --git a/src/routes/weather/+layout.ts b/src/routes/weather/+layout.ts
index d2a2971..d12525b 100644
--- a/src/routes/weather/+layout.ts
+++ b/src/routes/weather/+layout.ts
@@ -4,9 +4,8 @@ import { storedLocation } from '$lib/stores/settings';
import type { LayoutLoad } from './$types';
-const location = get(storedLocation);
-
export const load: LayoutLoad = async () => {
+ const location = get(storedLocation);
return {
title: `Weather ${location.name}`,
location: location
diff --git a/src/routes/weather/14-day/+layout.ts b/src/routes/weather/14-day/+layout.ts
index 55c9dc1..4ab5514 100644
--- a/src/routes/weather/14-day/+layout.ts
+++ b/src/routes/weather/14-day/+layout.ts
@@ -4,9 +4,8 @@ import { storedLocation } from '$lib/stores/settings';
import type { LayoutLoad } from './$types';
-const location = get(storedLocation);
-
export const load: LayoutLoad = async () => {
+ const location = get(storedLocation);
return {
heroTitle: `14 Day Weather ${location.name}`,
heroDescription: location.admin1 ?? '' + ' ' + location.country
diff --git a/src/routes/weather/14-day/+page.ts b/src/routes/weather/14-day/+page.ts
new file mode 100644
index 0000000..a860658
--- /dev/null
+++ b/src/routes/weather/14-day/+page.ts
@@ -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;
diff --git a/src/routes/weather/14-day/+page.svelte b/src/routes/weather/14-day/[location]/+page.svelte
similarity index 95%
rename from src/routes/weather/14-day/+page.svelte
rename to src/routes/weather/14-day/[location]/+page.svelte
index 1500798..94c2ea9 100644
--- a/src/routes/weather/14-day/+page.svelte
+++ b/src/routes/weather/14-day/[location]/+page.svelte
@@ -1,8 +1,7 @@