feat: move nav bar to the side (#6)
Co-authored-by: terraputix <terraputix@mailbox.org> Reviewed-on: useweb/ombrella#6
This commit is contained in:
@@ -1,216 +1,9 @@
|
||||
<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 {
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<WeatherNav />
|
||||
|
||||
<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>
|
||||
{@render children?.()}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
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 {
|
||||
buildAverageSeries,
|
||||
@@ -24,7 +23,7 @@
|
||||
fetchEnsembleForecast
|
||||
} from '$lib/services/weather';
|
||||
|
||||
import { defaultParameters } from '../options';
|
||||
import { defaultParameters } from '../../options';
|
||||
|
||||
import type * as echarts from 'echarts';
|
||||
|
||||
@@ -40,11 +39,12 @@
|
||||
let mounted = $state(false);
|
||||
let loading = $state(true);
|
||||
|
||||
const location = get(storedLocation);
|
||||
let location = $state<GeoLocation>($storedLocation);
|
||||
storedLocation.subscribe((value) => {
|
||||
location = value;
|
||||
});
|
||||
|
||||
let params = $state({
|
||||
latitude: [52.52],
|
||||
longitude: [13.41],
|
||||
...defaultParameters,
|
||||
hourly: ['temperature_2m'],
|
||||
models: ['gfs_seamless']
|
||||
@@ -87,14 +87,16 @@
|
||||
|
||||
if (!mounted || !hourlyVars?.length || !modelList?.length) return;
|
||||
|
||||
const loc = location;
|
||||
|
||||
const loadData = async () => {
|
||||
loading = true;
|
||||
chartInstances = [];
|
||||
chartComponents = [];
|
||||
|
||||
const result: EnsembleForecastResult = await fetchEnsembleForecast({
|
||||
latitude: location.latitude!,
|
||||
longitude: location.longitude!,
|
||||
latitude: loc.latitude!,
|
||||
longitude: loc.longitude!,
|
||||
hourlyVariables: hourlyVars,
|
||||
models: modelList,
|
||||
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">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import { storedLocation } from '$lib/stores/settings';
|
||||
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
|
||||
|
||||
import {
|
||||
buildAverageSeries,
|
||||
@@ -28,8 +27,8 @@
|
||||
fetchModelComparison
|
||||
} from '$lib/services/weather';
|
||||
|
||||
import { hourly, models as modelsFlat } from '../options';
|
||||
import { defaultParameters } from '../options';
|
||||
import { hourly, models as modelsFlat } from '../../options';
|
||||
import { defaultParameters } from '../../options';
|
||||
|
||||
import type * as echarts from 'echarts';
|
||||
|
||||
@@ -47,11 +46,12 @@
|
||||
let mounted = $state(false);
|
||||
let loading = $state(true);
|
||||
|
||||
const location = get(storedLocation);
|
||||
let location = $state<GeoLocation>($storedLocation);
|
||||
storedLocation.subscribe((value) => {
|
||||
location = value;
|
||||
});
|
||||
|
||||
let params = $state({
|
||||
latitude: [52.52],
|
||||
longitude: [13.41],
|
||||
...defaultParameters,
|
||||
hourly: ['temperature_2m', 'rain', 'relative_humidity_2m'],
|
||||
models: [
|
||||
@@ -101,14 +101,16 @@
|
||||
|
||||
if (!mounted || !hourlyVars?.length || !modelList?.length) return;
|
||||
|
||||
const loc = location;
|
||||
|
||||
const loadData = async () => {
|
||||
loading = true;
|
||||
chartInstances = [];
|
||||
chartComponents = [];
|
||||
|
||||
const result: ModelCompareResult = await fetchModelComparison({
|
||||
latitude: location.latitude!,
|
||||
longitude: location.longitude!,
|
||||
latitude: loc.latitude!,
|
||||
longitude: loc.longitude!,
|
||||
hourlyVariables: hourlyVars,
|
||||
models: modelList,
|
||||
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 { geoLocationNameToRoute } from '$lib/utils/meteo';
|
||||
import { buildLocationRoute } from '$lib/utils/location';
|
||||
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async () => {
|
||||
const location = get(storedLocation);
|
||||
const locationRoute = geoLocationNameToRoute(location.name);
|
||||
throw redirect(
|
||||
303,
|
||||
'/weather/week/' +
|
||||
(location.population
|
||||
? location.population > 543000
|
||||
? locationRoute
|
||||
: locationRoute + '_' + location.id
|
||||
: locationRoute + '_' + location.id)
|
||||
);
|
||||
const locationRoute = buildLocationRoute(location);
|
||||
throw redirect(303, '/weather/week/' + locationRoute);
|
||||
}) satisfies PageLoad;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
|
||||
import { storedLocation } from '$lib/stores/settings';
|
||||
@@ -31,38 +31,19 @@
|
||||
let loading = $state(true);
|
||||
|
||||
const selectedDay = new SvelteDate();
|
||||
let selectedDayIndex = $state(1);
|
||||
|
||||
let fetchedHourly: FetchedHourly | null = $state(null);
|
||||
let fetchedDaily: FetchedDaily | null = $state(null);
|
||||
|
||||
let meteogramCharts: MeteogramCharts | undefined = $state();
|
||||
|
||||
const switchDay = (date: Date, index: number) => {
|
||||
const switchDay = (date: Date) => {
|
||||
selectedDay.setTime(date.getTime());
|
||||
selectedDayIndex = index;
|
||||
meteogramCharts?.scrollToDay(date);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
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(() => {
|
||||
|
||||
@@ -1,89 +1,13 @@
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
|
||||
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
|
||||
|
||||
import { geoLocationNameToRoute } from '$lib/utils/meteo';
|
||||
import { resolveLocationFromRoute } from '$lib/utils/location';
|
||||
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async (event) => {
|
||||
const urlLocation = event.params.location;
|
||||
let urlLocationSplit, urlLocationName, urlLocationId;
|
||||
const location = await resolveLocationFromRoute({
|
||||
urlLocation: event.params.location,
|
||||
routePrefix: '/weather/week/',
|
||||
event
|
||||
});
|
||||
|
||||
if (urlLocation.includes('_')) {
|
||||
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 };
|
||||
return { location };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user