This commit is contained in:
Cayden Schwechten 2026-10-31 21:20:16 -05:00
commit 6250d23297
53 changed files with 6381 additions and 0 deletions

337
components/WeatherApp.tsx Normal file
View file

@ -0,0 +1,337 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { fetchProviders, fetchWeather } from "@/lib/client-api";
import { placeLabel, type Units } from "@/lib/display";
import { outlineButton, pixelLabel } from "@/lib/styles";
import type { Place, ProviderId, ProviderInfo, WeatherReport } from "@/lib/weather/types";
import type { ForecastView } from "./forecast/ForecastPanel";
import { HomeLanding } from "./HomeLanding";
import { SkyIcon } from "./PixelArt";
import { PlaceSearch } from "./PlaceSearch";
import { ProviderPicker } from "./ProviderPicker";
import { RadarPanel } from "./radar/RadarPanel";
import { SiteFooter } from "./SiteFooter";
import { Slot } from "./Slot";
import { WeatherView } from "./WeatherView";
/** Settings that survive a page reload (stored in this browser only). */
interface Settings {
provider: ProviderId;
units: Units;
place: Place | null;
recentPlaces: Place[];
forecastView: ForecastView;
}
const STORAGE_KEY = "wayfarer:settings";
const MAX_RECENT_PLACES = 5;
const DEFAULT_SETTINGS: Settings = {
provider: "openweather",
units: "imperial",
place: null,
recentPlaces: [],
forecastView: "hourly",
};
function samePlace(a: Place, b: Place): boolean {
return Math.abs(a.lat - b.lat) < 0.01 && Math.abs(a.lon - b.lon) < 0.01;
}
function loadSettings(): Settings {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (!saved) return DEFAULT_SETTINGS;
const parsed = JSON.parse(saved) as Partial<Settings>;
return {
...DEFAULT_SETTINGS,
...parsed,
recentPlaces: Array.isArray(parsed.recentPlaces) ? parsed.recentPlaces.slice(0, MAX_RECENT_PLACES) : [],
};
} catch {
return DEFAULT_SETTINGS; // private mode or blocked storage — just use defaults
}
}
function saveSettings(settings: Settings) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch {
/* not critical */
}
}
type Status = "idle" | "loading" | "ready" | "error";
export default function WeatherApp() {
const [settings, setSettings] = useState<Settings>(DEFAULT_SETTINGS);
const [loaded, setLoaded] = useState(false);
const [providers, setProviders] = useState<ProviderInfo[]>([]);
const [report, setReport] = useState<WeatherReport | null>(null);
const [status, setStatus] = useState<Status>("idle");
const [error, setError] = useState<string | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const update = (changes: Partial<Settings>) => setSettings((prev) => ({ ...prev, ...changes }));
function selectPlace(nextPlace: Place) {
setSettings((prev) => {
const filtered = prev.recentPlaces.filter((p) => !samePlace(p, nextPlace));
return {
...prev,
place: nextPlace,
recentPlaces: [nextPlace, ...filtered].slice(0, MAX_RECENT_PLACES),
};
});
}
function removeRecentPlace(target: Place) {
setSettings((prev) => ({
...prev,
recentPlaces: prev.recentPlaces.filter((p) => !samePlace(p, target)),
}));
}
function goHome() {
setReport(null);
setStatus("idle");
setError(null);
update({ place: null });
}
// 1. On first load: restore saved settings and find out which providers have keys.
useEffect(() => {
const saved = loadSettings();
fetchProviders()
.then((list) => {
setProviders(list);
// Prefer OpenWeather if its key was just added and user hasn't explicitly saved a choice;
// otherwise keep saved provider if ready, or fall back to the first ready source.
const savedIsReady = list.some((p) => p.id === saved.provider && p.ready);
const fallback = list.find((p) => p.ready)?.id ?? saved.provider;
setSettings({ ...saved, provider: savedIsReady ? saved.provider : fallback });
})
.catch(() => setSettings(saved))
.finally(() => setLoaded(true));
}, []);
// 2. Remember settings whenever they change.
useEffect(() => {
if (loaded) saveSettings(settings);
}, [settings, loaded]);
// 3. Fetch weather whenever the place or provider changes.
const { place, provider } = settings;
useEffect(() => {
if (!loaded || !place) return;
const controller = new AbortController();
setStatus("loading");
setError(null);
fetchWeather(provider, place, controller.signal)
.then((data) => {
setReport(data);
setStatus("ready");
})
.catch((err: Error) => {
if (controller.signal.aborted) return;
setError(err.message);
setStatus("error");
});
return () => controller.abort();
}, [loaded, place, provider]);
const activeProvider = providers.find((p) => p.id === settings.provider);
const reportProvider = providers.find((p) => p.id === report?.provider);
const hasOpenWeather = providers.some((p) => p.id === "openweather" && p.ready);
return (
<main
className={[
"mx-auto flex w-full max-w-[880px] flex-col gap-5",
// --gutter is reused by the forecast strip to bleed to the screen edge
"[--gutter:max(16px,env(safe-area-inset-left))] px-(--gutter)",
"pt-[max(20px,env(safe-area-inset-top))] pb-[max(40px,env(safe-area-inset-bottom))]",
].join(" ")}
>
{/* Top station bar */}
<header className="flex items-center justify-between gap-3 border-b border-gold-dim/50 pb-3.5">
<div className="flex min-w-0 items-center gap-3.5">
<div className="flex items-center gap-2.5">
{place ? (
<button
type="button"
onClick={goHome}
title="Return to Wayfarer home"
aria-label="Return to Wayfarer home"
className="cursor-pointer"
>
<CompassStar />
</button>
) : (
<CompassStar />
)}
<div className="flex flex-col">
{place ? (
<button
type="button"
onClick={goHome}
title="Return to Wayfarer home"
className="cursor-pointer text-left font-display text-lg leading-none font-bold tracking-[0.28em] text-gold uppercase transition-colors hover:text-gold-bright sm:text-2xl"
>
Wayfarer
</button>
) : (
<h1 className="font-display text-lg leading-none font-bold tracking-[0.28em] text-gold uppercase sm:text-2xl">
Wayfarer
</h1>
)}
<a
href="https://caydens.dev"
target="_blank"
rel="noopener"
className="group/author mt-1 w-fit font-pixel text-[0.68rem] leading-none tracking-[0.04em] text-stone transition-colors hover:text-gold-bright"
>
by <span className="text-gold transition-colors group-hover/author:text-gold-bright">Cayden Codes</span>
</a>
</div>
</div>
<span className="hidden border-l border-gold-dim/60 pl-3.5 text-xs text-stone sm:inline">
Weather for the road ahead
</span>
</div>
<div className="flex items-center gap-2.5">
{place && (
<button
type="button"
onClick={goHome}
className="min-h-9 cursor-pointer border border-gold-dim bg-panel/60 px-2.5 font-pixel text-[0.72rem] text-stone transition-colors hover:border-gold hover:text-gold-bright"
>
Home
</button>
)}
<div className="flex divide-x divide-gold-dim border border-gold-dim bg-panel/60" role="group" aria-label="Temperature units">
{(["imperial", "metric"] as const).map((u) => (
<button
key={u}
type="button"
aria-pressed={settings.units === u}
onClick={() => update({ units: u })}
className="min-h-9 min-w-11 cursor-pointer px-2.5 font-pixel text-[0.8rem] text-stone transition-colors hover:not-aria-pressed:text-ink aria-pressed:bg-gold aria-pressed:text-black"
>
{u === "imperial" ? "°F" : "°C"}
</button>
))}
</div>
</div>
</header>
{/* Search & recent waypoints */}
<div className="flex flex-col gap-2.5">
<PlaceSearch inputRef={searchInputRef} onSelect={selectPlace} geocoderUsesOpenWeather={hasOpenWeather} />
{settings.recentPlaces.length > 0 && (
<div className="flex items-center gap-2 overflow-x-auto pb-0.5 [scrollbar-width:none]" aria-label="Recent waypoints">
<span className={`${pixelLabel} flex-none`}>Recent</span>
{settings.recentPlaces.map((item) => {
const isCurrent = place ? samePlace(place, item) : false;
return (
<div
key={`${item.lat.toFixed(2)},${item.lon.toFixed(2)}`}
className={`flex flex-none items-center border text-xs transition-colors ${
isCurrent
? "border-gold bg-gold/12 text-gold-bright"
: "border-gold-dim/70 bg-panel/60 text-stone hover:border-gold/60 hover:text-ink"
}`}
>
<button
type="button"
onClick={() => selectPlace(item)}
className="min-h-7 cursor-pointer px-2.5 py-1 font-pixel text-[0.68rem]"
>
{placeLabel(item)}
</button>
<button
type="button"
aria-label={`Remove ${placeLabel(item)} from recent`}
onClick={() => removeRecentPlace(item)}
className="min-h-7 cursor-pointer pr-2 pl-1 font-pixel text-[0.68rem] opacity-60 hover:text-gold-bright hover:opacity-100"
>
×
</button>
</div>
);
})}
</div>
)}
</div>
{providers.length > 0 && (
<ProviderPicker providers={providers} selected={settings.provider} onChange={(id) => update({ provider: id })} />
)}
{/* Homepage landing state when no place is selected */}
{!place && (
<HomeLanding
onSelectPlace={selectPlace}
onFocusSearch={() => searchInputRef.current?.focus()}
activeProviderLabel={activeProvider?.label}
/>
)}
{status === "error" && error && (
<Slot as="section" tone="danger" innerClassName="flex flex-wrap items-center justify-between gap-4 p-6">
<p>{error}</p>
<button type="button" className={outlineButton} onClick={() => update({ place: place ? { ...place } : null })}>
Try again
</button>
</Slot>
)}
{/* Initial loading frame sits ABOVE radar so the radar doesn't jump down once forecast arrives */}
{place && status === "loading" && !report && (
<Slot as="section" className="[--cut:22px]" innerClassName="flex items-center gap-5 p-6 sm:px-8 sm:py-8">
<SkyIcon condition="clear" size={56} className="flex-none animate-float motion-reduce:animate-none" />
<div className="flex flex-col gap-1">
<p className="font-display text-lg text-gold-bright">Reading the skies above {placeLabel(place)}…</p>
<p className={pixelLabel}>Gathering current conditions, hourly horizon, and local alerts</p>
</div>
</Slot>
)}
{/* Keep the last report on screen while a new one loads, just dimmed */}
{report && place && status !== "error" && (
<div className={status === "loading" ? "opacity-50 transition-opacity" : undefined} aria-busy={status === "loading"}>
<WeatherView
report={report}
place={place}
units={settings.units}
forecastView={settings.forecastView}
onForecastViewChange={(view) => update({ forecastView: view })}
providerLabel={reportProvider?.label ?? report.provider}
maxDaysOnPlan={reportProvider?.forecastDays ?? report.daily.length}
/>
</div>
)}
{/* The radar doesn't depend on the forecast, so it shows as soon as there's a place */}
{place && <RadarPanel place={place} timeZone={report?.timeZone ?? null} hasOpenWeather={hasOpenWeather} />}
<SiteFooter report={report} activeProvider={settings.provider} hasOpenWeather={hasOpenWeather} />
</main>
);
}
/** Four-point gold compass emblem in the station header. */
function CompassStar() {
return (
<svg width={20} height={20} viewBox="0 0 20 20" fill="none" aria-hidden="true" className="flex-none text-gold">
<path d="M10 1L12.3 7.7L19 10L12.3 12.3L10 19L7.7 12.3L1 10L7.7 7.7L10 1Z" stroke="currentColor" strokeWidth="1.2" fill="var(--color-panel)" />
<circle cx="10" cy="10" r="1.6" fill="var(--color-gold-bright)" />
</svg>
);
}