chore: install project dependencies and generate build artifacts.
This commit is contained in:
@@ -25,3 +25,8 @@ pip-delete-this-directory.txt
|
||||
# OS
|
||||
.DS_Store
|
||||
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)
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
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")
|
||||
|
||||
_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")
|
||||
os.makedirs(_static, exist_ok=True)
|
||||
app.mount("/static", StaticFiles(directory=_static), name="static")
|
||||
|
||||
Reference in New Issue
Block a user