2026-10-31 21:20:16 -05:00
|
|
|
|
"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);
|
2026-10-31 21:50:28 -05:00
|
|
|
|
const savedIsAvailable = list.some((p) => p.id === saved.provider);
|
|
|
|
|
|
const fallback = list[0]?.id ?? saved.provider;
|
|
|
|
|
|
setSettings({ ...saved, provider: savedIsAvailable ? saved.provider : fallback });
|
2026-10-31 21:20:16 -05:00
|
|
|
|
})
|
|
|
|
|
|
.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);
|
2026-10-31 21:50:28 -05:00
|
|
|
|
const hasOpenWeather = providers.some((p) => p.id === "openweather");
|
2026-10-31 21:20:16 -05:00
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|