init
This commit is contained in:
commit
6250d23297
53 changed files with 6381 additions and 0 deletions
28
app/api/geocode/route.ts
Normal file
28
app/api/geocode/route.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { reversePlace, searchPlaces } from "@/lib/weather/geocode";
|
||||
import { badRequest, errorResponse, forbidCrossSite, parseCoords } from "@/lib/weather/respond";
|
||||
|
||||
/**
|
||||
* GET /api/geocode?q=Burnsville → Place[] (search by name)
|
||||
* GET /api/geocode?lat=44.7&lon=-93.2 → Place (name for coordinates)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const blocked = forbidCrossSite(request);
|
||||
if (blocked) return blocked;
|
||||
|
||||
const params = request.nextUrl.searchParams;
|
||||
const query = params.get("q")?.trim();
|
||||
|
||||
try {
|
||||
if (query) {
|
||||
if (query.length < 2) return NextResponse.json([]);
|
||||
return NextResponse.json(await searchPlaces(query));
|
||||
}
|
||||
|
||||
const coords = parseCoords(params);
|
||||
if (!coords) return badRequest("Pass either q, or lat and lon");
|
||||
return NextResponse.json(await reversePlace(coords.lat, coords.lon));
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
10
app/api/providers/route.ts
Normal file
10
app/api/providers/route.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { listProviders } from "@/lib/weather/providers";
|
||||
|
||||
// Read env vars per request so adding a key doesn't require a rebuild.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** GET /api/providers → which providers exist and which have keys configured. */
|
||||
export function GET() {
|
||||
return NextResponse.json(listProviders());
|
||||
}
|
||||
43
app/api/radar/route.ts
Normal file
43
app/api/radar/route.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { fetchJson } from "@/lib/weather/http";
|
||||
import { errorResponse, forbidCrossSite } from "@/lib/weather/respond";
|
||||
import type { RadarFrame } from "@/lib/weather/types";
|
||||
|
||||
/**
|
||||
* GET /api/radar → RainViewer frames, oldest first, ready for Leaflet.
|
||||
*
|
||||
* RainViewer publishes an index of frames. Each frame's `path` is a hash, not a
|
||||
* timestamp, so tile URLs must always be built from this index — never guessed.
|
||||
* Docs: https://www.rainviewer.com/api/weather-maps-api.html
|
||||
*/
|
||||
|
||||
interface RainViewerIndex {
|
||||
host: string;
|
||||
radar: { past: { time: number; path: string }[] };
|
||||
}
|
||||
|
||||
// Last 7 frames ≈ one hour. Keeps tile requests well under RainViewer's
|
||||
// limit of 100 requests per minute per IP.
|
||||
const FRAME_COUNT = 7;
|
||||
const COLOR_SCHEME = 2; // "Universal Blue", the only scheme still offered
|
||||
const OPTIONS = "1_1"; // smoothed, snow shown in its own colors
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const blocked = forbidCrossSite(request);
|
||||
if (blocked) return blocked;
|
||||
|
||||
try {
|
||||
const index = await fetchJson<RainViewerIndex>("https://api.rainviewer.com/public/weather-maps.json", "RainViewer", {
|
||||
cacheSeconds: 300, // new frames arrive every 10 minutes
|
||||
});
|
||||
|
||||
const frames: RadarFrame[] = index.radar.past.slice(-FRAME_COUNT).map((frame) => ({
|
||||
time: new Date(frame.time * 1000).toISOString(),
|
||||
url: `${index.host}${frame.path}/256/{z}/{x}/{y}/${COLOR_SCHEME}/${OPTIONS}.png`,
|
||||
}));
|
||||
|
||||
return NextResponse.json(frames);
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
92
app/api/tiles/[layer]/[z]/[x]/[y]/route.ts
Normal file
92
app/api/tiles/[layer]/[z]/[x]/[y]/route.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import type { NextRequest } from "next/server";
|
||||
import { OWM_TILE_LAYERS } from "@/lib/radar/layers";
|
||||
import { forbidCrossSite } from "@/lib/weather/respond";
|
||||
|
||||
const CARTO_LAYERS: Record<string, string> = {
|
||||
"carto-dark-nolabels": "dark_nolabels",
|
||||
"carto-dark-labels": "dark_only_labels",
|
||||
};
|
||||
|
||||
const CARTO_SUBDOMAINS = ["a", "b", "c", "d"] as const;
|
||||
const MAX_ZOOM = 18;
|
||||
|
||||
/**
|
||||
* GET /api/tiles/[layer]/[z]/[x]/[y] → one map tile (PNG).
|
||||
*
|
||||
* Proxies both OpenWeather weather overlays (OPENWEATHER_API_KEY) and CARTO
|
||||
* dark basemap/label tiles (CARTO_API_KEY) server-side so keys stay in `.env`
|
||||
* or `.env.local` and cannot be hotlinked from other domains.
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ layer: string; z: string; x: string; y: string }> },
|
||||
) {
|
||||
const blocked = forbidCrossSite(request);
|
||||
if (blocked) return blocked;
|
||||
|
||||
const { layer, z, x, y } = await context.params;
|
||||
const [zi, xi, yi] = [z, x, y].map(Number);
|
||||
const maxIndex = 2 ** zi;
|
||||
|
||||
if (
|
||||
![zi, xi, yi].every((n) => Number.isInteger(n) && n >= 0) ||
|
||||
zi > MAX_ZOOM ||
|
||||
xi >= maxIndex ||
|
||||
yi >= maxIndex
|
||||
) {
|
||||
return new Response("Bad tile coordinates", { status: 400 });
|
||||
}
|
||||
|
||||
// 1. CARTO dark basemap & place-label tiles
|
||||
const cartoStyle = CARTO_LAYERS[layer];
|
||||
if (cartoStyle) {
|
||||
const key = (process.env.CARTO_API_KEY ?? process.env.NEXT_PUBLIC_CARTO_API_KEY ?? "").trim();
|
||||
const scale = request.nextUrl.searchParams.get("r") === "@2x" ? "@2x" : "";
|
||||
const sub = CARTO_SUBDOMAINS[(xi + yi) % CARTO_SUBDOMAINS.length];
|
||||
const query = key ? `?key=${encodeURIComponent(key)}` : "";
|
||||
const url = `https://${sub}.basemaps.cartocdn.com/${cartoStyle}/${z}/${x}/${y}${scale}.png${query}`;
|
||||
|
||||
const headers: HeadersInit = {};
|
||||
const referer = request.headers.get("referer");
|
||||
const origin = request.headers.get("origin");
|
||||
if (referer) headers.Referer = referer;
|
||||
if (origin) headers.Origin = origin;
|
||||
|
||||
const upstream = await fetch(
|
||||
url,
|
||||
key ? { headers, next: { revalidate: 86_400 } } : { headers, cache: "no-store" },
|
||||
).catch(() => null);
|
||||
|
||||
if (!upstream?.ok) return new Response("Tile unavailable", { status: 502 });
|
||||
|
||||
// CARTO tags unauthenticated placeholder tiles with an `"wm-..."` ETag; never cache those.
|
||||
const isWatermarked = !key || Boolean(upstream.headers.get("etag")?.includes("wm-"));
|
||||
|
||||
return new Response(upstream.body, {
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
"Cache-Control": isWatermarked ? "no-store" : "public, max-age=86400",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 2. OpenWeather weather-model overlay tiles
|
||||
const owmLayer = OWM_TILE_LAYERS[layer];
|
||||
const owmKey = process.env.OPENWEATHER_API_KEY?.trim();
|
||||
|
||||
if (!owmLayer) return new Response("Unknown layer", { status: 404 });
|
||||
if (!owmKey) return new Response("OPENWEATHER_API_KEY is not set", { status: 503 });
|
||||
|
||||
const upstream = await fetch(`https://tile.openweathermap.org/map/${owmLayer}/${z}/${x}/${y}.png?appid=${owmKey}`, {
|
||||
next: { revalidate: 600 },
|
||||
}).catch(() => null);
|
||||
|
||||
if (!upstream?.ok) return new Response("Tile unavailable", { status: 502 });
|
||||
|
||||
return new Response(upstream.body, {
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
"Cache-Control": "public, max-age=600",
|
||||
},
|
||||
});
|
||||
}
|
||||
57
app/api/weather/route.ts
Normal file
57
app/api/weather/route.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { getLocalContext, type LocalContext } from "@/lib/weather/extras/air-quality";
|
||||
import { getNwsAlerts } from "@/lib/weather/extras/nws-alerts";
|
||||
import { isProviderId, isReady, PROVIDERS } from "@/lib/weather/providers";
|
||||
import { badRequest, errorResponse, forbidCrossSite, parseCoords } from "@/lib/weather/respond";
|
||||
import type { WeatherAlert, WeatherReport } from "@/lib/weather/types";
|
||||
|
||||
/**
|
||||
* GET /api/weather?provider=openweather&lat=44.77&lon=-93.27
|
||||
*
|
||||
* Runs on the server so API keys stay in .env / .env.local and never reach the browser.
|
||||
*
|
||||
* Three things are fetched at once:
|
||||
* 1. the forecast from the chosen provider (required — failure is an error)
|
||||
* 2. air quality + time zone from Open-Meteo (optional — failure just hides it)
|
||||
* 3. NWS alerts for US locations (optional — failure just hides them)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const blocked = forbidCrossSite(request);
|
||||
if (blocked) return blocked;
|
||||
|
||||
const params = request.nextUrl.searchParams;
|
||||
const providerId = params.get("provider") ?? "openweather";
|
||||
const coords = parseCoords(params);
|
||||
|
||||
if (!isProviderId(providerId)) return badRequest(`Unknown provider "${providerId}"`);
|
||||
if (!coords) return badRequest("lat and lon are required");
|
||||
|
||||
const provider = PROVIDERS[providerId];
|
||||
if (!isReady(provider)) return badRequest(`${provider.label} has no API key configured`);
|
||||
|
||||
const [forecast, context, nwsAlerts] = await Promise.allSettled([
|
||||
provider.getForecast(coords.lat, coords.lon),
|
||||
getLocalContext(coords.lat, coords.lon),
|
||||
getNwsAlerts(coords.lat, coords.lon),
|
||||
]);
|
||||
|
||||
if (forecast.status === "rejected") return errorResponse(forecast.reason);
|
||||
|
||||
const local: LocalContext | null = context.status === "fulfilled" ? context.value : null;
|
||||
const official: WeatherAlert[] = nwsAlerts.status === "fulfilled" ? nwsAlerts.value : [];
|
||||
|
||||
// Prefer official NWS alerts in the US; elsewhere use whatever the provider gave us.
|
||||
const alerts = official.length > 0 ? official : forecast.value.alerts;
|
||||
const alertSource = official.length > 0 ? "National Weather Service" : alerts.length > 0 ? provider.label : null;
|
||||
|
||||
const report: WeatherReport = {
|
||||
...forecast.value,
|
||||
provider: providerId,
|
||||
timeZone: forecast.value.timeZone ?? local?.timeZone ?? null,
|
||||
airQuality: local?.airQuality ?? null,
|
||||
alerts,
|
||||
alertSource,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
return NextResponse.json(report);
|
||||
}
|
||||
96
app/globals.css
Normal file
96
app/globals.css
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
/* ============================================================
|
||||
Wayfarer theme — Tailwind v4.
|
||||
Tokens here become utilities: bg-gold, text-stone, font-pixel…
|
||||
All styling lives as utility classes in the components.
|
||||
============================================================ */
|
||||
|
||||
/* `static` keeps every color as a CSS variable even if no class uses it,
|
||||
because the pixel sprites reference them directly (lib/pixel-art.ts). */
|
||||
@theme static {
|
||||
--color-black: #000000;
|
||||
--color-panel: #0a0905; /* faint warmth inside frames so gold reads as metal */
|
||||
--color-gold: #c9a54c;
|
||||
--color-gold-dim: #5c4a1f;
|
||||
--color-gold-bright: #f2d98b;
|
||||
--color-ink: #e8e0c9;
|
||||
--color-stone: #857e6c;
|
||||
--color-teal: #58cbbd;
|
||||
--color-teal-off: #1d2a28;
|
||||
--color-heart: #d64a3c;
|
||||
--color-heart-empty: #4a2420;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-display: "Cinzel", Georgia, serif;
|
||||
--font-pixel: "Silkscreen", system-ui, sans-serif;
|
||||
--font-sans: "IBM Plex Sans", system-ui, sans-serif;
|
||||
|
||||
/* The one ambient motion: the hero sprite drifts like a held item. */
|
||||
--animate-float: float 3.2s ease-in-out infinite;
|
||||
@keyframes float {
|
||||
50% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Cut-corner shape for every frame. Set the corner size per element
|
||||
with [--cut:22px]; it defaults to 14px. */
|
||||
@utility chamfer {
|
||||
clip-path: polygon(
|
||||
var(--cut, 14px) 0, calc(100% - var(--cut, 14px)) 0, 100% var(--cut, 14px),
|
||||
100% calc(100% - var(--cut, 14px)), calc(100% - var(--cut, 14px)) 100%,
|
||||
var(--cut, 14px) 100%, 0 calc(100% - var(--cut, 14px)), 0 var(--cut, 14px)
|
||||
);
|
||||
}
|
||||
|
||||
/* Leaflet builds its own controls, so they can't take utility classes.
|
||||
Two things make these overrides win over leaflet.css:
|
||||
- they are NOT in an @layer (unlayered CSS always beats layered CSS), and
|
||||
- each starts with .leaflet-container, so it is more specific than Leaflet's
|
||||
own rule even though leaflet.css loads later. */
|
||||
.leaflet-container {
|
||||
@apply bg-black font-sans;
|
||||
}
|
||||
.leaflet-container .leaflet-bar {
|
||||
@apply rounded-none border border-gold-dim shadow-none;
|
||||
}
|
||||
.leaflet-container .leaflet-bar a {
|
||||
@apply rounded-none border-gold-dim bg-black text-gold-bright;
|
||||
}
|
||||
.leaflet-container .leaflet-bar a:hover,
|
||||
.leaflet-container .leaflet-bar a:focus-visible {
|
||||
@apply bg-gold text-black;
|
||||
}
|
||||
.leaflet-container .leaflet-control.leaflet-control-attribution {
|
||||
@apply bg-black/70 text-[10px] text-stone;
|
||||
}
|
||||
.leaflet-container .leaflet-control-attribution a {
|
||||
@apply text-gold;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply min-h-dvh bg-black font-sans text-ink antialiased;
|
||||
/* A single low ember of light behind the hero; the rest stays pure black. */
|
||||
background-image:
|
||||
radial-gradient(ellipse 65% 42% at 50% 10%, rgb(201 165 76 / 0.11), transparent 72%),
|
||||
radial-gradient(ellipse 40% 30% at 80% 24%, rgb(88 203 189 / 0.04), transparent 70%);
|
||||
}
|
||||
|
||||
/* Hide the browser's unstyled native clear button on search inputs; PlaceSearch renders its own. */
|
||||
input[type="search"]::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
@apply outline-2 outline-offset-3 outline-teal;
|
||||
}
|
||||
}
|
||||
32
app/layout.tsx
Normal file
32
app/layout.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { Metadata, Viewport } from "next";
|
||||
|
||||
// Fonts are bundled from npm, so builds never depend on reaching Google Fonts.
|
||||
import "@fontsource/cinzel/500.css";
|
||||
import "@fontsource/cinzel/700.css";
|
||||
import "@fontsource/silkscreen/400.css";
|
||||
import "@fontsource/ibm-plex-sans/400.css";
|
||||
import "@fontsource/ibm-plex-sans/500.css";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Wayfarer",
|
||||
description: "Weather for the road ahead — by Cayden Codes.",
|
||||
authors: [{ name: "Cayden Codes", url: "https://caydens.dev" }],
|
||||
creator: "Cayden Codes",
|
||||
appleWebApp: { capable: true, statusBarStyle: "black-translucent", title: "Wayfarer" },
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover", // lets the layout use the full screen on notched phones
|
||||
themeColor: "#000000",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
5
app/page.tsx
Normal file
5
app/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import WeatherApp from "@/components/WeatherApp";
|
||||
|
||||
export default function Home() {
|
||||
return <WeatherApp />;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue