i18n
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
|
||||
import { LOCALE_LABELS, LOCALE_LIST } from '$lib/i18n';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import { type Locale, getLocale, localizeHref } from '$lib/paraglide/runtime';
|
||||
|
||||
interface Props {
|
||||
/** Called after a language is picked (used to close the menu around it). */
|
||||
onSelect?: () => void;
|
||||
}
|
||||
|
||||
let { onSelect }: Props = $props();
|
||||
|
||||
let current = $derived.by(() => {
|
||||
// re-read on navigation: the URL is what decides the locale
|
||||
void $page.url.pathname;
|
||||
return getLocale();
|
||||
});
|
||||
|
||||
/** The current page, in another language. */
|
||||
function switchTo(locale: Locale): string {
|
||||
return localizeHref($page.url.pathname + $page.url.search, { locale });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<span
|
||||
class="mb-1.5 block text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
|
||||
>
|
||||
{m.language_label()}
|
||||
</span>
|
||||
<!-- Plain links, not buttons: the locale lives in the URL, so switching
|
||||
language is a navigation. It also means each option is shareable and
|
||||
crawlable. -->
|
||||
<div class="grid grid-cols-2 gap-1">
|
||||
{#each LOCALE_LIST as locale (locale)}
|
||||
{@const active = current === locale}
|
||||
<a
|
||||
href={switchTo(locale)}
|
||||
hreflang={locale}
|
||||
data-sveltekit-reload
|
||||
class="cursor-pointer rounded-md px-2 py-1.5 text-center text-[13px] font-semibold transition-colors {active
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:text-foreground'}"
|
||||
aria-current={active ? 'true' : undefined}
|
||||
onclick={onSelect}
|
||||
>
|
||||
{LOCALE_LABELS[locale]}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import LanguageOptions from '$lib/components/language-options.svelte';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import { getLocale } from '$lib/paraglide/runtime';
|
||||
|
||||
import { page } from '$app/stores';
|
||||
|
||||
// the URL decides the locale, so re-read it on navigation
|
||||
let current = $derived.by(() => {
|
||||
void $page.url.pathname;
|
||||
return getLocale();
|
||||
});
|
||||
|
||||
let open = $state(false);
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger
|
||||
class="flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-border/70 px-3 text-xs font-semibold text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground"
|
||||
aria-label={m.language_label()}
|
||||
title={m.language_label()}
|
||||
>
|
||||
<!-- globe -->
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.75">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M3 12h18M12 3a15 15 0 0 1 0 18a15 15 0 0 1 0-18Z" />
|
||||
</svg>
|
||||
<span class="uppercase">{current}</span>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content align="end" class="w-56 border-border">
|
||||
<LanguageOptions onSelect={() => (open = false)} />
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { href } from '$lib/i18n';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import LogoMark from './logo-mark.svelte';
|
||||
|
||||
@@ -22,12 +23,12 @@
|
||||
];
|
||||
|
||||
const forecastLinks = [
|
||||
{ href: resolve('/weather/week'), label: '7-Day Forecast' },
|
||||
{ href: resolve('/weather/compare'), label: 'Model Comparison' },
|
||||
{ href: resolve('/weather/14-day'), label: '14-Day Forecast' },
|
||||
{ href: resolve('/weather/seasonal'), label: 'Seasonal Outlook' },
|
||||
{ href: resolve('/weather/historical'), label: 'Historical Weather' },
|
||||
{ href: resolve('/weather/maps'), label: 'Weather Maps' }
|
||||
{ href: href('/weather/week'), label: m.nav_week() },
|
||||
{ href: href('/weather/compare'), label: m.nav_compare() },
|
||||
{ href: href('/weather/14-day'), label: m.nav_14day() },
|
||||
{ href: href('/weather/seasonal'), label: m.page_seasonal_subtitle() },
|
||||
{ href: href('/weather/historical'), label: m.page_historical_subtitle() },
|
||||
{ href: href('/weather/maps'), label: m.nav_maps() }
|
||||
];
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
@@ -47,10 +48,10 @@
|
||||
<span class="text-lg font-bold tracking-tight">Drizz.li</span>
|
||||
</div>
|
||||
<p class="mt-3 text-sm leading-relaxed text-muted-foreground">
|
||||
Fast, no-nonsense weather forecasts built on open data.
|
||||
{m.footer_tagline()}
|
||||
</p>
|
||||
<p class="mt-3 text-xs text-muted-foreground">
|
||||
Weather data by
|
||||
{m.footer_data_by()}
|
||||
<a
|
||||
class="font-medium underline-offset-2 hover:text-foreground hover:underline"
|
||||
href="https://open-meteo.com"
|
||||
@@ -61,9 +62,9 @@
|
||||
</div>
|
||||
|
||||
<!-- Forecasts -->
|
||||
<nav aria-label="Forecasts">
|
||||
<nav aria-label={m.footer_forecasts()}>
|
||||
<h3 class="text-[11px] font-bold tracking-wider text-muted-foreground uppercase">
|
||||
Forecasts
|
||||
{m.footer_forecasts()}
|
||||
</h3>
|
||||
<ul class="mt-3 flex flex-col gap-2 text-sm">
|
||||
{#each forecastLinks as link (link.href)}
|
||||
@@ -78,16 +79,16 @@
|
||||
</nav>
|
||||
|
||||
<!-- Popular locations (spans two columns of links) -->
|
||||
<nav aria-label="Popular locations" class="lg:col-span-2">
|
||||
<nav aria-label={m.footer_popular()} class="lg:col-span-2">
|
||||
<h3 class="text-[11px] font-bold tracking-wider text-muted-foreground uppercase">
|
||||
Popular locations
|
||||
{m.footer_popular()}
|
||||
</h3>
|
||||
<ul class="mt-3 grid grid-cols-2 gap-x-6 gap-y-2 text-sm sm:grid-cols-3">
|
||||
{#each popularCities as city (city.slug)}
|
||||
<li>
|
||||
<a
|
||||
class="text-foreground/80 underline-offset-2 hover:text-foreground hover:underline"
|
||||
href={resolve('/weather/week/[location]', { location: city.slug })}
|
||||
href={href('/weather/week/[location]', { location: city.slug })}
|
||||
>{city.label} weather</a
|
||||
>
|
||||
</li>
|
||||
@@ -102,20 +103,20 @@
|
||||
>
|
||||
<span>© {year} Drizz.li</span>
|
||||
<nav aria-label="Legal" class="flex flex-wrap items-center gap-x-5 gap-y-1">
|
||||
<a class="underline-offset-2 hover:text-foreground hover:underline" href={resolve('/about')}
|
||||
<a class="underline-offset-2 hover:text-foreground hover:underline" href={href('/about')}
|
||||
>About</a
|
||||
>
|
||||
<a
|
||||
class="underline-offset-2 hover:text-foreground hover:underline"
|
||||
href={resolve('/legal/imprint')}>Imprint</a
|
||||
href={href('/legal/imprint')}>Imprint</a
|
||||
>
|
||||
<a
|
||||
class="underline-offset-2 hover:text-foreground hover:underline"
|
||||
href={resolve('/legal/privacy')}>Privacy</a
|
||||
href={href('/legal/privacy')}>Privacy</a
|
||||
>
|
||||
<a
|
||||
class="underline-offset-2 hover:text-foreground hover:underline"
|
||||
href={resolve('/legal/terms')}>Terms</a
|
||||
href={href('/legal/terms')}>Terms</a
|
||||
>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
import { type GeoLocation, type Theme, storedLocation, storedTheme } from '$lib/stores/settings';
|
||||
|
||||
import { buildLocationRoute } from '$lib/utils/location';
|
||||
|
||||
import LanguageSelector from '$lib/components/language-selector.svelte';
|
||||
import LocationSearch from '$lib/components/location/location-search.svelte';
|
||||
import UnitSelector from '$lib/components/unit-selector.svelte';
|
||||
|
||||
import { href, routePath } from '$lib/i18n';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import SupporterBadge from '$lib/paywall/SupporterBadge.svelte';
|
||||
|
||||
import SettingsMenu from './settings-menu.svelte';
|
||||
@@ -40,10 +42,10 @@
|
||||
});
|
||||
|
||||
const themeCycle: Theme[] = ['system', 'light', 'dark'];
|
||||
const themeTitles: Record<Theme, string> = {
|
||||
system: 'Theme: follow system',
|
||||
light: 'Theme: light',
|
||||
dark: 'Theme: dark'
|
||||
const themeTitles: Record<Theme, () => string> = {
|
||||
system: m.theme_follow_system,
|
||||
light: m.theme_light_title,
|
||||
dark: m.theme_dark_title
|
||||
};
|
||||
|
||||
function cycleTheme() {
|
||||
@@ -55,18 +57,18 @@
|
||||
function navigateToLocation(newLocation: GeoLocation) {
|
||||
storedLocation.set(newLocation);
|
||||
const locationRoute = buildLocationRoute(newLocation);
|
||||
const currentPath = get(page).url.pathname;
|
||||
const currentPath = routePath(get(page).url.pathname);
|
||||
|
||||
if (currentPath.startsWith('/weather/compare')) {
|
||||
goto(resolve('/weather/compare/[location]', { location: locationRoute }));
|
||||
goto(href('/weather/compare/[location]', { location: locationRoute }));
|
||||
} else if (currentPath.startsWith('/weather/14-day')) {
|
||||
goto(resolve('/weather/14-day/[location]', { location: locationRoute }));
|
||||
goto(href('/weather/14-day/[location]', { location: locationRoute }));
|
||||
} else if (currentPath.startsWith('/weather/historical')) {
|
||||
goto(resolve('/weather/historical/[location]', { location: locationRoute }));
|
||||
goto(href('/weather/historical/[location]', { location: locationRoute }));
|
||||
} else if (currentPath.startsWith('/weather/seasonal')) {
|
||||
goto(resolve('/weather/seasonal/[location]', { location: locationRoute }));
|
||||
goto(href('/weather/seasonal/[location]', { location: locationRoute }));
|
||||
} else {
|
||||
goto(resolve('/weather/week/[location]', { location: locationRoute }));
|
||||
goto(href('/weather/week/[location]', { location: locationRoute }));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -81,7 +83,7 @@
|
||||
<button
|
||||
class="-ms-1 flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground md:hidden"
|
||||
onclick={onMenuToggle}
|
||||
aria-label="Toggle menu"
|
||||
aria-label={m.nav_toggle_menu()}
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
@@ -125,7 +127,7 @@
|
||||
<!-- Location search: primary way to switch places, so keep it loud -->
|
||||
<div class="w-full max-w-sm md:max-w-md">
|
||||
<LocationSearch
|
||||
label="Search location..."
|
||||
label={m.search_placeholder()}
|
||||
on:location={(event) => {
|
||||
navigateToLocation(event.detail);
|
||||
}}
|
||||
@@ -142,6 +144,9 @@
|
||||
<!-- md+: the same settings as individual controls. Kept mounted (not `{#if}`)
|
||||
so SupporterBadge still verifies the key on every viewport. -->
|
||||
<div class="hidden items-center gap-3 md:flex">
|
||||
<!-- Language: the locale lives in the URL, so this is a set of links -->
|
||||
<LanguageSelector />
|
||||
|
||||
<!-- Measurement units -->
|
||||
<UnitSelector />
|
||||
|
||||
@@ -152,8 +157,8 @@
|
||||
<button
|
||||
class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/70 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onclick={cycleTheme}
|
||||
title={themeTitles[$storedTheme]}
|
||||
aria-label={themeTitles[$storedTheme]}
|
||||
title={themeTitles[$storedTheme]()}
|
||||
aria-label={themeTitles[$storedTheme]()}
|
||||
>
|
||||
<ThemeIcon theme={$storedTheme} />
|
||||
</button>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { type Theme, storedTheme } from '$lib/stores/settings';
|
||||
|
||||
import LanguageOptions from '$lib/components/language-options.svelte';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import UnitOptions from '$lib/components/unit-options.svelte';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import UnlockDialog from '$lib/paywall/UnlockDialog.svelte';
|
||||
import { isSupporter } from '$lib/paywall/supporter';
|
||||
|
||||
@@ -11,10 +13,10 @@
|
||||
|
||||
// The topbar has room for one control on a phone, so units, theme and the
|
||||
// supporter status share this menu instead of each carrying its own trigger.
|
||||
const THEMES: { value: Theme; label: string }[] = [
|
||||
{ value: 'system', label: 'System' },
|
||||
{ value: 'light', label: 'Light' },
|
||||
{ value: 'dark', label: 'Dark' }
|
||||
const THEMES: { value: Theme; label: () => string }[] = [
|
||||
{ value: 'system', label: m.theme_system },
|
||||
{ value: 'light', label: m.theme_light },
|
||||
{ value: 'dark', label: m.theme_dark }
|
||||
];
|
||||
|
||||
let open = $state(false);
|
||||
@@ -24,8 +26,8 @@
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger
|
||||
class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/70 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground"
|
||||
aria-label="Settings"
|
||||
title="Settings"
|
||||
aria-label={m.settings_title()}
|
||||
title={m.settings_title()}
|
||||
>
|
||||
<!-- gear -->
|
||||
<svg
|
||||
@@ -52,7 +54,7 @@
|
||||
<span
|
||||
class="mb-1.5 block text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
|
||||
>
|
||||
Theme
|
||||
{m.theme_label()}
|
||||
</span>
|
||||
<div class="flex gap-1 rounded-lg bg-muted p-0.5">
|
||||
{#each THEMES as option (option.value)}
|
||||
@@ -65,12 +67,14 @@
|
||||
onclick={() => storedTheme.set(option.value)}
|
||||
>
|
||||
<ThemeIcon theme={option.value} class="h-4 w-4" />
|
||||
{option.label}
|
||||
{option.label()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LanguageOptions onSelect={() => (open = false)} />
|
||||
|
||||
<div class="border-t border-border/70 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
@@ -108,10 +112,10 @@
|
||||
{/if}
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block text-[13px] font-semibold">
|
||||
{$isSupporter ? 'Supporter extras active' : 'Support Drizz.li'}
|
||||
{$isSupporter ? m.supporter_active() : m.supporter_support()}
|
||||
</span>
|
||||
<span class="block text-[11px] text-muted-foreground">
|
||||
{$isSupporter ? 'Manage your access key' : 'Unlock the supporter extras'}
|
||||
{$isSupporter ? m.supporter_manage_key() : m.supporter_unlock()}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
import { href, routePath } from '$lib/i18n';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import LogoMark from './logo-mark.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -14,38 +16,38 @@
|
||||
|
||||
const links = [
|
||||
{
|
||||
title: '7-Day Forecast',
|
||||
title: m.nav_week,
|
||||
url: '/weather/week' as const,
|
||||
iconPaths: [
|
||||
'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Model Comparison',
|
||||
title: m.nav_compare,
|
||||
url: '/weather/compare' as const,
|
||||
iconPaths: ['M13 7h8m0 0v8m0-8l-8 8-4-4-6 6']
|
||||
},
|
||||
{
|
||||
title: '14-Day Forecast',
|
||||
title: m.nav_14day,
|
||||
url: '/weather/14-day' as const,
|
||||
iconPaths: [
|
||||
'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'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Seasonal',
|
||||
title: m.nav_seasonal,
|
||||
url: '/weather/seasonal' as const,
|
||||
// rising trend line (long-range outlook)
|
||||
iconPaths: ['M3 17l6-6 4 4 7-7', 'M16 8h5v5']
|
||||
},
|
||||
{
|
||||
title: 'Historical',
|
||||
title: m.nav_historical,
|
||||
url: '/weather/historical' as const,
|
||||
// clock with a counter-clockwise arrow (history)
|
||||
iconPaths: ['M12 8v4l3 2', 'M3.5 9a9 9 0 1 0 2.2-3.6L3 8m0-4.5V8h4.5']
|
||||
},
|
||||
{
|
||||
title: 'Maps',
|
||||
title: m.nav_maps,
|
||||
url: '/weather/maps' as const,
|
||||
// Heroicons "map" outline icon
|
||||
iconPaths: [
|
||||
@@ -54,7 +56,8 @@
|
||||
}
|
||||
];
|
||||
|
||||
let currentPath = $derived($page.url.pathname);
|
||||
// the URL carries a locale prefix; compare the neutral path behind it
|
||||
let currentPath = $derived(routePath($page.url.pathname));
|
||||
|
||||
const isActive = (url: string) => {
|
||||
return currentPath === url || currentPath.startsWith(url + '/');
|
||||
@@ -70,12 +73,12 @@
|
||||
home link fills the entire row, padding included -->
|
||||
<div class="flex h-14 shrink-0 items-stretch border-b border-sidebar-border">
|
||||
<a
|
||||
href={resolve('/weather/week')}
|
||||
href={href('/weather/week')}
|
||||
class="flex flex-1 items-center gap-2.5 transition-colors hover:bg-sidebar-accent {collapsed
|
||||
? 'justify-center'
|
||||
: 'px-4'}"
|
||||
onclick={onMobileClose}
|
||||
aria-label="Drizzli home"
|
||||
aria-label={m.nav_home()}
|
||||
>
|
||||
<div
|
||||
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground"
|
||||
@@ -92,14 +95,14 @@
|
||||
|
||||
<!-- Navigation links -->
|
||||
<nav class="flex-1 space-y-1 px-2 py-3">
|
||||
{#each links as link (link.title)}
|
||||
{#each links as link (link.url)}
|
||||
{@const active = isActive(link.url)}
|
||||
<a
|
||||
href={resolve(link.url)}
|
||||
href={href(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}
|
||||
title={collapsed ? link.title() : undefined}
|
||||
onclick={onMobileClose}
|
||||
>
|
||||
<div class="flex h-5 w-5 shrink-0 items-center justify-center">
|
||||
@@ -116,7 +119,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
{#if !collapsed}
|
||||
<span class="ml-2.5 whitespace-nowrap">{link.title}</span>
|
||||
<span class="ml-2.5 whitespace-nowrap">{link.title()}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
@@ -130,7 +133,7 @@
|
||||
<button
|
||||
class="relative flex w-full cursor-pointer 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'}
|
||||
title={collapsed ? m.nav_expand_sidebar() : m.nav_collapse_sidebar()}
|
||||
>
|
||||
<div class="flex h-5 w-5 shrink-0 items-center justify-center">
|
||||
<svg
|
||||
@@ -149,7 +152,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
{#if !collapsed}
|
||||
<span class="ml-2.5 whitespace-nowrap">Collapse</span>
|
||||
<span class="ml-2.5 whitespace-nowrap">{m.nav_collapse()}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { type UnitPrefs, storedUnits } from '$lib/stores/settings';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
// each group maps a stored unit key to its selectable options
|
||||
const UNIT_GROUPS: {
|
||||
key: keyof UnitPrefs;
|
||||
label: string;
|
||||
label: () => string;
|
||||
options: { value: string; label: string }[];
|
||||
}[] = [
|
||||
{
|
||||
key: 'temperature_unit',
|
||||
label: 'Temperature',
|
||||
label: m.unit_temperature,
|
||||
options: [
|
||||
{ value: 'celsius', label: '°C' },
|
||||
{ value: 'fahrenheit', label: '°F' }
|
||||
@@ -17,7 +19,7 @@
|
||||
},
|
||||
{
|
||||
key: 'wind_speed_unit',
|
||||
label: 'Wind speed',
|
||||
label: m.unit_wind_speed,
|
||||
options: [
|
||||
{ value: 'kmh', label: 'km/h' },
|
||||
{ value: 'ms', label: 'm/s' },
|
||||
@@ -27,7 +29,7 @@
|
||||
},
|
||||
{
|
||||
key: 'precipitation_unit',
|
||||
label: 'Precipitation',
|
||||
label: m.unit_precipitation,
|
||||
options: [
|
||||
{ value: 'mm', label: 'mm' },
|
||||
{ value: 'inch', label: 'inch' }
|
||||
@@ -46,7 +48,7 @@
|
||||
<span
|
||||
class="mb-1.5 block text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
|
||||
>
|
||||
{group.label}
|
||||
{group.label()}
|
||||
</span>
|
||||
<div class="flex gap-1 rounded-lg bg-muted p-0.5">
|
||||
{#each group.options as opt (opt.value)}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<script lang="ts">
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import UnitOptions from '$lib/components/unit-options.svelte';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
</script>
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger
|
||||
class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/70 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground"
|
||||
aria-label="Choose measurement units"
|
||||
title="Units"
|
||||
aria-label={m.units_aria()}
|
||||
title={m.units_title()}
|
||||
>
|
||||
<!-- gauge icon -->
|
||||
<svg
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Locale-aware routing helpers.
|
||||
*
|
||||
* SvelteKit's `resolve()` returns the language-neutral path a route lives at;
|
||||
* every link has to go through `localizeHref()` on top of that, or clicking it
|
||||
* would drop the visitor back into the base locale (the reroute hook in
|
||||
* `src/hooks.ts` strips the prefix again on the way in).
|
||||
*/
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
import { type Locale, deLocalizeHref, locales, localizeHref } from '$lib/paraglide/runtime';
|
||||
|
||||
import type {
|
||||
PathnameWithSearchOrHash,
|
||||
RouteId,
|
||||
RouteIdWithSearchOrHash,
|
||||
RouteParams
|
||||
} from '$app/types';
|
||||
|
||||
// Mirrors SvelteKit's own (non-exported) argument type for `resolve`, so a
|
||||
// route id still demands exactly the params that route declares.
|
||||
type StripSearchOrHash<T extends string> = T extends `${infer P}?${string}`
|
||||
? P
|
||||
: T extends `${infer P}#${string}`
|
||||
? P
|
||||
: T;
|
||||
|
||||
type ResolveArgs<T extends RouteIdWithSearchOrHash | PathnameWithSearchOrHash> = T extends RouteId
|
||||
? RouteParams<T> extends Record<string, never>
|
||||
? [route: T]
|
||||
: [route: T, params: RouteParams<T>]
|
||||
: StripSearchOrHash<T> extends infer U extends RouteId
|
||||
? RouteParams<U> extends Record<string, never>
|
||||
? [route: T]
|
||||
: [route: T, params: RouteParams<U>]
|
||||
: [route: T];
|
||||
|
||||
/**
|
||||
* `resolve()` for the current locale. Mirrors SvelteKit's own signature, so
|
||||
* route ids and their params keep being type-checked.
|
||||
*/
|
||||
export function href<T extends RouteIdWithSearchOrHash | PathnameWithSearchOrHash>(
|
||||
...args: ResolveArgs<T>
|
||||
): string {
|
||||
return localizeHref(resolve(...args));
|
||||
}
|
||||
|
||||
/** Same as `href`, but forced into a specific locale. */
|
||||
export function hrefIn<T extends RouteIdWithSearchOrHash | PathnameWithSearchOrHash>(
|
||||
locale: Locale,
|
||||
...args: ResolveArgs<T>
|
||||
): string {
|
||||
return localizeHref(resolve(...args), { locale });
|
||||
}
|
||||
|
||||
/** `goto()` that keeps the visitor in their language. */
|
||||
export function gotoLocalized<T extends RouteIdWithSearchOrHash | PathnameWithSearchOrHash>(
|
||||
...args: ResolveArgs<T>
|
||||
) {
|
||||
return goto(href(...args));
|
||||
}
|
||||
|
||||
/**
|
||||
* The language-neutral path for a URL that still carries its locale prefix -
|
||||
* what "which page am I on?" checks have to compare against.
|
||||
*/
|
||||
export function routePath(pathname: string): string {
|
||||
return deLocalizeHref(pathname);
|
||||
}
|
||||
|
||||
/** Display names for the language switcher, in the language itself. */
|
||||
export const LOCALE_LABELS: Record<Locale, string> = {
|
||||
en: 'English',
|
||||
de: 'Deutsch',
|
||||
es: 'Español',
|
||||
fr: 'Français',
|
||||
it: 'Italiano'
|
||||
};
|
||||
|
||||
export const LOCALE_LIST = locales;
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import UnlockDialog from './UnlockDialog.svelte';
|
||||
import { isSupporter, refreshSupporter } from './supporter';
|
||||
|
||||
@@ -16,8 +18,8 @@
|
||||
? 'border-amber-400/50 bg-amber-400/10 text-amber-700 hover:bg-amber-400/20 dark:text-amber-300'
|
||||
: 'border-border/70 text-muted-foreground hover:bg-muted hover:text-foreground'}"
|
||||
onclick={() => (open = true)}
|
||||
title={$isSupporter ? 'Supporter extras active' : 'Support Drizz.li'}
|
||||
aria-label={$isSupporter ? 'Supporter extras active' : 'Support Drizz.li'}
|
||||
title={$isSupporter ? m.supporter_active() : m.supporter_support()}
|
||||
aria-label={$isSupporter ? m.supporter_active() : m.supporter_support()}
|
||||
>
|
||||
{#if $isSupporter}
|
||||
<!-- star -->
|
||||
@@ -26,7 +28,7 @@
|
||||
d="M12 2.5l2.9 6 6.6.9-4.8 4.6 1.2 6.5L12 17.9 6.1 20.5l1.2-6.5L2.5 9.4l6.6-.9L12 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Supporter</span>
|
||||
<span class="hidden sm:inline">{m.supporter_badge()}</span>
|
||||
{:else}
|
||||
<!-- padlock -->
|
||||
<svg
|
||||
@@ -40,7 +42,7 @@
|
||||
<rect x="4" y="10" width="16" height="11" rx="2" />
|
||||
<path stroke-linecap="round" d="M8 10V7a4 4 0 0 1 8 0v3" />
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Supporter</span>
|
||||
<span class="hidden sm:inline">{m.supporter_badge()}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
|
||||
+17
-5
@@ -1,12 +1,24 @@
|
||||
import { isSameDay as isSameDayDateFns } from 'date-fns';
|
||||
import { formatInTimeZone, toZonedTime } from 'date-fns-tz';
|
||||
import { de, enGB, es, fr, it } from 'date-fns/locale';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import { getLocale } from '$lib/paraglide/runtime';
|
||||
|
||||
import type { Locale as DateFnsLocale } from 'date-fns';
|
||||
|
||||
// Weekday and month names come from date-fns, so they have to follow the same
|
||||
// locale the messages do.
|
||||
const DATE_LOCALES: Record<string, DateFnsLocale> = { en: enGB, de, es, fr, it };
|
||||
|
||||
/**
|
||||
* Formats a UTC Date into a string for a specific timezone using date-fns patterns.
|
||||
* Patterns: 'HH:mm' for 24h time, 'EEE' for short weekday, etc.
|
||||
*/
|
||||
export function formatZoned(date: Date, timeZone: string, pattern: string): string {
|
||||
return formatInTimeZone(date, timeZone, pattern);
|
||||
return formatInTimeZone(date, timeZone, pattern, {
|
||||
locale: DATE_LOCALES[getLocale()] ?? enGB
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,17 +47,17 @@ export function getRelativeDayLabel(date: Date, timeZone: string): string {
|
||||
const zonedDate = toZonedTime(date, timeZone);
|
||||
const zonedNow = toZonedTime(now, timeZone);
|
||||
|
||||
if (isSameDayDateFns(zonedDate, zonedNow)) return 'Today';
|
||||
if (isSameDayDateFns(zonedDate, zonedNow)) return m.day_today();
|
||||
|
||||
const tomorrow = new Date(zonedNow);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
if (isSameDayDateFns(zonedDate, tomorrow)) return 'Tomorrow';
|
||||
if (isSameDayDateFns(zonedDate, tomorrow)) return m.day_tomorrow();
|
||||
|
||||
const yesterday = new Date(zonedNow);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
if (isSameDayDateFns(zonedDate, yesterday)) return 'Yesterday';
|
||||
if (isSameDayDateFns(zonedDate, yesterday)) return m.day_yesterday();
|
||||
|
||||
return formatInTimeZone(date, timeZone, 'EEE d MMM');
|
||||
return formatZoned(date, timeZone, 'EEE d MMM');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
|
||||
import { deLocalizeHref, localizeHref } from '$lib/paraglide/runtime';
|
||||
|
||||
import type { GeoLocation } from '$lib/stores/settings';
|
||||
|
||||
export const geoLocationNameToRoute = (name: string) => {
|
||||
@@ -117,10 +119,12 @@ export async function resolveLocationFromRoute({
|
||||
|
||||
// trailingSlash is 'always' (see routes/+layout.ts), so the router serves
|
||||
// every path with a trailing slash. Match that here or the equality check
|
||||
// never holds and the redirect loops forever.
|
||||
// never holds and the redirect loops forever. The comparison also has to
|
||||
// ignore the locale prefix the URL carries, while the redirect keeps it -
|
||||
// otherwise every localized URL would bounce back to English.
|
||||
const canonicalPath = `${routePrefix}${buildLocationRoute(location)}/`;
|
||||
if (event.url.pathname !== canonicalPath) {
|
||||
throw redirect(303, canonicalPath);
|
||||
if (deLocalizeHref(event.url.pathname) !== canonicalPath) {
|
||||
throw redirect(303, localizeHref(canonicalPath));
|
||||
}
|
||||
|
||||
return location;
|
||||
|
||||
Reference in New Issue
Block a user