i18n
This commit is contained in:
@@ -26,3 +26,7 @@ AGENTS.md
|
||||
|
||||
# Local agent/editor config
|
||||
.claude/
|
||||
|
||||
# paraglide compiles the message runtime into the source tree
|
||||
/src/lib/paraglide
|
||||
/.inlang
|
||||
|
||||
@@ -25,6 +25,10 @@ export default defineConfig(
|
||||
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||
'no-undef': 'off',
|
||||
// Internal links go through `href()` / `gotoLocalized()` from $lib/i18n,
|
||||
// which call resolve() and then add the locale prefix. The rule only
|
||||
// recognises a literal resolve() call, so it cannot see through them.
|
||||
'svelte/no-navigation-without-resolve': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"nav_week": "7-Tage-Vorhersage",
|
||||
"nav_compare": "Modellvergleich",
|
||||
"nav_14day": "14-Tage-Vorhersage",
|
||||
"nav_seasonal": "Saisonal",
|
||||
"nav_historical": "Rückblick",
|
||||
"nav_maps": "Karten",
|
||||
"nav_collapse": "Einklappen",
|
||||
"nav_expand_sidebar": "Seitenleiste ausklappen",
|
||||
"nav_collapse_sidebar": "Seitenleiste einklappen",
|
||||
"nav_home": "Drizzli Startseite",
|
||||
"nav_toggle_menu": "Menü umschalten",
|
||||
"search_placeholder": "Ort suchen …",
|
||||
"settings_title": "Einstellungen",
|
||||
"units_title": "Einheiten",
|
||||
"units_aria": "Maßeinheiten wählen",
|
||||
"unit_temperature": "Temperatur",
|
||||
"unit_wind_speed": "Windgeschwindigkeit",
|
||||
"unit_precipitation": "Niederschlag",
|
||||
"theme_label": "Design",
|
||||
"theme_system": "System",
|
||||
"theme_light": "Hell",
|
||||
"theme_dark": "Dunkel",
|
||||
"theme_follow_system": "Design: System folgen",
|
||||
"theme_light_title": "Design: hell",
|
||||
"theme_dark_title": "Design: dunkel",
|
||||
"language_label": "Sprache",
|
||||
"supporter_active": "Unterstützer-Extras aktiv",
|
||||
"supporter_support": "Drizz.li unterstützen",
|
||||
"supporter_manage_key": "Zugangsschlüssel verwalten",
|
||||
"supporter_unlock": "Unterstützer-Extras freischalten",
|
||||
"supporter_badge": "Unterstützer",
|
||||
"strip_past_label": "zurück",
|
||||
"strip_past_aria": "Die letzten 3 Tage laden",
|
||||
"strip_days_label": "Tage",
|
||||
"strip_extend_aria": "Die vollen 15 Tage anzeigen",
|
||||
"strip_history_label": "Archiv",
|
||||
"strip_history_aria": "Wetterarchiv öffnen",
|
||||
"strip_history_title": "Rückblick: jedes Datum zurück bis 1940",
|
||||
"strip_seasonal_label": "saisonal",
|
||||
"strip_seasonal_aria": "Saisonale Aussichten öffnen",
|
||||
"strip_seasonal_title": "Saisonale Aussichten: Monatstrends für die kommenden Monate",
|
||||
"day_today": "Heute",
|
||||
"day_tomorrow": "Morgen",
|
||||
"day_yesterday": "Gestern",
|
||||
"page_week_subtitle": "7-Tage-Vorhersage",
|
||||
"page_compare_subtitle": "Modellvergleich",
|
||||
"page_14day_subtitle": "14-Tage-Ensemblevorhersage",
|
||||
"page_seasonal_subtitle": "Saisonale Aussichten",
|
||||
"page_historical_subtitle": "Wetterrückblick",
|
||||
"hourly_heading": "stündlich",
|
||||
"hourly_variables": "Variablen",
|
||||
"meteograms_heading": "Meteogramme",
|
||||
"meteograms_customize": "Anpassen",
|
||||
"meteograms_zoom_hint": "Ziehen oder",
|
||||
"meteograms_zoom_hint_end": "+ Scrollen zum Zoomen",
|
||||
"range_today": "Heute",
|
||||
"range_selected_day": "Gewählter Tag",
|
||||
"range_3_days": "3 Tage",
|
||||
"range_5_days": "5 Tage",
|
||||
"range_all": "Alles",
|
||||
"range_group_aria": "Zeitraum des Diagramms",
|
||||
"reset_zoom": "Zoom zurücksetzen",
|
||||
"default_range_title": "Standard-Zeitraum",
|
||||
"default_range_auto": "Auto",
|
||||
"default_range_auto_hint": "3 Tage auf dem Handy, alles auf größeren Bildschirmen",
|
||||
"summary_heading": "in Worten",
|
||||
"summary_no_data": "Für diesen Tag liegen keine Stundenwerte vor.",
|
||||
"label_sunrise": "Sonnenaufgang",
|
||||
"label_sunset": "Sonnenuntergang",
|
||||
"label_daylight": "Tageslicht",
|
||||
"label_uv_index": "UV-Index",
|
||||
"label_moonrise": "Mondaufgang",
|
||||
"label_moonset": "Monduntergang",
|
||||
"label_moon": "Mond",
|
||||
"daylight_hours": "{hours} Std. {minutes} Min.",
|
||||
"sunshine_share": "{percent}% Sonne",
|
||||
"uv_low": "Niedrig",
|
||||
"uv_moderate": "Mäßig",
|
||||
"uv_high": "Hoch",
|
||||
"uv_very_high": "Sehr hoch",
|
||||
"uv_extreme": "Extrem",
|
||||
"moon_new": "Neumond",
|
||||
"moon_waxing_crescent": "Zunehmende Sichel",
|
||||
"moon_first_quarter": "Erstes Viertel",
|
||||
"moon_waxing_gibbous": "Zunehmender Mond",
|
||||
"moon_full": "Vollmond",
|
||||
"moon_waning_gibbous": "Abnehmender Mond",
|
||||
"moon_last_quarter": "Letztes Viertel",
|
||||
"moon_waning_crescent": "Abnehmende Sichel",
|
||||
"cond_clear": "klar",
|
||||
"cond_fair": "teils bewölkt",
|
||||
"cond_cloudy": "bedeckt",
|
||||
"cond_fog": "neblig",
|
||||
"cond_drizzle": "nieselig",
|
||||
"cond_rain": "regnerisch",
|
||||
"cond_snow": "schneereich",
|
||||
"cond_thunder": "stürmisch",
|
||||
"period_overnight": "nachts",
|
||||
"period_morning": "morgens",
|
||||
"period_afternoon": "nachmittags",
|
||||
"period_evening": "in den Abendstunden",
|
||||
"forecast_sky_all_day": "Den ganzen Tag {condition}.",
|
||||
"forecast_sky_two": "{p1} {c1}, {p2} {c2}.",
|
||||
"forecast_sky_three": "{p1} {c1}, {p2} {c2}, {p3} dann {c3}.",
|
||||
"forecast_temp": "Höchstwerte um {high}, nachts Abkühlung auf {low}.",
|
||||
"forecast_temp_feels": "Höchstwerte um {high}, nachts Abkühlung auf {low} - gefühlt eher {feels}.",
|
||||
"forecast_precip_window": "Rund {amount} Niederschlag, das meiste davon {when}.",
|
||||
"forecast_precip_spread": "Rund {amount} Niederschlag über den Tag verteilt.",
|
||||
"forecast_precip_chance": "Überwiegend trocken, mit bis zu {percent}% Schauerwahrscheinlichkeit.",
|
||||
"forecast_precip_dry": "Es bleibt durchweg trocken.",
|
||||
"forecast_wind_dir": "Wind aus {direction} mit bis zu {speed}.",
|
||||
"forecast_wind_dir_gusts": "Wind aus {direction} mit bis zu {speed}, in Böen {gust}.",
|
||||
"forecast_wind": "Wind mit bis zu {speed}.",
|
||||
"forecast_wind_gusts": "Wind mit bis zu {speed}, in Böen {gust}.",
|
||||
"forecast_uv": "UV-Index bis {value} ({label}) - mittags an Sonnenschutz denken.",
|
||||
"forecast_calm": "Kaum ein Lüftchen.",
|
||||
"footer_tagline": "Schnelle, unkomplizierte Wettervorhersagen auf Basis offener Daten.",
|
||||
"footer_data_by": "Wetterdaten von",
|
||||
"footer_forecasts": "Vorhersagen",
|
||||
"footer_popular": "Beliebte Orte",
|
||||
"footer_about_section": "Über",
|
||||
"model_weather": "Wettermodell",
|
||||
"model_ensemble": "Ensemblemodell"
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"nav_week": "7-Day Forecast",
|
||||
"nav_compare": "Model Comparison",
|
||||
"nav_14day": "14-Day Forecast",
|
||||
"nav_seasonal": "Seasonal",
|
||||
"nav_historical": "Historical",
|
||||
"nav_maps": "Maps",
|
||||
"nav_collapse": "Collapse",
|
||||
"nav_expand_sidebar": "Expand sidebar",
|
||||
"nav_collapse_sidebar": "Collapse sidebar",
|
||||
"nav_home": "Drizzli home",
|
||||
"nav_toggle_menu": "Toggle menu",
|
||||
"search_placeholder": "Search location...",
|
||||
"settings_title": "Settings",
|
||||
"units_title": "Units",
|
||||
"units_aria": "Choose measurement units",
|
||||
"unit_temperature": "Temperature",
|
||||
"unit_wind_speed": "Wind speed",
|
||||
"unit_precipitation": "Precipitation",
|
||||
"theme_label": "Theme",
|
||||
"theme_system": "System",
|
||||
"theme_light": "Light",
|
||||
"theme_dark": "Dark",
|
||||
"theme_follow_system": "Theme: follow system",
|
||||
"theme_light_title": "Theme: light",
|
||||
"theme_dark_title": "Theme: dark",
|
||||
"language_label": "Language",
|
||||
"supporter_active": "Supporter extras active",
|
||||
"supporter_support": "Support Drizz.li",
|
||||
"supporter_manage_key": "Manage your access key",
|
||||
"supporter_unlock": "Unlock the supporter extras",
|
||||
"supporter_badge": "Supporter",
|
||||
"strip_past_label": "past",
|
||||
"strip_past_aria": "Load the past 3 days",
|
||||
"strip_days_label": "days",
|
||||
"strip_extend_aria": "Show the full 15-day forecast",
|
||||
"strip_history_label": "history",
|
||||
"strip_history_aria": "Open the historical weather archive",
|
||||
"strip_history_title": "Historical weather: any past date back to 1940",
|
||||
"strip_seasonal_label": "seasonal",
|
||||
"strip_seasonal_aria": "Open the seasonal outlook",
|
||||
"strip_seasonal_title": "Seasonal outlook: monthly trends for the months ahead",
|
||||
"day_today": "Today",
|
||||
"day_tomorrow": "Tomorrow",
|
||||
"day_yesterday": "Yesterday",
|
||||
"page_week_subtitle": "7-day forecast",
|
||||
"page_compare_subtitle": "Model comparison",
|
||||
"page_14day_subtitle": "14-day ensemble forecast",
|
||||
"page_seasonal_subtitle": "Seasonal outlook",
|
||||
"page_historical_subtitle": "Historical weather",
|
||||
"hourly_heading": "hourly",
|
||||
"hourly_variables": "Variables",
|
||||
"meteograms_heading": "Meteograms",
|
||||
"meteograms_customize": "Customize",
|
||||
"meteograms_zoom_hint": "drag or",
|
||||
"meteograms_zoom_hint_end": "+ scroll to zoom",
|
||||
"range_today": "Today",
|
||||
"range_selected_day": "Selected day",
|
||||
"range_3_days": "3 days",
|
||||
"range_5_days": "5 days",
|
||||
"range_all": "All",
|
||||
"range_group_aria": "Chart time range",
|
||||
"reset_zoom": "Reset zoom",
|
||||
"default_range_title": "Default time range",
|
||||
"default_range_auto": "Auto",
|
||||
"default_range_auto_hint": "3 days on phones, everything on wider screens",
|
||||
"summary_heading": "in words",
|
||||
"summary_no_data": "No hourly detail available for this day.",
|
||||
"label_sunrise": "Sunrise",
|
||||
"label_sunset": "Sunset",
|
||||
"label_daylight": "Daylight",
|
||||
"label_uv_index": "UV index",
|
||||
"label_moonrise": "Moonrise",
|
||||
"label_moonset": "Moonset",
|
||||
"label_moon": "Moon",
|
||||
"daylight_hours": "{hours} h {minutes} m",
|
||||
"sunshine_share": "{percent}% sun",
|
||||
"uv_low": "Low",
|
||||
"uv_moderate": "Moderate",
|
||||
"uv_high": "High",
|
||||
"uv_very_high": "Very high",
|
||||
"uv_extreme": "Extreme",
|
||||
"moon_new": "New moon",
|
||||
"moon_waxing_crescent": "Waxing crescent",
|
||||
"moon_first_quarter": "First quarter",
|
||||
"moon_waxing_gibbous": "Waxing gibbous",
|
||||
"moon_full": "Full moon",
|
||||
"moon_waning_gibbous": "Waning gibbous",
|
||||
"moon_last_quarter": "Last quarter",
|
||||
"moon_waning_crescent": "Waning crescent",
|
||||
"cond_clear": "clear",
|
||||
"cond_fair": "partly cloudy",
|
||||
"cond_cloudy": "overcast",
|
||||
"cond_fog": "foggy",
|
||||
"cond_drizzle": "drizzly",
|
||||
"cond_rain": "wet",
|
||||
"cond_snow": "snowy",
|
||||
"cond_thunder": "stormy",
|
||||
"period_overnight": "overnight",
|
||||
"period_morning": "in the morning",
|
||||
"period_afternoon": "in the afternoon",
|
||||
"period_evening": "in the evening",
|
||||
"forecast_sky_all_day": "{condition} all day.",
|
||||
"forecast_sky_two": "{c1} {p1}, turning {c2} {p2}.",
|
||||
"forecast_sky_three": "{c1} {p1}, turning {c2} {p2}, then {c3} {p3}.",
|
||||
"forecast_temp": "Highs near {high}, dropping to {low} overnight.",
|
||||
"forecast_temp_feels": "Highs near {high}, dropping to {low} overnight, though it will feel closer to {feels}.",
|
||||
"forecast_precip_window": "Around {amount} of precipitation, most of it {when}.",
|
||||
"forecast_precip_spread": "Around {amount} of precipitation spread across the day.",
|
||||
"forecast_precip_chance": "Mostly dry, with up to a {percent}% chance of catching a shower.",
|
||||
"forecast_precip_dry": "Staying dry throughout.",
|
||||
"forecast_wind_dir": "Wind from the {direction} reaching {speed}.",
|
||||
"forecast_wind_dir_gusts": "Wind from the {direction} reaching {speed}, with gusts to {gust}.",
|
||||
"forecast_wind": "Wind reaching {speed}.",
|
||||
"forecast_wind_gusts": "Wind reaching {speed}, with gusts to {gust}.",
|
||||
"forecast_uv": "UV peaks at {value} ({label}), so cover up around midday.",
|
||||
"forecast_calm": "Barely a breath of wind.",
|
||||
"footer_tagline": "Fast, no-nonsense weather forecasts built on open data.",
|
||||
"footer_data_by": "Weather data by",
|
||||
"footer_forecasts": "Forecasts",
|
||||
"footer_popular": "Popular locations",
|
||||
"footer_about_section": "About",
|
||||
"model_weather": "Weather model",
|
||||
"model_ensemble": "Ensemble model"
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"nav_week": "Pronóstico de 7 días",
|
||||
"nav_compare": "Comparación de modelos",
|
||||
"nav_14day": "Pronóstico de 14 días",
|
||||
"nav_seasonal": "Estacional",
|
||||
"nav_historical": "Histórico",
|
||||
"nav_maps": "Mapas",
|
||||
"nav_collapse": "Contraer",
|
||||
"nav_expand_sidebar": "Expandir barra lateral",
|
||||
"nav_collapse_sidebar": "Contraer barra lateral",
|
||||
"nav_home": "Inicio de Drizzli",
|
||||
"nav_toggle_menu": "Alternar menú",
|
||||
"search_placeholder": "Buscar ubicación…",
|
||||
"settings_title": "Ajustes",
|
||||
"units_title": "Unidades",
|
||||
"units_aria": "Elegir unidades de medida",
|
||||
"unit_temperature": "Temperatura",
|
||||
"unit_wind_speed": "Velocidad del viento",
|
||||
"unit_precipitation": "Precipitación",
|
||||
"theme_label": "Tema",
|
||||
"theme_system": "Sistema",
|
||||
"theme_light": "Claro",
|
||||
"theme_dark": "Oscuro",
|
||||
"theme_follow_system": "Tema: seguir al sistema",
|
||||
"theme_light_title": "Tema: claro",
|
||||
"theme_dark_title": "Tema: oscuro",
|
||||
"language_label": "Idioma",
|
||||
"supporter_active": "Extras de colaborador activos",
|
||||
"supporter_support": "Apoya Drizz.li",
|
||||
"supporter_manage_key": "Gestiona tu clave de acceso",
|
||||
"supporter_unlock": "Desbloquea los extras de colaborador",
|
||||
"supporter_badge": "Colaborador",
|
||||
"strip_past_label": "antes",
|
||||
"strip_past_aria": "Cargar los últimos 3 días",
|
||||
"strip_days_label": "días",
|
||||
"strip_extend_aria": "Ver el pronóstico completo de 15 días",
|
||||
"strip_history_label": "archivo",
|
||||
"strip_history_aria": "Abrir el archivo meteorológico",
|
||||
"strip_history_title": "Histórico: cualquier fecha desde 1940",
|
||||
"strip_seasonal_label": "estacional",
|
||||
"strip_seasonal_aria": "Abrir la previsión estacional",
|
||||
"strip_seasonal_title": "Previsión estacional: tendencias de los próximos meses",
|
||||
"day_today": "Hoy",
|
||||
"day_tomorrow": "Mañana",
|
||||
"day_yesterday": "Ayer",
|
||||
"page_week_subtitle": "pronóstico de 7 días",
|
||||
"page_compare_subtitle": "comparación de modelos",
|
||||
"page_14day_subtitle": "pronóstico de conjunto a 14 días",
|
||||
"page_seasonal_subtitle": "previsión estacional",
|
||||
"page_historical_subtitle": "clima histórico",
|
||||
"hourly_heading": "por horas",
|
||||
"hourly_variables": "Variables",
|
||||
"meteograms_heading": "Meteogramas",
|
||||
"meteograms_customize": "Personalizar",
|
||||
"meteograms_zoom_hint": "arrastra o",
|
||||
"meteograms_zoom_hint_end": "+ desplazar para ampliar",
|
||||
"range_today": "Hoy",
|
||||
"range_selected_day": "Día seleccionado",
|
||||
"range_3_days": "3 días",
|
||||
"range_5_days": "5 días",
|
||||
"range_all": "Todo",
|
||||
"range_group_aria": "Rango temporal del gráfico",
|
||||
"reset_zoom": "Restablecer zoom",
|
||||
"default_range_title": "Rango temporal predeterminado",
|
||||
"default_range_auto": "Auto",
|
||||
"default_range_auto_hint": "3 días en el móvil, todo en pantallas anchas",
|
||||
"summary_heading": "en palabras",
|
||||
"summary_no_data": "No hay datos horarios para este día.",
|
||||
"label_sunrise": "Amanecer",
|
||||
"label_sunset": "Atardecer",
|
||||
"label_daylight": "Luz diurna",
|
||||
"label_uv_index": "Índice UV",
|
||||
"label_moonrise": "Salida de la luna",
|
||||
"label_moonset": "Puesta de la luna",
|
||||
"label_moon": "Luna",
|
||||
"daylight_hours": "{hours} h {minutes} min",
|
||||
"sunshine_share": "{percent}% de sol",
|
||||
"uv_low": "Bajo",
|
||||
"uv_moderate": "Moderado",
|
||||
"uv_high": "Alto",
|
||||
"uv_very_high": "Muy alto",
|
||||
"uv_extreme": "Extremo",
|
||||
"moon_new": "Luna nueva",
|
||||
"moon_waxing_crescent": "Luna creciente",
|
||||
"moon_first_quarter": "Cuarto creciente",
|
||||
"moon_waxing_gibbous": "Gibosa creciente",
|
||||
"moon_full": "Luna llena",
|
||||
"moon_waning_gibbous": "Gibosa menguante",
|
||||
"moon_last_quarter": "Cuarto menguante",
|
||||
"moon_waning_crescent": "Luna menguante",
|
||||
"cond_clear": "despejado",
|
||||
"cond_fair": "parcialmente nublado",
|
||||
"cond_cloudy": "nublado",
|
||||
"cond_fog": "con niebla",
|
||||
"cond_drizzle": "con llovizna",
|
||||
"cond_rain": "lluvioso",
|
||||
"cond_snow": "con nieve",
|
||||
"cond_thunder": "tormentoso",
|
||||
"period_overnight": "por la noche",
|
||||
"period_morning": "por la mañana",
|
||||
"period_afternoon": "por la tarde",
|
||||
"period_evening": "al anochecer",
|
||||
"forecast_sky_all_day": "{condition} todo el día.",
|
||||
"forecast_sky_two": "{c1} {p1} y {c2} {p2}.",
|
||||
"forecast_sky_three": "{c1} {p1}, {c2} {p2} y {c3} {p3}.",
|
||||
"forecast_temp": "Máximas de {high} y mínimas de {low} por la noche.",
|
||||
"forecast_temp_feels": "Máximas de {high} y mínimas de {low} por la noche, aunque la sensación será de {feels}.",
|
||||
"forecast_precip_window": "Unos {amount} de precipitación, sobre todo {when}.",
|
||||
"forecast_precip_spread": "Unos {amount} de precipitación repartidos a lo largo del día.",
|
||||
"forecast_precip_chance": "Mayormente seco, con hasta un {percent}% de probabilidad de chubascos.",
|
||||
"forecast_precip_dry": "Se mantendrá seco todo el día.",
|
||||
"forecast_wind_dir": "Viento del {direction} de hasta {speed}.",
|
||||
"forecast_wind_dir_gusts": "Viento del {direction} de hasta {speed}, con rachas de {gust}.",
|
||||
"forecast_wind": "Viento de hasta {speed}.",
|
||||
"forecast_wind_gusts": "Viento de hasta {speed}, con rachas de {gust}.",
|
||||
"forecast_uv": "El índice UV llega a {value} ({label}), así que protégete al mediodía.",
|
||||
"forecast_calm": "Apenas hay viento.",
|
||||
"footer_tagline": "Pronósticos rápidos y directos basados en datos abiertos.",
|
||||
"footer_data_by": "Datos meteorológicos de",
|
||||
"footer_forecasts": "Pronósticos",
|
||||
"footer_popular": "Ubicaciones populares",
|
||||
"footer_about_section": "Acerca de",
|
||||
"model_weather": "Modelo meteorológico",
|
||||
"model_ensemble": "Modelo de conjunto"
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"nav_week": "Prévisions à 7 jours",
|
||||
"nav_compare": "Comparaison des modèles",
|
||||
"nav_14day": "Prévisions à 14 jours",
|
||||
"nav_seasonal": "Saisonnier",
|
||||
"nav_historical": "Historique",
|
||||
"nav_maps": "Cartes",
|
||||
"nav_collapse": "Réduire",
|
||||
"nav_expand_sidebar": "Déployer la barre latérale",
|
||||
"nav_collapse_sidebar": "Réduire la barre latérale",
|
||||
"nav_home": "Accueil Drizzli",
|
||||
"nav_toggle_menu": "Basculer le menu",
|
||||
"search_placeholder": "Rechercher un lieu…",
|
||||
"settings_title": "Paramètres",
|
||||
"units_title": "Unités",
|
||||
"units_aria": "Choisir les unités de mesure",
|
||||
"unit_temperature": "Température",
|
||||
"unit_wind_speed": "Vitesse du vent",
|
||||
"unit_precipitation": "Précipitations",
|
||||
"theme_label": "Thème",
|
||||
"theme_system": "Système",
|
||||
"theme_light": "Clair",
|
||||
"theme_dark": "Sombre",
|
||||
"theme_follow_system": "Thème : suivre le système",
|
||||
"theme_light_title": "Thème : clair",
|
||||
"theme_dark_title": "Thème : sombre",
|
||||
"language_label": "Langue",
|
||||
"supporter_active": "Bonus contributeur actifs",
|
||||
"supporter_support": "Soutenir Drizz.li",
|
||||
"supporter_manage_key": "Gérer votre clé d'accès",
|
||||
"supporter_unlock": "Débloquer les bonus contributeur",
|
||||
"supporter_badge": "Contributeur",
|
||||
"strip_past_label": "avant",
|
||||
"strip_past_aria": "Charger les 3 derniers jours",
|
||||
"strip_days_label": "jours",
|
||||
"strip_extend_aria": "Afficher les 15 jours complets",
|
||||
"strip_history_label": "archives",
|
||||
"strip_history_aria": "Ouvrir les archives météo",
|
||||
"strip_history_title": "Historique : toute date depuis 1940",
|
||||
"strip_seasonal_label": "saisonnier",
|
||||
"strip_seasonal_aria": "Ouvrir les tendances saisonnières",
|
||||
"strip_seasonal_title": "Tendances saisonnières : les mois à venir",
|
||||
"day_today": "Aujourd'hui",
|
||||
"day_tomorrow": "Demain",
|
||||
"day_yesterday": "Hier",
|
||||
"page_week_subtitle": "prévisions à 7 jours",
|
||||
"page_compare_subtitle": "comparaison des modèles",
|
||||
"page_14day_subtitle": "prévision d'ensemble à 14 jours",
|
||||
"page_seasonal_subtitle": "tendances saisonnières",
|
||||
"page_historical_subtitle": "météo historique",
|
||||
"hourly_heading": "par heure",
|
||||
"hourly_variables": "Variables",
|
||||
"meteograms_heading": "Météogrammes",
|
||||
"meteograms_customize": "Personnaliser",
|
||||
"meteograms_zoom_hint": "glissez ou",
|
||||
"meteograms_zoom_hint_end": "+ défiler pour zoomer",
|
||||
"range_today": "Aujourd'hui",
|
||||
"range_selected_day": "Jour sélectionné",
|
||||
"range_3_days": "3 jours",
|
||||
"range_5_days": "5 jours",
|
||||
"range_all": "Tout",
|
||||
"range_group_aria": "Plage horaire du graphique",
|
||||
"reset_zoom": "Réinitialiser le zoom",
|
||||
"default_range_title": "Plage horaire par défaut",
|
||||
"default_range_auto": "Auto",
|
||||
"default_range_auto_hint": "3 jours sur mobile, tout sur grand écran",
|
||||
"summary_heading": "en résumé",
|
||||
"summary_no_data": "Aucun détail horaire pour ce jour.",
|
||||
"label_sunrise": "Lever du soleil",
|
||||
"label_sunset": "Coucher du soleil",
|
||||
"label_daylight": "Durée du jour",
|
||||
"label_uv_index": "Indice UV",
|
||||
"label_moonrise": "Lever de la lune",
|
||||
"label_moonset": "Coucher de la lune",
|
||||
"label_moon": "Lune",
|
||||
"daylight_hours": "{hours} h {minutes} min",
|
||||
"sunshine_share": "{percent}% de soleil",
|
||||
"uv_low": "Faible",
|
||||
"uv_moderate": "Modéré",
|
||||
"uv_high": "Élevé",
|
||||
"uv_very_high": "Très élevé",
|
||||
"uv_extreme": "Extrême",
|
||||
"moon_new": "Nouvelle lune",
|
||||
"moon_waxing_crescent": "Premier croissant",
|
||||
"moon_first_quarter": "Premier quartier",
|
||||
"moon_waxing_gibbous": "Gibbeuse croissante",
|
||||
"moon_full": "Pleine lune",
|
||||
"moon_waning_gibbous": "Gibbeuse décroissante",
|
||||
"moon_last_quarter": "Dernier quartier",
|
||||
"moon_waning_crescent": "Dernier croissant",
|
||||
"cond_clear": "dégagé",
|
||||
"cond_fair": "partiellement nuageux",
|
||||
"cond_cloudy": "couvert",
|
||||
"cond_fog": "brumeux",
|
||||
"cond_drizzle": "bruineux",
|
||||
"cond_rain": "pluvieux",
|
||||
"cond_snow": "neigeux",
|
||||
"cond_thunder": "orageux",
|
||||
"period_overnight": "la nuit",
|
||||
"period_morning": "le matin",
|
||||
"period_afternoon": "l'après-midi",
|
||||
"period_evening": "en soirée",
|
||||
"forecast_sky_all_day": "{condition} toute la journée.",
|
||||
"forecast_sky_two": "{c1} {p1}, puis {c2} {p2}.",
|
||||
"forecast_sky_three": "{c1} {p1}, puis {c2} {p2} et enfin {c3} {p3}.",
|
||||
"forecast_temp": "Maximales proches de {high}, minimales de {low} la nuit.",
|
||||
"forecast_temp_feels": "Maximales proches de {high}, minimales de {low} la nuit, mais ressenti plutôt {feels}.",
|
||||
"forecast_precip_window": "Environ {amount} de précipitations, surtout {when}.",
|
||||
"forecast_precip_spread": "Environ {amount} de précipitations réparties sur la journée.",
|
||||
"forecast_precip_chance": "Temps plutôt sec, avec jusqu'à {percent}% de risque d'averse.",
|
||||
"forecast_precip_dry": "Le temps reste sec toute la journée.",
|
||||
"forecast_wind_dir": "Vent de {direction} atteignant {speed}.",
|
||||
"forecast_wind_dir_gusts": "Vent de {direction} atteignant {speed}, avec des rafales à {gust}.",
|
||||
"forecast_wind": "Vent atteignant {speed}.",
|
||||
"forecast_wind_gusts": "Vent atteignant {speed}, avec des rafales à {gust}.",
|
||||
"forecast_uv": "Indice UV jusqu'à {value} ({label}) : protégez-vous en milieu de journée.",
|
||||
"forecast_calm": "Pratiquement pas de vent.",
|
||||
"footer_tagline": "Des prévisions météo rapides et sans fioritures, basées sur des données ouvertes.",
|
||||
"footer_data_by": "Données météo par",
|
||||
"footer_forecasts": "Prévisions",
|
||||
"footer_popular": "Lieux populaires",
|
||||
"footer_about_section": "À propos",
|
||||
"model_weather": "Modèle météo",
|
||||
"model_ensemble": "Modèle d'ensemble"
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"nav_week": "Previsioni a 7 giorni",
|
||||
"nav_compare": "Confronto modelli",
|
||||
"nav_14day": "Previsioni a 14 giorni",
|
||||
"nav_seasonal": "Stagionale",
|
||||
"nav_historical": "Storico",
|
||||
"nav_maps": "Mappe",
|
||||
"nav_collapse": "Comprimi",
|
||||
"nav_expand_sidebar": "Espandi barra laterale",
|
||||
"nav_collapse_sidebar": "Comprimi barra laterale",
|
||||
"nav_home": "Home di Drizzli",
|
||||
"nav_toggle_menu": "Attiva/disattiva menu",
|
||||
"search_placeholder": "Cerca una località…",
|
||||
"settings_title": "Impostazioni",
|
||||
"units_title": "Unità",
|
||||
"units_aria": "Scegli le unità di misura",
|
||||
"unit_temperature": "Temperatura",
|
||||
"unit_wind_speed": "Velocità del vento",
|
||||
"unit_precipitation": "Precipitazioni",
|
||||
"theme_label": "Tema",
|
||||
"theme_system": "Sistema",
|
||||
"theme_light": "Chiaro",
|
||||
"theme_dark": "Scuro",
|
||||
"theme_follow_system": "Tema: segui il sistema",
|
||||
"theme_light_title": "Tema: chiaro",
|
||||
"theme_dark_title": "Tema: scuro",
|
||||
"language_label": "Lingua",
|
||||
"supporter_active": "Extra sostenitore attivi",
|
||||
"supporter_support": "Sostieni Drizz.li",
|
||||
"supporter_manage_key": "Gestisci la tua chiave di accesso",
|
||||
"supporter_unlock": "Sblocca gli extra sostenitore",
|
||||
"supporter_badge": "Sostenitore",
|
||||
"strip_past_label": "prima",
|
||||
"strip_past_aria": "Carica gli ultimi 3 giorni",
|
||||
"strip_days_label": "giorni",
|
||||
"strip_extend_aria": "Mostra le previsioni complete a 15 giorni",
|
||||
"strip_history_label": "archivio",
|
||||
"strip_history_aria": "Apri l'archivio meteo",
|
||||
"strip_history_title": "Storico: qualsiasi data dal 1940",
|
||||
"strip_seasonal_label": "stagionale",
|
||||
"strip_seasonal_aria": "Apri le tendenze stagionali",
|
||||
"strip_seasonal_title": "Tendenze stagionali: i prossimi mesi",
|
||||
"day_today": "Oggi",
|
||||
"day_tomorrow": "Domani",
|
||||
"day_yesterday": "Ieri",
|
||||
"page_week_subtitle": "previsioni a 7 giorni",
|
||||
"page_compare_subtitle": "confronto modelli",
|
||||
"page_14day_subtitle": "previsione d'insieme a 14 giorni",
|
||||
"page_seasonal_subtitle": "tendenze stagionali",
|
||||
"page_historical_subtitle": "meteo storico",
|
||||
"hourly_heading": "orario",
|
||||
"hourly_variables": "Variabili",
|
||||
"meteograms_heading": "Meteogrammi",
|
||||
"meteograms_customize": "Personalizza",
|
||||
"meteograms_zoom_hint": "trascina o",
|
||||
"meteograms_zoom_hint_end": "+ scorri per ingrandire",
|
||||
"range_today": "Oggi",
|
||||
"range_selected_day": "Giorno selezionato",
|
||||
"range_3_days": "3 giorni",
|
||||
"range_5_days": "5 giorni",
|
||||
"range_all": "Tutto",
|
||||
"range_group_aria": "Intervallo temporale del grafico",
|
||||
"reset_zoom": "Reimposta zoom",
|
||||
"default_range_title": "Intervallo temporale predefinito",
|
||||
"default_range_auto": "Auto",
|
||||
"default_range_auto_hint": "3 giorni su cellulare, tutto su schermi ampi",
|
||||
"summary_heading": "in parole",
|
||||
"summary_no_data": "Nessun dato orario per questo giorno.",
|
||||
"label_sunrise": "Alba",
|
||||
"label_sunset": "Tramonto",
|
||||
"label_daylight": "Ore di luce",
|
||||
"label_uv_index": "Indice UV",
|
||||
"label_moonrise": "Sorgere della luna",
|
||||
"label_moonset": "Tramonto della luna",
|
||||
"label_moon": "Luna",
|
||||
"daylight_hours": "{hours} h {minutes} min",
|
||||
"sunshine_share": "{percent}% di sole",
|
||||
"uv_low": "Basso",
|
||||
"uv_moderate": "Moderato",
|
||||
"uv_high": "Alto",
|
||||
"uv_very_high": "Molto alto",
|
||||
"uv_extreme": "Estremo",
|
||||
"moon_new": "Luna nuova",
|
||||
"moon_waxing_crescent": "Luna crescente",
|
||||
"moon_first_quarter": "Primo quarto",
|
||||
"moon_waxing_gibbous": "Gibbosa crescente",
|
||||
"moon_full": "Luna piena",
|
||||
"moon_waning_gibbous": "Gibbosa calante",
|
||||
"moon_last_quarter": "Ultimo quarto",
|
||||
"moon_waning_crescent": "Luna calante",
|
||||
"cond_clear": "sereno",
|
||||
"cond_fair": "parzialmente nuvoloso",
|
||||
"cond_cloudy": "coperto",
|
||||
"cond_fog": "nebbioso",
|
||||
"cond_drizzle": "con pioviggine",
|
||||
"cond_rain": "piovoso",
|
||||
"cond_snow": "nevoso",
|
||||
"cond_thunder": "temporalesco",
|
||||
"period_overnight": "durante la notte",
|
||||
"period_morning": "al mattino",
|
||||
"period_afternoon": "nel pomeriggio",
|
||||
"period_evening": "in serata",
|
||||
"forecast_sky_all_day": "{condition} per tutta la giornata.",
|
||||
"forecast_sky_two": "{c1} {p1}, poi {c2} {p2}.",
|
||||
"forecast_sky_three": "{c1} {p1}, poi {c2} {p2} e infine {c3} {p3}.",
|
||||
"forecast_temp": "Massime intorno a {high} e minime di {low} durante la notte.",
|
||||
"forecast_temp_feels": "Massime intorno a {high} e minime di {low} durante la notte, ma con una percezione di {feels}.",
|
||||
"forecast_precip_window": "Circa {amount} di precipitazioni, soprattutto {when}.",
|
||||
"forecast_precip_spread": "Circa {amount} di precipitazioni distribuite nell'arco della giornata.",
|
||||
"forecast_precip_chance": "Perlopiù asciutto, con una probabilità di rovesci fino al {percent}%.",
|
||||
"forecast_precip_dry": "Resterà asciutto tutto il giorno.",
|
||||
"forecast_wind_dir": "Vento da {direction} fino a {speed}.",
|
||||
"forecast_wind_dir_gusts": "Vento da {direction} fino a {speed}, con raffiche a {gust}.",
|
||||
"forecast_wind": "Vento fino a {speed}.",
|
||||
"forecast_wind_gusts": "Vento fino a {speed}, con raffiche a {gust}.",
|
||||
"forecast_uv": "Indice UV fino a {value} ({label}): proteggiti a metà giornata.",
|
||||
"forecast_calm": "Quasi assenza di vento.",
|
||||
"footer_tagline": "Previsioni meteo rapide e senza fronzoli, basate su dati aperti.",
|
||||
"footer_data_by": "Dati meteo da",
|
||||
"footer_forecasts": "Previsioni",
|
||||
"footer_popular": "Località popolari",
|
||||
"footer_about_section": "Informazioni",
|
||||
"model_weather": "Modello meteo",
|
||||
"model_ensemble": "Modello d'insieme"
|
||||
}
|
||||
Generated
+268
@@ -10,6 +10,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^2.1.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@inlang/paraglide-js": "^2.23.0",
|
||||
"@internationalized/date": "^3.12.2",
|
||||
"@lucide/svelte": "^1.25.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
@@ -495,6 +496,64 @@
|
||||
"url": "https://github.com/sponsors/nzakas"
|
||||
}
|
||||
},
|
||||
"node_modules/@inlang/paraglide-js": {
|
||||
"version": "2.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@inlang/paraglide-js/-/paraglide-js-2.23.0.tgz",
|
||||
"integrity": "sha512-q+FQisRAVQqyD+0fdVHPkC7UDfaXENUJqUjim7XGEzWHE6vVMUOO10MFtAOKx5VYAgi8o7rLwe4zjzesCY0Anw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@inlang/recommend-sherlock": "^0.2.1",
|
||||
"@inlang/sdk": "^2.10.0",
|
||||
"commander": "11.1.0",
|
||||
"consola": "3.4.0",
|
||||
"json5": "2.2.3",
|
||||
"unplugin": "^2.1.2",
|
||||
"urlpattern-polyfill": "^10.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"paraglide-js": "bin/run.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.6",
|
||||
"vite": ">=5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@inlang/recommend-sherlock": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@inlang/recommend-sherlock/-/recommend-sherlock-0.2.1.tgz",
|
||||
"integrity": "sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"comment-json": "^4.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@inlang/sdk": {
|
||||
"version": "2.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@inlang/sdk/-/sdk-2.10.2.tgz",
|
||||
"integrity": "sha512-O1ki72SNK6LPagaGrvlioBb1mWKvump7cO7P85hfGZjdFTmDdn3icI0A6MvaBsB3P9KQHAjzyubnN1OslGufTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lix-js/sdk": "0.4.10",
|
||||
"@sinclair/typebox": "^0.31.17",
|
||||
"kysely": "^0.28.12",
|
||||
"sqlite-wasm-kysely": "0.3.0",
|
||||
"uuid": "^14.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@internationalized/date": {
|
||||
"version": "3.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.2.tgz",
|
||||
@@ -555,6 +614,32 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@lix-js/sdk": {
|
||||
"version": "0.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@lix-js/sdk/-/sdk-0.4.10.tgz",
|
||||
"integrity": "sha512-0dMInAJK/67guTG5rRZaCEhvzC5cCXENOjaePA5AqMXrCE97kaY7SRor9e2vnoGsFIiGqXKlT0MCIoZj36G0gg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@lix-js/server-protocol-schema": "0.1.1",
|
||||
"dedent": "1.5.1",
|
||||
"human-id": "^4.1.1",
|
||||
"js-sha256": "^0.11.0",
|
||||
"kysely": "^0.28.12",
|
||||
"sqlite-wasm-kysely": "0.3.0",
|
||||
"uuid": "^14.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@lix-js/server-protocol-schema": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@lix-js/server-protocol-schema/-/server-protocol-schema-0.1.1.tgz",
|
||||
"integrity": "sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@lucide/svelte": {
|
||||
"version": "1.25.0",
|
||||
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.25.0.tgz",
|
||||
@@ -896,6 +981,23 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sinclair/typebox": {
|
||||
"version": "0.31.30",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.31.30.tgz",
|
||||
"integrity": "sha512-MGsM7bVmHg3sUKCphlu3SGQ+T+5JTbygZWYArfKQCW5anJeACHeqLhcnf+R7XlCWZ+QR3C8JTBx3kCKJTN69ow==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sqlite.org/sqlite-wasm": {
|
||||
"version": "3.48.0-build4",
|
||||
"resolved": "https://registry.npmjs.org/@sqlite.org/sqlite-wasm/-/sqlite-wasm-3.48.0-build4.tgz",
|
||||
"integrity": "sha512-hI6twvUkzOmyGZhQMza1gpfqErZxXRw6JEsiVjUbo7tFanVD+8Oil0Ih3l2nGzHdxPI41zFmfUQG7GHqhciKZQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"sqlite-wasm": "bin/index.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
@@ -1896,6 +1998,13 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/array-timsort": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz",
|
||||
"integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
@@ -1994,6 +2103,40 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
|
||||
"integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/comment-json": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.6.2.tgz",
|
||||
"integrity": "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"array-timsort": "^1.0.3",
|
||||
"esprima": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/consola": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.0.tgz",
|
||||
"integrity": "sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -2078,6 +2221,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/dedent": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz",
|
||||
"integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"babel-plugin-macros": "^3.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"babel-plugin-macros": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -2374,6 +2532,20 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/esprima": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"bin": {
|
||||
"esparse": "bin/esparse.js",
|
||||
"esvalidate": "bin/esvalidate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/esquery": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
|
||||
@@ -2603,6 +2775,16 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/human-id": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/human-id/-/human-id-4.2.0.tgz",
|
||||
"integrity": "sha512-K3GbkIWqyvvlpfhBPlbEvD97TtqBpAYA4kt+cn2lD2x2HuohzZCibcA2nOlnJT6exqvJLggoB5nv2dNf192nEA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"human-id": "dist/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -2687,6 +2869,13 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-sha256": {
|
||||
"version": "0.11.1",
|
||||
"resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.1.tgz",
|
||||
"integrity": "sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -2728,6 +2917,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"json5": "lib/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -2755,6 +2957,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/kysely": {
|
||||
"version": "0.28.17",
|
||||
"resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.17.tgz",
|
||||
"integrity": "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
@@ -3779,6 +3991,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sqlite-wasm-kysely": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/sqlite-wasm-kysely/-/sqlite-wasm-kysely-0.3.0.tgz",
|
||||
"integrity": "sha512-TzjBNv7KwRw6E3pdKdlRyZiTmUIE0UttT/Sl56MVwVARl/u5gp978KepazCJZewFUnlWHz9i3NQd4kOtP/Afdg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@sqlite.org/sqlite-wasm": "^3.48.0-build2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"kysely": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
@@ -4170,6 +4394,22 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unplugin": {
|
||||
"version": "2.3.11",
|
||||
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",
|
||||
"integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"acorn": "^8.15.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"webpack-virtual-modules": "^0.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uri-js": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||
@@ -4180,6 +4420,13 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/urlpattern-polyfill": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz",
|
||||
"integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -4187,6 +4434,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",
|
||||
"integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.1.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
|
||||
@@ -4407,6 +4668,13 @@
|
||||
"vitest": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-virtual-modules": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^2.1.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@inlang/paraglide-js": "^2.23.0",
|
||||
"@internationalized/date": "^3.12.2",
|
||||
"@lucide/svelte": "^1.25.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/project-settings",
|
||||
"baseLocale": "en",
|
||||
"locales": ["en", "de", "es", "fr", "it"],
|
||||
"modules": [
|
||||
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4.4.0/dist/index.js",
|
||||
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2.2.6/dist/index.js"
|
||||
],
|
||||
"plugin.inlang.messageFormat": {
|
||||
"pathPattern": "./messages/{locale}.json"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="%paraglide.lang%">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { paraglideMiddleware } from '$lib/paraglide/server';
|
||||
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
/**
|
||||
* Runs for every prerendered page, so the HTML written to disk is already in the
|
||||
* right language and carries a matching `<html lang>` - the client never has to
|
||||
* repaint the page into its locale.
|
||||
*/
|
||||
export const handle: Handle = ({ event, resolve }) =>
|
||||
paraglideMiddleware(event.request, ({ request, locale }) => {
|
||||
event.request = request;
|
||||
return resolve(event, {
|
||||
transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale)
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { deLocalizeUrl } from '$lib/paraglide/runtime';
|
||||
|
||||
import type { Reroute } from '@sveltejs/kit';
|
||||
|
||||
/**
|
||||
* The locale lives in the URL (`/de/weather/week/`), but the routes on disk are
|
||||
* language-neutral. Stripping the prefix here lets one route tree serve every
|
||||
* language - on the server, during prerendering and on client-side navigation.
|
||||
*/
|
||||
export const reroute: Reroute = (request) => deLocalizeUrl(request.url).pathname;
|
||||
@@ -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;
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
|
||||
import { routePath } from '$lib/i18n';
|
||||
|
||||
import './layout.css';
|
||||
|
||||
let { children } = $props();
|
||||
@@ -40,7 +42,7 @@
|
||||
});
|
||||
|
||||
// the maps page embeds a full-bleed map: no padding, no scrolling
|
||||
let fullBleed = $derived($page.url.pathname.startsWith('/weather/maps'));
|
||||
let fullBleed = $derived(routePath($page.url.pathname).startsWith('/weather/maps'));
|
||||
|
||||
let sidebarCollapsed = $state(false);
|
||||
let mobileMenuOpen = $state(false);
|
||||
|
||||
+5
-1
@@ -1,7 +1,11 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
import { localizeHref } from '$lib/paraglide/runtime';
|
||||
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async () => {
|
||||
throw redirect(303, '/weather/week/');
|
||||
// bare `/` lands in the locale the strategy resolved (URL, cookie, then the
|
||||
// browser's own languages), not always English
|
||||
throw redirect(303, localizeHref('/weather/week/'));
|
||||
}) satisfies PageLoad;
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
|
||||
import { storedLocation } from '$lib/stores/settings';
|
||||
|
||||
import { routePath } from '$lib/i18n';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -29,15 +32,15 @@
|
||||
// load resolves (and any weather page that doesn't carry a location).
|
||||
let location = $derived($page.data.location ?? $storedLocation);
|
||||
|
||||
const SUBTITLES: [string, string][] = [
|
||||
['/weather/week', '7-day forecast'],
|
||||
['/weather/compare', 'Model comparison'],
|
||||
['/weather/14-day', '14-day ensemble forecast'],
|
||||
['/weather/seasonal', 'Seasonal outlook'],
|
||||
['/weather/historical', 'Historical weather']
|
||||
const SUBTITLES: [string, () => string][] = [
|
||||
['/weather/week', m.page_week_subtitle],
|
||||
['/weather/compare', m.page_compare_subtitle],
|
||||
['/weather/14-day', m.page_14day_subtitle],
|
||||
['/weather/seasonal', m.page_seasonal_subtitle],
|
||||
['/weather/historical', m.page_historical_subtitle]
|
||||
];
|
||||
let subtitle = $derived(
|
||||
SUBTITLES.find(([prefix]) => $page.url.pathname.startsWith(prefix))?.[1] ?? null
|
||||
SUBTITLES.find(([prefix]) => routePath($page.url.pathname).startsWith(prefix))?.[1]?.() ?? null
|
||||
);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
import { localizeHref } from '$lib/paraglide/runtime';
|
||||
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async () => {
|
||||
throw redirect(303, '/weather/week/');
|
||||
// keep the visitor in their language when the bare /weather path is hit
|
||||
throw redirect(303, localizeHref('/weather/week/'));
|
||||
}) satisfies PageLoad;
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
import { storedLocation } from '$lib/stores/settings';
|
||||
|
||||
import { buildLocationRoute } from '$lib/utils/location';
|
||||
|
||||
import { href } from '$lib/i18n';
|
||||
|
||||
// at build time this page knows nothing about the visitor, so the redirect
|
||||
// target (the persisted location) is resolved in the browser instead of
|
||||
// being baked to the default city during prerender
|
||||
onMount(() => {
|
||||
goto(
|
||||
resolve('/weather/14-day/[location]', { location: buildLocationRoute(get(storedLocation)) }),
|
||||
href('/weather/14-day/[location]', { location: buildLocationRoute(get(storedLocation)) }),
|
||||
{
|
||||
replaceState: true
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
|
||||
import { CHART_COLORS, CanvasChart, type ChartSeries, isColumnUnit } from '$lib/charts';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import {
|
||||
type DaylightBand,
|
||||
type EnsembleForecastResult,
|
||||
@@ -281,7 +282,7 @@
|
||||
<ModelSelector
|
||||
selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'}
|
||||
groups={ensembleModelGroups}
|
||||
label="Ensemble model"
|
||||
label={m.model_ensemble()}
|
||||
onModelChange={(model) => {
|
||||
params.models = [model];
|
||||
storedEnsembleModel.set(model);
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
import { storedLocation } from '$lib/stores/settings';
|
||||
|
||||
import { buildLocationRoute } from '$lib/utils/location';
|
||||
|
||||
import { href } from '$lib/i18n';
|
||||
|
||||
// at build time this page knows nothing about the visitor, so the redirect
|
||||
// target (the persisted location) is resolved in the browser instead of
|
||||
// being baked to the default city during prerender
|
||||
onMount(() => {
|
||||
goto(
|
||||
resolve('/weather/compare/[location]', { location: buildLocationRoute(get(storedLocation)) }),
|
||||
href('/weather/compare/[location]', { location: buildLocationRoute(get(storedLocation)) }),
|
||||
{
|
||||
replaceState: true
|
||||
}
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
import { storedLocation } from '$lib/stores/settings';
|
||||
|
||||
import { buildLocationRoute } from '$lib/utils/location';
|
||||
|
||||
import { href } from '$lib/i18n';
|
||||
|
||||
// at build time this page knows nothing about the visitor, so the redirect
|
||||
// target (the persisted location) is resolved in the browser instead of
|
||||
// being baked to the default city during prerender
|
||||
onMount(() => {
|
||||
goto(
|
||||
resolve('/weather/historical/[location]', {
|
||||
href('/weather/historical/[location]', {
|
||||
location: buildLocationRoute(get(storedLocation))
|
||||
}),
|
||||
{ replaceState: true }
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
import { storedLocation } from '$lib/stores/settings';
|
||||
|
||||
import { buildLocationRoute } from '$lib/utils/location';
|
||||
|
||||
import { href } from '$lib/i18n';
|
||||
|
||||
// at build time this page knows nothing about the visitor, so the redirect
|
||||
// target (the persisted location) is resolved in the browser instead of
|
||||
// being baked to the default city during prerender
|
||||
onMount(() => {
|
||||
goto(
|
||||
resolve('/weather/seasonal/[location]', {
|
||||
href('/weather/seasonal/[location]', {
|
||||
location: buildLocationRoute(get(storedLocation))
|
||||
}),
|
||||
{ replaceState: true }
|
||||
|
||||
@@ -3,21 +3,19 @@
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
import { storedLocation } from '$lib/stores/settings';
|
||||
|
||||
import { buildLocationRoute } from '$lib/utils/location';
|
||||
|
||||
import { href } from '$lib/i18n';
|
||||
|
||||
// at build time this page knows nothing about the visitor, so the redirect
|
||||
// target (the persisted location) is resolved in the browser instead of
|
||||
// being baked to the default city during prerender
|
||||
onMount(() => {
|
||||
goto(
|
||||
resolve('/weather/week/[location]', { location: buildLocationRoute(get(storedLocation)) }),
|
||||
{
|
||||
replaceState: true
|
||||
}
|
||||
);
|
||||
goto(href('/weather/week/[location]', { location: buildLocationRoute(get(storedLocation)) }), {
|
||||
replaceState: true
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
storedChartRange
|
||||
} from '$lib/stores/settings';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import { CHART_VARIABLES, VARIABLE_BY_KEY } from './variables';
|
||||
|
||||
interface Props {
|
||||
@@ -22,11 +24,11 @@
|
||||
let usedKeys = $derived(new Set($storedChartLayout.flatMap((p) => p.variables)));
|
||||
|
||||
const RANGE_OPTIONS: { value: ChartRangePref; label: string; hint: string }[] = [
|
||||
{ value: 'auto', label: 'Auto', hint: '3 days on phones, everything on wider screens' },
|
||||
{ value: 'today', label: 'Today', hint: 'Open on the current day' },
|
||||
{ value: '3d', label: '3 days', hint: 'Open on the next three days' },
|
||||
{ value: '5d', label: '5 days', hint: 'Open on the next five days' },
|
||||
{ value: 'all', label: 'All', hint: 'Open on the full forecast' }
|
||||
{ value: 'auto', label: m.default_range_auto(), hint: m.default_range_auto_hint() },
|
||||
{ value: 'today', label: m.range_today(), hint: '' },
|
||||
{ value: '3d', label: m.range_3_days(), hint: '' },
|
||||
{ value: '5d', label: m.range_5_days(), hint: '' },
|
||||
{ value: 'all', label: m.range_all(), hint: '' }
|
||||
];
|
||||
let availableVars = $derived(CHART_VARIABLES.filter((v) => !usedKeys.has(v.key)));
|
||||
|
||||
@@ -177,7 +179,7 @@
|
||||
<div class="rounded-xl border border-border p-3">
|
||||
<div class="mb-2 flex items-baseline justify-between gap-2">
|
||||
<span class="text-[11px] font-bold tracking-wider text-primary uppercase">
|
||||
Default time range
|
||||
{m.default_range_title()}
|
||||
</span>
|
||||
<span class="text-[11px] text-muted-foreground">
|
||||
{RANGE_OPTIONS.find((o) => o.value === $storedChartRange)?.hint}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
|
||||
|
||||
import { href } from '$lib/i18n';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import { getTempStyle } from '../../utils/colors';
|
||||
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||
import {
|
||||
@@ -142,8 +143,8 @@
|
||||
type="button"
|
||||
class="strip-side strip-park flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
|
||||
onclick={onExtendPast}
|
||||
aria-label="Load the past 3 days"
|
||||
title="Load the past 3 days"
|
||||
aria-label={m.strip_past_aria()}
|
||||
title={m.strip_past_aria()}
|
||||
>
|
||||
<span class="inline-flex items-baseline gap-0.5">
|
||||
<svg
|
||||
@@ -157,16 +158,16 @@
|
||||
</svg>
|
||||
<span class="text-sm leading-none font-extrabold">3</span>
|
||||
</span>
|
||||
<span class="text-[9px] leading-tight font-semibold">past</span>
|
||||
<span class="text-[9px] leading-tight font-semibold">{m.strip_past_label()}</span>
|
||||
</button>
|
||||
{:else if locationRoute}
|
||||
<!-- the past days are already in the strip: the only way further back
|
||||
is the archive, so the button hands over to it -->
|
||||
<a
|
||||
href={resolve('/weather/historical/[location]', { location: locationRoute })}
|
||||
href={href('/weather/historical/[location]', { location: locationRoute })}
|
||||
class="strip-side flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
|
||||
aria-label="Open the historical weather archive"
|
||||
title="Historical weather: any past date back to 1940"
|
||||
aria-label={m.strip_history_aria()}
|
||||
title={m.strip_history_title()}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
@@ -182,7 +183,7 @@
|
||||
d="M3.5 9a9 9 0 1 0 2.2-3.6L3 8m0-4.5V8h4.5"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-[9px] leading-tight font-semibold">history</span>
|
||||
<span class="text-[9px] leading-tight font-semibold">{m.strip_history_label()}</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
@@ -355,8 +356,8 @@
|
||||
type="button"
|
||||
class="strip-side flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
|
||||
onclick={onExtend}
|
||||
aria-label="Show the full 15-day forecast"
|
||||
title="Show the full 15-day forecast"
|
||||
aria-label={m.strip_extend_aria()}
|
||||
title={m.strip_extend_aria()}
|
||||
>
|
||||
<span class="inline-flex items-baseline gap-0.5">
|
||||
<span class="text-sm leading-none font-extrabold">15</span>
|
||||
@@ -370,16 +371,16 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="text-[9px] leading-tight font-semibold">days</span>
|
||||
<span class="text-[9px] leading-tight font-semibold">{m.strip_days_label()}</span>
|
||||
</button>
|
||||
{:else if locationRoute}
|
||||
<!-- the strip is at the model's limit: anything further out is a
|
||||
seasonal outlook, so the button hands over to it -->
|
||||
<a
|
||||
href={resolve('/weather/seasonal/[location]', { location: locationRoute })}
|
||||
href={href('/weather/seasonal/[location]', { location: locationRoute })}
|
||||
class="strip-side flex shrink-0 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-xl border border-dashed border-primary/50 bg-primary/5 text-primary transition-colors hover:border-primary hover:bg-primary/10"
|
||||
aria-label="Open the seasonal outlook"
|
||||
title="Seasonal outlook: monthly trends for the months ahead"
|
||||
aria-label={m.strip_seasonal_aria()}
|
||||
title={m.strip_seasonal_title()}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
@@ -391,7 +392,7 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 17l6-6 4 4 7-7" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16 8h5v5" />
|
||||
</svg>
|
||||
<span class="text-[9px] leading-tight font-semibold">seasonal</span>
|
||||
<span class="text-[9px] leading-tight font-semibold">{m.strip_seasonal_label()}</span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { formatZoned, getRelativeDayLabel } from '$lib/utils/date';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import {
|
||||
buildDayNarrative,
|
||||
moonIllumination,
|
||||
@@ -28,7 +30,6 @@
|
||||
daily.dailyDates.findIndex((d) => formatZoned(d, timezone, 'yyyy-MM-dd') === dayKey)
|
||||
);
|
||||
let relLabel = $derived(getRelativeDayLabel(selectedDay, timezone));
|
||||
let isToday = $derived(relLabel === 'Today');
|
||||
|
||||
let sentences = $derived(
|
||||
buildDayNarrative({
|
||||
@@ -38,8 +39,7 @@
|
||||
dailyDates: daily.dailyDates,
|
||||
timezone,
|
||||
day: selectedDay,
|
||||
units,
|
||||
isToday
|
||||
units
|
||||
})
|
||||
);
|
||||
|
||||
@@ -70,8 +70,8 @@
|
||||
let daylight = $derived.by(() => {
|
||||
if (!finite(daylightSeconds)) return null;
|
||||
const h = Math.floor(daylightSeconds / 3600);
|
||||
const m = Math.round((daylightSeconds % 3600) / 60);
|
||||
return `${h} h ${String(m).padStart(2, '0')} m`;
|
||||
const min = Math.round((daylightSeconds % 3600) / 60);
|
||||
return m.daylight_hours({ hours: h, minutes: String(min).padStart(2, '0') });
|
||||
});
|
||||
|
||||
let sunshine = $derived.by(() => {
|
||||
@@ -98,13 +98,13 @@
|
||||
let illumination = $derived(finite(phase) ? Math.round(moonIllumination(phase) * 100) : null);
|
||||
</script>
|
||||
|
||||
<section class="mt-6" aria-label="Written forecast">
|
||||
<section class="mt-6" aria-label={m.summary_heading()}>
|
||||
<div class="overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm">
|
||||
<div class="border-b border-border/70 bg-muted/40 px-4 py-2.5">
|
||||
<h3 class="text-base font-bold">
|
||||
{formatZoned(selectedDay, timezone, 'EEEE')}
|
||||
<span class="font-semibold text-muted-foreground">
|
||||
– in words{relLabel === formatZoned(selectedDay, timezone, 'EEEE')
|
||||
– {m.summary_heading()}{relLabel === formatZoned(selectedDay, timezone, 'EEEE')
|
||||
? ''
|
||||
: ` (${relLabel})`}
|
||||
</span>
|
||||
@@ -118,7 +118,7 @@
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-[15px] leading-relaxed text-muted-foreground">
|
||||
No hourly detail available for this day.
|
||||
{m.summary_no_data()}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
{#if sunrise}
|
||||
<div>
|
||||
<dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
Sunrise
|
||||
{m.label_sunrise()}
|
||||
</dt>
|
||||
<dd class="font-semibold tabular-nums">{sunrise}</dd>
|
||||
</div>
|
||||
@@ -137,7 +137,7 @@
|
||||
{#if sunset}
|
||||
<div>
|
||||
<dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
Sunset
|
||||
{m.label_sunset()}
|
||||
</dt>
|
||||
<dd class="font-semibold tabular-nums">{sunset}</dd>
|
||||
</div>
|
||||
@@ -145,12 +145,14 @@
|
||||
{#if daylight}
|
||||
<div>
|
||||
<dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
Daylight
|
||||
{m.label_daylight()}
|
||||
</dt>
|
||||
<dd class="font-semibold tabular-nums">
|
||||
{daylight}
|
||||
{#if sunshine != null}
|
||||
<span class="font-medium text-muted-foreground">· {sunshine}% sun</span>
|
||||
<span class="font-medium text-muted-foreground"
|
||||
>· {m.sunshine_share({ percent: sunshine })}</span
|
||||
>
|
||||
{/if}
|
||||
</dd>
|
||||
</div>
|
||||
@@ -158,7 +160,7 @@
|
||||
{#if finite(uv)}
|
||||
<div>
|
||||
<dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
UV index
|
||||
{m.label_uv_index()}
|
||||
</dt>
|
||||
<dd class="font-semibold tabular-nums">
|
||||
{uv.toFixed(1)}
|
||||
@@ -169,7 +171,7 @@
|
||||
{#if moonrise}
|
||||
<div>
|
||||
<dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
Moonrise
|
||||
{m.label_moonrise()}
|
||||
</dt>
|
||||
<dd class="font-semibold tabular-nums">{moonrise}</dd>
|
||||
</div>
|
||||
@@ -177,7 +179,7 @@
|
||||
{#if moonset}
|
||||
<div>
|
||||
<dt class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
Moonset
|
||||
{m.label_moonset()}
|
||||
</dt>
|
||||
<dd class="font-semibold tabular-nums">{moonset}</dd>
|
||||
</div>
|
||||
@@ -196,7 +198,7 @@
|
||||
</svg>
|
||||
<div class="min-w-0">
|
||||
<div class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
Moon
|
||||
{m.label_moon()}
|
||||
</div>
|
||||
<div class="truncate font-semibold">
|
||||
{moonPhaseName(phase!)}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import { formatUtcOffset, formatZoned, getZonedHour, isSameDayInZone } from '$lib/utils/date';
|
||||
|
||||
import { groupHover, setGroupHover } from '$lib/charts';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import { getTempStyle } from '../../utils/colors';
|
||||
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||
@@ -402,7 +403,7 @@
|
||||
>
|
||||
<h3 class="text-base font-bold">
|
||||
{formatZoned(selectedDay, data.timezone, 'EEEE')}
|
||||
<span class="font-semibold text-muted-foreground">– hourly</span>
|
||||
<span class="font-semibold text-muted-foreground">– {m.hourly_heading()}</span>
|
||||
<span
|
||||
class="ms-2 rounded-full bg-muted px-2 py-0.5 align-middle text-[10px] font-semibold text-muted-foreground"
|
||||
>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { ChartContainer, downloadChartsPng } from '$lib/components/charts';
|
||||
|
||||
import { CanvasChart, groupRange } from '$lib/charts';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||
import ChartCustomizer from './ChartCustomizer.svelte';
|
||||
@@ -137,11 +138,13 @@
|
||||
let rangePresets = $derived.by(() => {
|
||||
const isToday = isSameDayInZone(now, selectedDay, data.timezone);
|
||||
return [
|
||||
{ label: 'Today', apply: () => setRangeDays(new Date(), 1) },
|
||||
...(isToday ? [] : [{ label: 'Selected day', apply: () => setRangeDays(selectedDay, 1) }]),
|
||||
{ label: '3 days', apply: () => setRangeDays(new Date(), 3) },
|
||||
{ label: '5 days', apply: () => setRangeDays(new Date(), 5) },
|
||||
{ label: 'All', apply: () => showFullRange() }
|
||||
{ label: m.range_today(), apply: () => setRangeDays(new Date(), 1) },
|
||||
...(isToday
|
||||
? []
|
||||
: [{ label: m.range_selected_day(), apply: () => setRangeDays(selectedDay, 1) }]),
|
||||
{ label: m.range_3_days(), apply: () => setRangeDays(new Date(), 3) },
|
||||
{ label: m.range_5_days(), apply: () => setRangeDays(new Date(), 5) },
|
||||
{ label: m.range_all(), apply: () => showFullRange() }
|
||||
];
|
||||
});
|
||||
|
||||
@@ -215,7 +218,7 @@
|
||||
<section class="mt-8" transition:fade={{ duration: 200 }}>
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 class="text-lg font-bold">
|
||||
Meteograms
|
||||
{m.meteograms_heading()}
|
||||
<span class="font-semibold text-muted-foreground">
|
||||
– {formatZoned(selectedDay, data.timezone, 'EEEE')}{getRelativeDayLabel(
|
||||
selectedDay,
|
||||
@@ -231,12 +234,12 @@
|
||||
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]"
|
||||
>Ctrl</kbd
|
||||
>
|
||||
+ scroll to zoom
|
||||
{m.meteograms_zoom_hint_end()}
|
||||
</span>
|
||||
<div
|
||||
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-xs font-semibold"
|
||||
role="group"
|
||||
aria-label="Chart time range"
|
||||
aria-label={m.range_group_aria()}
|
||||
>
|
||||
{#each rangePresets as preset (preset.label)}
|
||||
<button
|
||||
@@ -262,7 +265,7 @@
|
||||
>
|
||||
<path stroke-linecap="round" d="M4 6h16M4 12h16M4 18h16M8 4v4m8 2v4M6 16v4" />
|
||||
</svg>
|
||||
Customize
|
||||
{m.meteograms_customize()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -316,7 +319,7 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v6h6M20 20v-6h-6" />
|
||||
<path stroke-linecap="round" d="M20 10a8 8 0 0 0-14-4M4 14a8 8 0 0 0 14 4" />
|
||||
</svg>
|
||||
Reset zoom
|
||||
{m.reset_zoom()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import { type WeatherModelGroup, modelGroups } from '../../options';
|
||||
|
||||
interface Props {
|
||||
@@ -15,7 +17,7 @@
|
||||
selectedModel,
|
||||
onModelChange,
|
||||
groups = modelGroups,
|
||||
label = 'Weather model'
|
||||
label = m.model_weather()
|
||||
}: Props = $props();
|
||||
|
||||
let model = $derived(
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
/**
|
||||
* Turns a day's hourly forecast into a short written summary - the kind of
|
||||
* sentence a person would actually say about the weather, rather than another
|
||||
* table of numbers. Everything here is derived from the same data the charts
|
||||
* plot, so the wording can never disagree with them.
|
||||
* table of numbers. Everything is derived from the same data the charts plot,
|
||||
* so the wording can never disagree with them.
|
||||
*
|
||||
* Every sentence is a whole message, not a string built from glued-together
|
||||
* fragments: word order, prepositions and agreement differ per language, so
|
||||
* each locale owns its full sentence and only receives the values.
|
||||
*/
|
||||
import { formatZoned } from '$lib/utils/date';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import {
|
||||
type WeatherUnits,
|
||||
getPrecipUnit,
|
||||
@@ -30,6 +36,17 @@ const CATEGORY_RANK: Record<Category, number> = {
|
||||
thunder: 7
|
||||
};
|
||||
|
||||
const CATEGORY_MESSAGE: Record<Category, () => string> = {
|
||||
clear: m.cond_clear,
|
||||
fair: m.cond_fair,
|
||||
cloudy: m.cond_cloudy,
|
||||
fog: m.cond_fog,
|
||||
drizzle: m.cond_drizzle,
|
||||
rain: m.cond_rain,
|
||||
snow: m.cond_snow,
|
||||
thunder: m.cond_thunder
|
||||
};
|
||||
|
||||
/** WMO weather code → condition family. */
|
||||
function categoryOf(code: number): Category {
|
||||
if (code >= 95) return 'thunder';
|
||||
@@ -45,29 +62,18 @@ function categoryOf(code: number): Category {
|
||||
return 'clear';
|
||||
}
|
||||
|
||||
const CATEGORY_PHRASE: Record<Category, string> = {
|
||||
clear: 'clear',
|
||||
fair: 'partly cloudy',
|
||||
cloudy: 'overcast',
|
||||
fog: 'foggy',
|
||||
drizzle: 'drizzly',
|
||||
rain: 'wet',
|
||||
snow: 'snowy',
|
||||
thunder: 'stormy'
|
||||
};
|
||||
|
||||
interface Period {
|
||||
label: string;
|
||||
message: () => string;
|
||||
/** Inclusive start hour, exclusive end hour (local). */
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
const PERIODS: Period[] = [
|
||||
{ label: 'overnight', from: 0, to: 6 },
|
||||
{ label: 'this morning', from: 6, to: 12 },
|
||||
{ label: 'this afternoon', from: 12, to: 18 },
|
||||
{ label: 'this evening', from: 18, to: 24 }
|
||||
{ message: m.period_overnight, from: 0, to: 6 },
|
||||
{ message: m.period_morning, from: 6, to: 12 },
|
||||
{ message: m.period_afternoon, from: 12, to: 18 },
|
||||
{ message: m.period_evening, from: 18, to: 24 }
|
||||
];
|
||||
|
||||
export interface NarrativeInput {
|
||||
@@ -79,8 +85,6 @@ export interface NarrativeInput {
|
||||
/** The day being described. */
|
||||
day: Date;
|
||||
units: WeatherUnits;
|
||||
/** True when `day` is today, which changes the wording to the present tense. */
|
||||
isToday: boolean;
|
||||
}
|
||||
|
||||
const finite = (v: number | null | undefined): v is number => v != null && Number.isFinite(v);
|
||||
@@ -137,7 +141,7 @@ function capitalise(s: string): string {
|
||||
* paragraph). Returns an empty list when the day has no usable data.
|
||||
*/
|
||||
export function buildDayNarrative(input: NarrativeInput): string[] {
|
||||
const { hourly, hourlyDates, daily, dailyDates, timezone, day, units, isToday } = input;
|
||||
const { hourly, hourlyDates, daily, dailyDates, timezone, day, units } = input;
|
||||
|
||||
const idx = hoursOfDay(hourlyDates, day, timezone);
|
||||
if (idx.length === 0) return [];
|
||||
@@ -151,11 +155,13 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
|
||||
const precipUnit = getPrecipUnit(units);
|
||||
const at = (arr: number[] | undefined, i: number) => (arr ? arr[i] : undefined);
|
||||
const hourOf = (i: number) => Number(formatZoned(hourlyDates[i], timezone, 'H'));
|
||||
const temp = (v: number) => `${v.toFixed(0)}${tempUnit}`;
|
||||
const speed = (v: number) => `${v.toFixed(0)} ${windUnit}`;
|
||||
|
||||
const sentences: string[] = [];
|
||||
|
||||
// ─── How the sky behaves through the day ────────────────────────────────────
|
||||
const segments: { label: string; category: Category }[] = [];
|
||||
const segments: { period: Period; category: Category }[] = [];
|
||||
for (const period of PERIODS) {
|
||||
const inPeriod = idx.filter((i) => {
|
||||
const h = hourOf(i);
|
||||
@@ -163,37 +169,47 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
|
||||
});
|
||||
if (inPeriod.length < 2) continue;
|
||||
const cat = dominantCategory(inPeriod.map((i) => hourly.weather_code?.[i]).filter(finite));
|
||||
if (cat) segments.push({ label: period.label, category: cat });
|
||||
if (cat) segments.push({ period, category: cat });
|
||||
}
|
||||
|
||||
if (segments.length > 0) {
|
||||
// collapse neighbouring periods that share a description
|
||||
const runs: { labels: string[]; category: Category }[] = [];
|
||||
const runs: { period: Period; category: Category }[] = [];
|
||||
for (const seg of segments) {
|
||||
const last = runs[runs.length - 1];
|
||||
if (last && last.category === seg.category) last.labels.push(seg.label);
|
||||
else runs.push({ labels: [seg.label], category: seg.category });
|
||||
if (!last || last.category !== seg.category) runs.push(seg);
|
||||
}
|
||||
const phrase = (r: { category: Category }) => CATEGORY_MESSAGE[r.category]();
|
||||
|
||||
if (runs.length === 1) {
|
||||
sentences.push(capitalise(m.forecast_sky_all_day({ condition: phrase(runs[0]) })));
|
||||
} else if (runs.length === 2) {
|
||||
sentences.push(
|
||||
`${capitalise(CATEGORY_PHRASE[runs[0].category])} ${isToday ? 'all day' : 'throughout the day'}.`
|
||||
capitalise(
|
||||
m.forecast_sky_two({
|
||||
c1: phrase(runs[0]),
|
||||
p1: runs[0].period.message(),
|
||||
c2: phrase(runs[1]),
|
||||
p2: runs[1].period.message()
|
||||
})
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// Four clauses is a mouthful; keep the opening, the first change and
|
||||
// Four clauses is a mouthful: keep the opening, the first change and
|
||||
// where the day ends up.
|
||||
const kept = runs.length > 3 ? [runs[0], runs[1], runs[runs.length - 1]] : runs;
|
||||
const parts = kept.map((run, i) => {
|
||||
const phrase = CATEGORY_PHRASE[run.category];
|
||||
const when = run.labels[0];
|
||||
if (i === 0) return `${capitalise(phrase)} ${when}`;
|
||||
// only the first change gets a verb; later ones read as a list
|
||||
if (i > 1) return `then ${phrase} ${when}`;
|
||||
const prev = kept[i - 1].category;
|
||||
if (CATEGORY_RANK[run.category] > CATEGORY_RANK[prev]) return `turning ${phrase} ${when}`;
|
||||
return `${run.category === 'clear' || run.category === 'fair' ? 'clearing to' : 'easing to'} ${phrase} ${when}`;
|
||||
});
|
||||
sentences.push(`${parts.join(', ')}.`);
|
||||
const kept = [runs[0], runs[1], runs[runs.length - 1]];
|
||||
sentences.push(
|
||||
capitalise(
|
||||
m.forecast_sky_three({
|
||||
c1: phrase(kept[0]),
|
||||
p1: kept[0].period.message(),
|
||||
c2: phrase(kept[1]),
|
||||
p2: kept[1].period.message(),
|
||||
c3: phrase(kept[2]),
|
||||
p3: kept[2].period.message()
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,15 +219,12 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
|
||||
const high = Math.max(...temps);
|
||||
const low = Math.min(...temps);
|
||||
const feels = idx.map((i) => hourly.apparent_temperature?.[i]).filter(finite);
|
||||
let sentence = `Highs near ${high.toFixed(0)}${tempUnit}, down to ${low.toFixed(0)}${tempUnit}`;
|
||||
if (feels.length > 0) {
|
||||
const feelsHigh = Math.max(...feels);
|
||||
const delta = feelsHigh - high;
|
||||
if (Math.abs(delta) >= 3) {
|
||||
sentence += `, though it will feel more like ${feelsHigh.toFixed(0)}${tempUnit}`;
|
||||
}
|
||||
}
|
||||
sentences.push(`${sentence}.`);
|
||||
const feelsHigh = feels.length > 0 ? Math.max(...feels) : null;
|
||||
sentences.push(
|
||||
feelsHigh != null && Math.abs(feelsHigh - high) >= 3
|
||||
? m.forecast_temp_feels({ high: temp(high), low: temp(low), feels: temp(feelsHigh) })
|
||||
: m.forecast_temp({ high: temp(high), low: temp(low) })
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Precipitation ──────────────────────────────────────────────────────────
|
||||
@@ -225,7 +238,7 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
|
||||
|
||||
if (total >= wetThreshold) {
|
||||
// name the window carrying most of the total
|
||||
let bestLabel = '';
|
||||
let bestPeriod: Period | null = null;
|
||||
let bestAmount = 0;
|
||||
for (const period of PERIODS) {
|
||||
const amount = idx
|
||||
@@ -235,22 +248,19 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
|
||||
.reduce((a, b) => a + b, 0);
|
||||
if (amount > bestAmount) {
|
||||
bestAmount = amount;
|
||||
bestLabel = period.label;
|
||||
bestPeriod = period;
|
||||
}
|
||||
}
|
||||
const amountText = `${total.toFixed(total < 10 ? 1 : 0)} ${precipUnit}`;
|
||||
const share = bestAmount / total;
|
||||
const amount = `${total.toFixed(total < 10 ? 1 : 0)} ${precipUnit}`;
|
||||
sentences.push(
|
||||
bestLabel && share >= 0.5
|
||||
? `Around ${amountText} of precipitation, most of it ${bestLabel}.`
|
||||
: `Around ${amountText} of precipitation spread through the day.`
|
||||
bestPeriod && bestAmount / total >= 0.5
|
||||
? m.forecast_precip_window({ amount, when: bestPeriod.message() })
|
||||
: m.forecast_precip_spread({ amount })
|
||||
);
|
||||
} else if (peakProb >= 30) {
|
||||
sentences.push(
|
||||
`Mostly dry, with up to a ${Math.round(peakProb)}% chance of catching a shower.`
|
||||
);
|
||||
sentences.push(m.forecast_precip_chance({ percent: Math.round(peakProb) }));
|
||||
} else {
|
||||
sentences.push('Staying dry.');
|
||||
sentences.push(m.forecast_precip_dry());
|
||||
}
|
||||
|
||||
// ─── Wind ───────────────────────────────────────────────────────────────────
|
||||
@@ -260,18 +270,32 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
|
||||
const dir = dayIndex >= 0 ? at(daily.winddirection_10m_dominant, dayIndex) : undefined;
|
||||
const gusts = idx.map((i) => hourly.wind_gusts_10m?.[i]).filter(finite);
|
||||
const maxGust = gusts.length > 0 ? Math.max(...gusts) : 0;
|
||||
const from = finite(dir) ? ` from the ${getWindDirectionLabel(dir)}` : '';
|
||||
let sentence = `Wind${from} up to ${maxWind.toFixed(0)} ${windUnit}`;
|
||||
if (maxGust > maxWind * 1.4) sentence += `, gusting ${maxGust.toFixed(0)}`;
|
||||
sentences.push(`${sentence}.`);
|
||||
const gusty = maxGust > maxWind * 1.4;
|
||||
// below ~5 km/h (or the equivalent in other units) there is nothing to say
|
||||
const calm = maxWind < (windUnit === 'm/s' ? 1.5 : windUnit === 'kn' ? 3 : 5);
|
||||
|
||||
if (calm && !gusty) {
|
||||
sentences.push(m.forecast_calm());
|
||||
} else if (finite(dir)) {
|
||||
const direction = getWindDirectionLabel(dir);
|
||||
sentences.push(
|
||||
gusty
|
||||
? m.forecast_wind_dir_gusts({ direction, speed: speed(maxWind), gust: speed(maxGust) })
|
||||
: m.forecast_wind_dir({ direction, speed: speed(maxWind) })
|
||||
);
|
||||
} else {
|
||||
sentences.push(
|
||||
gusty
|
||||
? m.forecast_wind_gusts({ speed: speed(maxWind), gust: speed(maxGust) })
|
||||
: m.forecast_wind({ speed: speed(maxWind) })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── UV ─────────────────────────────────────────────────────────────────────
|
||||
const uv = dayIndex >= 0 ? at(daily.uv_index_max, dayIndex) : undefined;
|
||||
if (finite(uv) && uv >= 6) {
|
||||
sentences.push(
|
||||
`UV peaks at ${uv.toFixed(0)} - ${uvLabel(uv).toLowerCase()}, so cover up around midday.`
|
||||
);
|
||||
sentences.push(m.forecast_uv({ value: uv.toFixed(0), label: uvLabel(uv).toLowerCase() }));
|
||||
}
|
||||
|
||||
return sentences;
|
||||
@@ -279,11 +303,11 @@ export function buildDayNarrative(input: NarrativeInput): string[] {
|
||||
|
||||
/** WHO exposure category for a UV index value. */
|
||||
export function uvLabel(uv: number): string {
|
||||
if (uv < 3) return 'Low';
|
||||
if (uv < 6) return 'Moderate';
|
||||
if (uv < 8) return 'High';
|
||||
if (uv < 11) return 'Very high';
|
||||
return 'Extreme';
|
||||
if (uv < 3) return m.uv_low();
|
||||
if (uv < 6) return m.uv_moderate();
|
||||
if (uv < 8) return m.uv_high();
|
||||
if (uv < 11) return m.uv_very_high();
|
||||
return m.uv_extreme();
|
||||
}
|
||||
|
||||
/** Tailwind text colour matching the WHO UV bands. */
|
||||
@@ -298,14 +322,14 @@ export function uvColorClass(uv: number): string {
|
||||
/** Name of the lunar phase for a 0-1 fraction (0 and 1 are new moon). */
|
||||
export function moonPhaseName(phase: number): string {
|
||||
const p = ((phase % 1) + 1) % 1;
|
||||
if (p < 0.03 || p >= 0.97) return 'New moon';
|
||||
if (p < 0.22) return 'Waxing crescent';
|
||||
if (p < 0.28) return 'First quarter';
|
||||
if (p < 0.47) return 'Waxing gibbous';
|
||||
if (p < 0.53) return 'Full moon';
|
||||
if (p < 0.72) return 'Waning gibbous';
|
||||
if (p < 0.78) return 'Last quarter';
|
||||
return 'Waning crescent';
|
||||
if (p < 0.03 || p >= 0.97) return m.moon_new();
|
||||
if (p < 0.22) return m.moon_waxing_crescent();
|
||||
if (p < 0.28) return m.moon_first_quarter();
|
||||
if (p < 0.47) return m.moon_waxing_gibbous();
|
||||
if (p < 0.53) return m.moon_full();
|
||||
if (p < 0.72) return m.moon_waning_gibbous();
|
||||
if (p < 0.78) return m.moon_last_quarter();
|
||||
return m.moon_waning_crescent();
|
||||
}
|
||||
|
||||
/** Illuminated fraction of the disc, 0 at new moon and 1 at full. */
|
||||
|
||||
+23
-3
@@ -25,19 +25,39 @@ const config = {
|
||||
console.warn(`prerender skipped ${path}: ${message}`);
|
||||
},
|
||||
entries: (() => {
|
||||
// Locale lives in the path (see the paraglide config in vite.config.ts),
|
||||
// so a localized URL is not a route SvelteKit can discover on its own -
|
||||
// each one has to be listed. City pages are prerendered for the base
|
||||
// locale only; the other languages reach them through the SPA fallback,
|
||||
// which keeps the build from ballooning to cities × locales.
|
||||
const locales = ['en', 'de', 'es', 'fr', 'it'];
|
||||
const shared = [
|
||||
'/weather/week',
|
||||
'/weather/compare',
|
||||
'/weather/14-day',
|
||||
'/weather/seasonal',
|
||||
'/weather/historical',
|
||||
'/weather/maps',
|
||||
'/about',
|
||||
'/legal/imprint',
|
||||
'/legal/privacy',
|
||||
'/legal/terms'
|
||||
];
|
||||
const localized = locales.flatMap((locale) => shared.map((p) => `/${locale}${p}`));
|
||||
|
||||
try {
|
||||
const citiesPath = path.resolve('src/routes/weather/locations/city-names100.json');
|
||||
const raw = fs.readFileSync(citiesPath, 'utf-8');
|
||||
const cities = JSON.parse(raw);
|
||||
if (Array.isArray(cities)) {
|
||||
const cityEntries = cities.map((c) => `/weather/week/${c}`);
|
||||
const cityEntries = cities.map((c) => `/en/weather/week/${c}`);
|
||||
// Keep the default wildcard to include other routes
|
||||
return ['*', ...cityEntries];
|
||||
return ['*', ...localized, ...cityEntries];
|
||||
}
|
||||
} catch {
|
||||
// If anything goes wrong, fall back to default behavior
|
||||
}
|
||||
return ['*'];
|
||||
return ['*', ...localized];
|
||||
})()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 950 } });
|
||||
await page.goto('http://localhost:5181/en/weather/week/berlin/', { waitUntil: 'load', timeout: 120000 });
|
||||
await page.waitForTimeout(12000);
|
||||
|
||||
const info = await page.evaluate(() => {
|
||||
const btn = document.querySelector('[data-slot="select-trigger"], button.group.min-h-12') ||
|
||||
[...document.querySelectorAll('button')].find((b) => b.className.includes('min-h-12'));
|
||||
const card = document.querySelector('.strip-cell');
|
||||
const cs = (el) => {
|
||||
const s = getComputedStyle(el);
|
||||
return { prop: s.transitionProperty, dur: s.transitionDuration, delay: s.transitionDelay };
|
||||
};
|
||||
return { button: btn ? cs(btn) : null, cardEl: card ? cs(card) : null, btnClass: btn?.className.slice(0, 120) };
|
||||
});
|
||||
console.log('BEFORE switch:', JSON.stringify(info, null, 1));
|
||||
|
||||
// flip the theme and watch when each element's background actually moves
|
||||
await page.getByRole('button', { name: /^Theme: / }).click();
|
||||
const samples = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
samples.push(await page.evaluate(() => {
|
||||
const btn = [...document.querySelectorAll('button')].find((b) => b.className.includes('min-h-12'));
|
||||
const card = document.querySelector('.strip-cell');
|
||||
const g = (el) => (el ? getComputedStyle(el).backgroundColor : null);
|
||||
return { t: Math.round(performance.now() % 100000), btn: g(btn), card: g(card), dur: btn ? getComputedStyle(btn).transitionDuration : null };
|
||||
}));
|
||||
await page.waitForTimeout(60);
|
||||
}
|
||||
console.log(JSON.stringify(samples.map((s) => `${s.btn} | ${s.card} | ${s.dur}`), null, 1));
|
||||
await browser.close();
|
||||
+26
-1
@@ -1,3 +1,4 @@
|
||||
import { paraglideVitePlugin } from '@inlang/paraglide-js';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { playwright } from '@vitest/browser-playwright';
|
||||
@@ -29,8 +30,32 @@ const viteServerConfig = (): Plugin => ({
|
||||
}
|
||||
});
|
||||
|
||||
// Every locale carries its own path prefix, English included, so a URL always
|
||||
// says which language it is in. `trailingSlash: 'always'` (see routes/+layout.ts)
|
||||
// means the patterns have to match both `/de/weather/week/` and `/de`.
|
||||
const LOCALES = ['en', 'de', 'es', 'fr', 'it'] as const;
|
||||
|
||||
const urlPatterns = [
|
||||
{
|
||||
pattern: '/:path(.*)?',
|
||||
localized: LOCALES.map((locale) => [locale, `/${locale}/:path(.*)?`] as [string, string])
|
||||
}
|
||||
];
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit(), viteServerConfig()],
|
||||
plugins: [
|
||||
paraglideVitePlugin({
|
||||
project: './project.inlang',
|
||||
outdir: './src/lib/paraglide',
|
||||
// the URL is authoritative; the cookie only carries a choice forward to
|
||||
// the next bare visit, and the browser's languages seed a first visit
|
||||
strategy: ['url', 'cookie', 'preferredLanguage', 'baseLocale'],
|
||||
urlPatterns
|
||||
}),
|
||||
tailwindcss(),
|
||||
sveltekit(),
|
||||
viteServerConfig()
|
||||
],
|
||||
|
||||
test: {
|
||||
expect: { requireAssertions: true },
|
||||
|
||||
Reference in New Issue
Block a user