From f4a02a208cdb524b4b9692d3ca65ebe59e29d023 Mon Sep 17 00:00:00 2001 From: Vincent van der Wal Date: Thu, 6 Aug 2026 19:08:00 +0200 Subject: [PATCH] overlay --- .gitignore | 13 +++ README.md | 11 +- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + src/app.html | 5 + src/lib/components/navigation/header.svelte | 20 ++-- src/lib/utils/date.ts | 54 +++++++-- src/lib/utils/day-swap.ts | 12 +- src/lib/utils/location.ts | 24 ++++ src/routes/+layout.svelte | 108 +++++++++++++++--- src/routes/weather/+layout.svelte | 11 +- .../weather/14-day/[location]/+page.svelte | 2 +- .../weather/compare/[location]/+page.svelte | 2 +- .../historical/[location]/+page.svelte | 10 +- .../weather/seasonal/[location]/+page.svelte | 66 ++++++----- .../weather/week/[location]/+page.svelte | 9 +- static/favicon.svg | 46 ++++++++ svelte.config.js | 16 ++- 21 files changed, 336 insertions(+), 78 deletions(-) create mode 100644 static/favicon.svg diff --git a/.gitignore b/.gitignore index 3b87454..39548b6 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,16 @@ AGENTS.md # Claude Code scratch: throwaway probe scripts, never committed /.scratch + +# Empty stubs the Claude Code sandbox mounts over shell/editor dotfiles while it +# runs. They are not project files and keep sneaking into commits via `git add -A`. +/.bash_profile +/.bashrc +/.profile +/.zprofile +/.zshrc +/.gitconfig +/.gitmodules +/.ripgreprc +/.idea +/.mcp.json diff --git a/README.md b/README.md index 6e7136a..0790d6e 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,12 @@ Pages that are not prerendered (unlisted cities, GPS coordinate routes like and resolves the location client-side. Configure the server to serve `404.html` for unknown paths. +Serve it as an **internal rewrite (200)**, not as an error page. `error_page +404 /404.html` sends the right body with a 404 status: the page works, but +every hard reload of an unprerendered URL logs a 404 in the network panel and +tells crawlers the page does not exist. `try_files` with a URI as its last +argument does an internal redirect instead, and answers 200. + ### 2. Cross-origin isolation (SharedArrayBuffer for the embedded map) The `/weather/maps/` page embeds `maps.open-meteo.com`, which uses @@ -83,11 +89,12 @@ drizzli.example.com { server { server_name drizzli.example.com; root /srv/drizzli; - error_page 404 /404.html; add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Embedder-Policy "require-corp" always; location / { - try_files $uri $uri/ =404; + # the trailing /404.html is a URI, so nginx rewrites internally and + # answers 200 - `error_page 404 /404.html` would answer 404 instead + try_files $uri $uri/index.html /404.html; } } ``` diff --git a/messages/de.json b/messages/de.json index 3c55855..ac30e78 100644 --- a/messages/de.json +++ b/messages/de.json @@ -260,6 +260,7 @@ "table_interval_aria": "Stundenintervall", "table_now": "Jetzt", "interval_toggle": "Zwischen 1- und 3-Stunden-Intervall wechseln", + "page_loading": "Wird geladen…", "charts_loading": "Diagramme werden geladen…", "chart_download": "Meteogramm als PNG-Bild herunterladen", "chart_credit_viz": "Visualisierung von", diff --git a/messages/en.json b/messages/en.json index 60958e8..1475299 100644 --- a/messages/en.json +++ b/messages/en.json @@ -260,6 +260,7 @@ "table_interval_aria": "Hourly interval", "table_now": "Now", "interval_toggle": "Toggle between 1-hour and 3-hour intervals", + "page_loading": "Loading…", "charts_loading": "Loading charts...", "chart_download": "Download meteogram as PNG image", "chart_credit_viz": "visualisation by", diff --git a/messages/es.json b/messages/es.json index c6e0efe..5186393 100644 --- a/messages/es.json +++ b/messages/es.json @@ -260,6 +260,7 @@ "table_interval_aria": "Intervalo horario", "table_now": "Ahora", "interval_toggle": "Alternar entre intervalos de 1 y 3 horas", + "page_loading": "Cargando…", "charts_loading": "Cargando gráficos…", "chart_download": "Descargar el meteograma como imagen PNG", "chart_credit_viz": "visualización de", diff --git a/messages/fr.json b/messages/fr.json index e057603..830dd9d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -260,6 +260,7 @@ "table_interval_aria": "Intervalle horaire", "table_now": "Maintenant", "interval_toggle": "Basculer entre les intervalles de 1 h et 3 h", + "page_loading": "Chargement…", "charts_loading": "Chargement des graphiques…", "chart_download": "Télécharger le météogramme en PNG", "chart_credit_viz": "visualisation par", diff --git a/messages/it.json b/messages/it.json index fe570ba..3990888 100644 --- a/messages/it.json +++ b/messages/it.json @@ -260,6 +260,7 @@ "table_interval_aria": "Intervallo orario", "table_now": "Ora", "interval_toggle": "Alterna tra intervalli di 1 e 3 ore", + "page_loading": "Caricamento…", "charts_loading": "Caricamento dei grafici…", "chart_download": "Scarica il meteogramma come immagine PNG", "chart_credit_viz": "visualizzazione di", diff --git a/src/app.html b/src/app.html index cb618ef..c833eb1 100644 --- a/src/app.html +++ b/src/app.html @@ -3,6 +3,11 @@ + + - + + +{#if loadingOverlay} +
+
+ + {m.page_loading()} +
+
+{/if} +
diff --git a/src/routes/weather/14-day/[location]/+page.svelte b/src/routes/weather/14-day/[location]/+page.svelte index ebe9a9a..e440a6f 100644 --- a/src/routes/weather/14-day/[location]/+page.svelte +++ b/src/routes/weather/14-day/[location]/+page.svelte @@ -44,7 +44,7 @@ let { data }: { data: PageData } = $props(); // the page cross-fade waits for this before revealing the new page - reportPageReady(() => fetchedData != null); + reportPageReady(() => fetchedData != null || loadError != null); useHeroActions(heroActions); diff --git a/src/routes/weather/compare/[location]/+page.svelte b/src/routes/weather/compare/[location]/+page.svelte index e6cf6f7..6e4d55f 100644 --- a/src/routes/weather/compare/[location]/+page.svelte +++ b/src/routes/weather/compare/[location]/+page.svelte @@ -59,7 +59,7 @@ let { data }: { data: PageData } = $props(); // the page cross-fade waits for this before revealing the new page - reportPageReady(() => fetchedData != null); + reportPageReady(() => fetchedData != null || loadError != null); useHeroActions(heroActions); diff --git a/src/routes/weather/historical/[location]/+page.svelte b/src/routes/weather/historical/[location]/+page.svelte index ff359c7..a106540 100644 --- a/src/routes/weather/historical/[location]/+page.svelte +++ b/src/routes/weather/historical/[location]/+page.svelte @@ -39,8 +39,10 @@ let { data }: { data: PageData } = $props(); - // the page cross-fade waits for this before revealing the new page - reportPageReady(() => result != null); + // The page cross-fade waits for this before revealing the new page - and so + // does the loading overlay, so a visitor without a key has to count as ready: + // the paywall is the finished page here, nothing is on its way. + reportPageReady(() => result != null || loadError != null || !$isSupporter); useHeroActions(heroActions); @@ -213,7 +215,9 @@ ); function switchDay(date: Date) { - selectedDay.setTime(date.getTime()); + // see the week page: an unformattable selected day breaks every consumer + const time = date?.getTime(); + if (Number.isFinite(time)) selectedDay.setTime(time); } diff --git a/src/routes/weather/seasonal/[location]/+page.svelte b/src/routes/weather/seasonal/[location]/+page.svelte index f57368b..80f76b6 100644 --- a/src/routes/weather/seasonal/[location]/+page.svelte +++ b/src/routes/weather/seasonal/[location]/+page.svelte @@ -33,8 +33,10 @@ let { data }: { data: PageData } = $props(); - // the page cross-fade waits for this before revealing the new page - reportPageReady(() => result != null); + // The page cross-fade waits for this before revealing the new page - and so + // does the loading overlay, so a visitor without a key has to count as ready: + // the paywall is the finished page here, nothing is on its way. + reportPageReady(() => result != null || loadError != null || !$isSupporter); useHeroActions(heroActions); @@ -189,33 +191,39 @@ {#snippet heroActions()} -
- {#if result} - -
- {#each RANGES as range, i (range.label)} - {@const disabled = range.days !== Infinity && range.days > horizonDays} - - {/each} -
- {/if} + +
+ +
+ {#each RANGES as range, i (range.label)} + {@const disabled = !result || (range.days !== Infinity && range.days > horizonDays)} + + {/each} +
fetchedDaily != null && fetchedHourly != null); + reportPageReady(() => (fetchedDaily != null && fetchedHourly != null) || loadError != null); useHeroActions(heroActions); @@ -211,8 +211,13 @@ // Charts intentionally keep their current range: they show the full week // unless the user narrows it via the range presets or Ctrl+scroll. + // A model that answers with a broken timestamp must not be able to park an + // unreadable date in `selectedDay`: everything downstream formats it, and a + // date that cannot be formatted takes the page with it. const switchDay = (date: Date) => { - runDayTransition(() => selectedDay.setTime(date.getTime())); + const time = date?.getTime(); + if (!Number.isFinite(time)) return; + runDayTransition(() => selectedDay.setTime(time)); }; onMount(() => { diff --git a/static/favicon.svg b/static/favicon.svg new file mode 100644 index 0000000..3a99f1f --- /dev/null +++ b/static/favicon.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/svelte.config.js b/svelte.config.js index d615f22..3bdead3 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -45,12 +45,26 @@ const config = { ]; const localized = locales.flatMap((locale) => shared.map((p) => `/${locale}${p}`)); + // Every per-location route, not just the week page: an unprerendered + // path is served by the SPA fallback, which the host answers with a + // 404 status. The page still works, but it costs a bogus 404 on every + // hard reload (and tells crawlers the page does not exist). + const cityRoutes = [ + '/weather/week', + '/weather/compare', + '/weather/14-day', + '/weather/seasonal', + '/weather/historical' + ]; + 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) => `/en/weather/week/${c}`); + const cityEntries = cities.flatMap((c) => + cityRoutes.map((route) => `/en${route}/${c}`) + ); // Keep the default wildcard to include other routes return ['*', ...localized, ...cityEntries]; }