chore: install project dependencies and generate build artifacts.
This commit is contained in:
@@ -25,3 +25,8 @@ pip-delete-this-directory.txt
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Next.js frontend
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/.next/
|
||||||
|
frontend/.vercel/
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
POLYWEATHER_API_BASE_URL=http://127.0.0.1:8000
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# PolyWeather Frontend (Next.js)
|
||||||
|
|
||||||
|
Standalone web frontend for `polyweather.vercel.app`.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Next.js 14+ (App Router)
|
||||||
|
- Tailwind CSS
|
||||||
|
- Lucide React
|
||||||
|
- shadcn/ui base components
|
||||||
|
- Leaflet (react-leaflet)
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
cp .env.example .env.local
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Default frontend URL: `http://localhost:3000`
|
||||||
|
|
||||||
|
## Backend API
|
||||||
|
|
||||||
|
Set `POLYWEATHER_API_BASE_URL` to your FastAPI service URL.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POLYWEATHER_API_BASE_URL=https://api.yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
The frontend uses Next Route Handlers as a thin BFF layer:
|
||||||
|
|
||||||
|
- `GET /api/cities`
|
||||||
|
- `GET /api/city/:name`
|
||||||
|
|
||||||
|
## Vercel Deployment
|
||||||
|
|
||||||
|
1. Import this repo in Vercel.
|
||||||
|
2. Set **Root Directory** to `frontend`.
|
||||||
|
3. Add environment variable:
|
||||||
|
- `POLYWEATHER_API_BASE_URL=https://<your-fastapi-host>`
|
||||||
|
4. Deploy.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Backend CORS must allow `https://polyweather.vercel.app`.
|
||||||
|
- This is phase-1 split: map + city list + detail panel are migrated first.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const API_BASE =
|
||||||
|
process.env.POLYWEATHER_API_BASE_URL || "http://127.0.0.1:8000";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/cities`, {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
next: { revalidate: 120 },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Backend returned ${res.status}` },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch cities", detail: String(error) },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const API_BASE =
|
||||||
|
process.env.POLYWEATHER_API_BASE_URL || "http://127.0.0.1:8000";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
context: { params: Promise<{ name: string }> },
|
||||||
|
) {
|
||||||
|
const { name } = await context.params;
|
||||||
|
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
|
||||||
|
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}?force_refresh=${forceRefresh}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Backend returned ${res.status}` },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch city detail", detail: String(error) },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: 222 47% 6%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--card: 222 47% 9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
--primary: 190 95% 52%;
|
||||||
|
--primary-foreground: 222 47% 8%;
|
||||||
|
--secondary: 223 36% 16%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
--accent: 217 33% 18%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--border: 220 33% 20%;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
border-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 15% 5%, rgba(6, 78, 99, 0.2), transparent 45%),
|
||||||
|
radial-gradient(circle at 85% 85%, rgba(30, 41, 59, 0.4), transparent 42%),
|
||||||
|
hsl(var(--background));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "PolyWeather | Weather Intelligence",
|
||||||
|
description:
|
||||||
|
"PolyWeather pro dashboard with global weather risk map and city analytics.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
return (
|
||||||
|
<html lang="zh-CN" className="dark">
|
||||||
|
<body className="min-h-screen font-sans antialiased">
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PolyWeatherDashboard } from "@/components/polyweather-dashboard";
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
return <PolyWeatherDashboard />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.ts",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CloudSun, Gauge, Loader2, RefreshCw, Thermometer } from "lucide-react";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import type { CityDetail } from "@/lib/types";
|
||||||
|
|
||||||
|
type CityDetailPanelProps = {
|
||||||
|
detail: CityDetail | null;
|
||||||
|
loading: boolean;
|
||||||
|
onRefresh: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CityDetailPanel({
|
||||||
|
detail,
|
||||||
|
loading,
|
||||||
|
onRefresh,
|
||||||
|
}: CityDetailPanelProps) {
|
||||||
|
if (!detail) {
|
||||||
|
return (
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>City Detail</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm text-slate-400">
|
||||||
|
Select a city from the left list or map marker.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const symbol = detail.temp_symbol || "C";
|
||||||
|
const displayTemp =
|
||||||
|
detail.current?.max_so_far != null
|
||||||
|
? detail.current.max_so_far
|
||||||
|
: detail.current?.temp;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<CardTitle className="text-xl uppercase tracking-wide text-cyan-200">
|
||||||
|
{detail.display_name}
|
||||||
|
</CardTitle>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="default">{detail.local_time || "--:--"}</Badge>
|
||||||
|
{detail.current?.wu_settlement != null ? (
|
||||||
|
<Badge variant="warning">WU {detail.current.wu_settlement}</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 text-sm">
|
||||||
|
<div className="rounded-lg border border-slate-800 bg-slate-900/70 p-3">
|
||||||
|
<div className="mb-1 flex items-center gap-2 text-slate-400">
|
||||||
|
<Thermometer className="h-4 w-4" />
|
||||||
|
<span>Current / Max</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-semibold text-white">
|
||||||
|
{displayTemp ?? "--"}
|
||||||
|
{symbol}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-slate-400">
|
||||||
|
DEB: {detail.deb?.prediction ?? "--"}
|
||||||
|
{symbol}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-lg border border-slate-800 bg-slate-900/70 p-3">
|
||||||
|
<div className="mb-1 flex items-center gap-2 text-slate-400">
|
||||||
|
<CloudSun className="h-4 w-4" />
|
||||||
|
<span>Cloud</span>
|
||||||
|
</div>
|
||||||
|
<p className="font-medium">{detail.current?.cloud_desc || "N/A"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-slate-800 bg-slate-900/70 p-3">
|
||||||
|
<div className="mb-1 flex items-center gap-2 text-slate-400">
|
||||||
|
<Gauge className="h-4 w-4" />
|
||||||
|
<span>Wind</span>
|
||||||
|
</div>
|
||||||
|
<p className="font-medium">
|
||||||
|
{detail.current?.wind_speed_kt != null
|
||||||
|
? `${detail.current.wind_speed_kt} kt`
|
||||||
|
: "N/A"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-slate-800 bg-slate-900/70 p-3">
|
||||||
|
<p className="mb-2 text-slate-400">AI Summary</p>
|
||||||
|
<div
|
||||||
|
className="max-h-44 overflow-y-auto leading-6 text-slate-200"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: detail.ai_analysis || "No analysis yet.",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ThermometerSun } from "lucide-react";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { CitySummary } from "@/lib/types";
|
||||||
|
|
||||||
|
type CityListProps = {
|
||||||
|
cities: CitySummary[];
|
||||||
|
selectedCity: string | null;
|
||||||
|
onSelectCity: (cityName: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function riskVariant(level: CitySummary["risk_level"]): "success" | "warning" | "danger" {
|
||||||
|
if (level === "high") return "danger";
|
||||||
|
if (level === "medium") return "warning";
|
||||||
|
return "success";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CityList({ cities, selectedCity, onSelectCity }: CityListProps) {
|
||||||
|
return (
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center justify-between text-sm uppercase tracking-wider text-slate-300">
|
||||||
|
<span>Monitored Cities</span>
|
||||||
|
<Badge variant="default">{cities.length}</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="h-[calc(100%-64px)] overflow-y-auto">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{cities.map((city) => (
|
||||||
|
<button
|
||||||
|
key={city.name}
|
||||||
|
onClick={() => onSelectCity(city.name)}
|
||||||
|
className={cn(
|
||||||
|
"w-full rounded-lg border px-3 py-2 text-left transition-colors",
|
||||||
|
selectedCity === city.name
|
||||||
|
? "border-cyan-500 bg-cyan-950/40"
|
||||||
|
: "border-slate-800 bg-slate-900/70 hover:bg-slate-800/80",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="truncate font-medium text-slate-100">
|
||||||
|
{city.display_name}
|
||||||
|
</div>
|
||||||
|
<Badge variant={riskVariant(city.risk_level)}>{city.risk_level}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center gap-1 text-xs text-slate-400">
|
||||||
|
<ThermometerSun className="h-3.5 w-3.5" />
|
||||||
|
<span>{city.temp_unit === "fahrenheit" ? "Fahrenheit" : "Celsius"}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MapContainer, Marker, Popup, TileLayer } from "react-leaflet";
|
||||||
|
import L from "leaflet";
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
import type { CitySummary } from "@/lib/types";
|
||||||
|
|
||||||
|
type MapCanvasProps = {
|
||||||
|
cities: CitySummary[];
|
||||||
|
selectedCity: string | null;
|
||||||
|
onSelectCity: (cityName: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tempIcon = (risk: CitySummary["risk_level"]) =>
|
||||||
|
L.divIcon({
|
||||||
|
className: "",
|
||||||
|
html: `<div style="
|
||||||
|
background:${risk === "high" ? "#ef4444" : risk === "medium" ? "#f59e0b" : "#10b981"};
|
||||||
|
color:#fff;
|
||||||
|
padding:6px 10px;
|
||||||
|
border-radius:999px;
|
||||||
|
font-size:12px;
|
||||||
|
font-weight:700;
|
||||||
|
box-shadow:0 2px 10px rgba(0,0,0,.4);
|
||||||
|
">${risk.toUpperCase()}</div>`,
|
||||||
|
iconSize: [56, 28],
|
||||||
|
iconAnchor: [28, 14],
|
||||||
|
});
|
||||||
|
|
||||||
|
export function MapCanvas({ cities, selectedCity, onSelectCity }: MapCanvasProps) {
|
||||||
|
return (
|
||||||
|
<MapContainer center={[30, 10]} zoom={3} minZoom={2} className="h-full w-full">
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/">OSM</a> © <a href="https://carto.com/">CARTO</a>'
|
||||||
|
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||||
|
/>
|
||||||
|
{cities.map((city) => (
|
||||||
|
<Marker
|
||||||
|
key={city.name}
|
||||||
|
position={[city.lat, city.lon]}
|
||||||
|
icon={tempIcon(city.risk_level)}
|
||||||
|
eventHandlers={{ click: () => onSelectCity(city.name) }}
|
||||||
|
>
|
||||||
|
<Popup>
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-semibold">{city.display_name}</p>
|
||||||
|
<p>Risk: {city.risk_level}</p>
|
||||||
|
<p>{selectedCity === city.name ? "Selected" : "Click to open"}</p>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
</MapContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import type { CitySummary } from "@/lib/types";
|
||||||
|
|
||||||
|
const DynamicMap = dynamic(
|
||||||
|
() => import("@/components/map-canvas").then((m) => m.MapCanvas),
|
||||||
|
{ ssr: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
type MapViewProps = {
|
||||||
|
cities: CitySummary[];
|
||||||
|
selectedCity: string | null;
|
||||||
|
onSelectCity: (cityName: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MapView(props: MapViewProps) {
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full overflow-hidden rounded-2xl border border-slate-800">
|
||||||
|
<DynamicMap {...props} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { AlertTriangle, Bot, Globe2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { CityDetailPanel } from "@/components/city-detail-panel";
|
||||||
|
import { CityList } from "@/components/city-list";
|
||||||
|
import { MapView } from "@/components/map-view";
|
||||||
|
import { getCities, getCityDetail } from "@/lib/api";
|
||||||
|
import type { CityDetail, CitySummary } from "@/lib/types";
|
||||||
|
|
||||||
|
export function PolyWeatherDashboard() {
|
||||||
|
const [cities, setCities] = useState<CitySummary[]>([]);
|
||||||
|
const [selectedCity, setSelectedCity] = useState<string | null>(null);
|
||||||
|
const [selectedDetail, setSelectedDetail] = useState<CityDetail | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
const data = await getCities();
|
||||||
|
if (!mounted) return;
|
||||||
|
setCities(data);
|
||||||
|
} catch (err) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setError(String(err));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedCity) return;
|
||||||
|
let mounted = true;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const detail = await getCityDetail(selectedCity);
|
||||||
|
if (!mounted) return;
|
||||||
|
setSelectedDetail(detail);
|
||||||
|
} catch (err) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setError(String(err));
|
||||||
|
} finally {
|
||||||
|
if (mounted) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, [selectedCity]);
|
||||||
|
|
||||||
|
const orderedCities = useMemo(() => {
|
||||||
|
const order = { high: 0, medium: 1, low: 2 };
|
||||||
|
return [...cities].sort(
|
||||||
|
(a, b) => (order[a.risk_level] ?? 99) - (order[b.risk_level] ?? 99),
|
||||||
|
);
|
||||||
|
}, [cities]);
|
||||||
|
|
||||||
|
async function refreshCurrentCity() {
|
||||||
|
if (!selectedCity) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const detail = await getCityDetail(selectedCity, true);
|
||||||
|
setSelectedDetail(detail);
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="h-screen w-full p-3 md:p-4">
|
||||||
|
<div className="grid h-full grid-cols-1 gap-3 md:grid-cols-[280px_1fr_360px]">
|
||||||
|
<section className="min-h-0">
|
||||||
|
<CityList
|
||||||
|
cities={orderedCities}
|
||||||
|
selectedCity={selectedCity}
|
||||||
|
onSelectCity={setSelectedCity}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="relative min-h-0">
|
||||||
|
<div className="absolute left-3 right-3 top-3 z-[900] rounded-xl border border-slate-800 bg-slate-950/70 p-2 backdrop-blur md:left-4 md:right-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Globe2 className="h-4 w-4 text-cyan-300" />
|
||||||
|
<h1 className="text-lg font-semibold uppercase tracking-wide text-cyan-100 md:text-xl">
|
||||||
|
PolyWeather
|
||||||
|
</h1>
|
||||||
|
<span className="hidden text-xs text-slate-400 md:inline">
|
||||||
|
Global Weather Risk Intelligence
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="secondary">
|
||||||
|
<Bot className="mr-1 h-4 w-4" />
|
||||||
|
Technical Guide
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<MapView
|
||||||
|
cities={orderedCities}
|
||||||
|
selectedCity={selectedCity}
|
||||||
|
onSelectCity={setSelectedCity}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="min-h-0">
|
||||||
|
<CityDetailPanel
|
||||||
|
detail={selectedDetail}
|
||||||
|
loading={loading}
|
||||||
|
onRefresh={refreshCurrentCity}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="pointer-events-none fixed bottom-4 left-1/2 z-[1200] -translate-x-1/2 rounded-lg border border-red-900 bg-red-950/90 px-3 py-2 text-sm text-red-100">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border-cyan-700 bg-cyan-950 text-cyan-200",
|
||||||
|
success: "border-emerald-700 bg-emerald-950 text-emerald-200",
|
||||||
|
warning: "border-amber-700 bg-amber-950 text-amber-200",
|
||||||
|
danger: "border-red-700 bg-red-950 text-red-200",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-cyan-400 text-slate-950 hover:bg-cyan-300",
|
||||||
|
secondary:
|
||||||
|
"bg-slate-800 text-slate-100 border border-slate-700 hover:bg-slate-700",
|
||||||
|
ghost: "hover:bg-slate-800 hover:text-slate-100",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2",
|
||||||
|
sm: "h-8 rounded-md px-3",
|
||||||
|
lg: "h-10 rounded-md px-6",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl border border-slate-800 bg-slate-950/70 text-slate-100 shadow-glow backdrop-blur-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Card.displayName = "Card";
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-4 md:p-5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardHeader.displayName = "CardHeader";
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p ref={ref} className={cn("text-sm text-slate-400", className)} {...props} />
|
||||||
|
));
|
||||||
|
CardDescription.displayName = "CardDescription";
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-4 pt-0 md:p-5 md:pt-0", className)} {...props} />
|
||||||
|
));
|
||||||
|
CardContent.displayName = "CardContent";
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardTitle, CardDescription, CardContent };
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { CityDetail, CitySummary } from "@/lib/types";
|
||||||
|
|
||||||
|
export async function getCities(): Promise<CitySummary[]> {
|
||||||
|
const res = await fetch("/api/cities", { cache: "no-store" });
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to load cities: ${res.status}`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
return data.cities ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCityDetail(
|
||||||
|
cityName: string,
|
||||||
|
forceRefresh = false,
|
||||||
|
): Promise<CityDetail> {
|
||||||
|
const slug = cityName.replace(/\s/g, "-");
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/city/${encodeURIComponent(slug)}?force_refresh=${forceRefresh}`,
|
||||||
|
{
|
||||||
|
cache: "no-store",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to load city detail: ${res.status}`);
|
||||||
|
}
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export type CitySummary = {
|
||||||
|
name: string;
|
||||||
|
display_name: string;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
risk_level: "low" | "medium" | "high";
|
||||||
|
risk_emoji?: string;
|
||||||
|
temp_unit: "fahrenheit" | "celsius";
|
||||||
|
is_major?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CityDetail = {
|
||||||
|
name: string;
|
||||||
|
display_name: string;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
temp_symbol: string;
|
||||||
|
local_time: string;
|
||||||
|
current?: {
|
||||||
|
temp?: number | null;
|
||||||
|
max_so_far?: number | null;
|
||||||
|
wu_settlement?: number | null;
|
||||||
|
cloud_desc?: string | null;
|
||||||
|
wind_speed_kt?: number | null;
|
||||||
|
obs_time?: string | null;
|
||||||
|
};
|
||||||
|
deb?: {
|
||||||
|
prediction?: number | null;
|
||||||
|
};
|
||||||
|
probabilities?: {
|
||||||
|
mu?: number | null;
|
||||||
|
distribution?: Array<{ value: number; probability: number }>;
|
||||||
|
};
|
||||||
|
ai_analysis?: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference path="./.next/types/routes.d.ts" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+3227
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "polyweather-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"lucide-react": "^0.511.0",
|
||||||
|
"next": "^15.3.2",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
|
"tailwind-merge": "^3.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/leaflet": "^1.9.20",
|
||||||
|
"@types/node": "^22.15.21",
|
||||||
|
"@types/react": "^19.0.10",
|
||||||
|
"@types/react-dom": "^19.0.4",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.5.3",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5.8.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: [
|
||||||
|
"./app/**/*.{ts,tsx}",
|
||||||
|
"./components/**/*.{ts,tsx}",
|
||||||
|
"./lib/**/*.{ts,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
card: "hsl(var(--card))",
|
||||||
|
"card-foreground": "hsl(var(--card-foreground))",
|
||||||
|
primary: "hsl(var(--primary))",
|
||||||
|
"primary-foreground": "hsl(var(--primary-foreground))",
|
||||||
|
secondary: "hsl(var(--secondary))",
|
||||||
|
"secondary-foreground": "hsl(var(--secondary-foreground))",
|
||||||
|
accent: "hsl(var(--accent))",
|
||||||
|
"accent-foreground": "hsl(var(--accent-foreground))",
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
glow: "0 0 0 1px rgba(34, 211, 238, 0.2), 0 8px 32px rgba(8, 47, 73, 0.5)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
},
|
||||||
|
"plugins": [{ "name": "next" }]
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
+13
@@ -18,6 +18,7 @@ if _root not in sys.path:
|
|||||||
sys.path.insert(0, _root)
|
sys.path.insert(0, _root)
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -32,6 +33,18 @@ from src.analysis.deb_algorithm import calculate_dynamic_weights, get_deb_accura
|
|||||||
# ──────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────
|
||||||
app = FastAPI(title="PolyWeather Map", version="1.0")
|
app = FastAPI(title="PolyWeather Map", version="1.0")
|
||||||
|
|
||||||
|
_cors_origins = os.getenv(
|
||||||
|
"WEB_CORS_ORIGINS",
|
||||||
|
"http://localhost:3000,http://127.0.0.1:3000,https://polyweather.vercel.app",
|
||||||
|
)
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=[o.strip() for o in _cors_origins.split(",") if o.strip()],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
_static = os.path.join(os.path.dirname(__file__), "static")
|
_static = os.path.join(os.path.dirname(__file__), "static")
|
||||||
os.makedirs(_static, exist_ok=True)
|
os.makedirs(_static, exist_ok=True)
|
||||||
app.mount("/static", StaticFiles(directory=_static), name="static")
|
app.mount("/static", StaticFiles(directory=_static), name="static")
|
||||||
|
|||||||
Reference in New Issue
Block a user