feat: Implement initial PolyWeather dashboard with city list, detail view, map, and API endpoints.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
.vercel
|
||||
@@ -1,17 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const API_BASE =
|
||||
process.env.POLYWEATHER_API_BASE_URL || "http://127.0.0.1:8000";
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET() {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/cities`, {
|
||||
headers: { Accept: "application/json" },
|
||||
next: { revalidate: 120 },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
return NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}` },
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE =
|
||||
process.env.POLYWEATHER_API_BASE_URL || "http://127.0.0.1:8000";
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
if (!API_BASE) {
|
||||
return NextResponse.json(
|
||||
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
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}`;
|
||||
@@ -17,8 +23,9 @@ export async function GET(
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
return NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}` },
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: 222 47% 6%;
|
||||
--background: 223 53% 4%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222 47% 9%;
|
||||
--card: 223 46% 8%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--primary: 190 95% 52%;
|
||||
--primary: 190 95% 56%;
|
||||
--primary-foreground: 222 47% 8%;
|
||||
--secondary: 223 36% 16%;
|
||||
--secondary: 224 30% 14%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--accent: 217 33% 18%;
|
||||
--accent: 217 30% 18%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--border: 220 33% 20%;
|
||||
--border: 221 38% 22%;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -21,9 +21,11 @@
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Sora", "Avenir Next", "Segoe UI", sans-serif;
|
||||
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%),
|
||||
radial-gradient(circle at 10% -10%, rgba(34, 211, 238, 0.2), transparent 40%),
|
||||
radial-gradient(circle at 90% 0%, rgba(59, 130, 246, 0.14), transparent 36%),
|
||||
radial-gradient(circle at 80% 100%, rgba(8, 47, 73, 0.5), transparent 48%),
|
||||
hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
@@ -33,3 +35,73 @@ body {
|
||||
height: 100%;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom a {
|
||||
background: rgba(2, 6, 23, 0.82) !important;
|
||||
border-color: rgba(71, 85, 105, 0.5) !important;
|
||||
color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom a:hover {
|
||||
background: rgba(15, 23, 42, 0.95) !important;
|
||||
}
|
||||
|
||||
.map-pill {
|
||||
min-width: 52px;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding: 5px 10px;
|
||||
color: #f8fafc;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow:
|
||||
0 2px 14px rgba(2, 6, 23, 0.45),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.map-pill.high {
|
||||
background: linear-gradient(135deg, #ef4444, #b91c1c);
|
||||
}
|
||||
|
||||
.map-pill.medium {
|
||||
background: linear-gradient(135deg, #f59e0b, #b45309);
|
||||
}
|
||||
|
||||
.map-pill.low {
|
||||
background: linear-gradient(135deg, #10b981, #047857);
|
||||
}
|
||||
|
||||
.map-pill.active {
|
||||
transform: translateY(-2px) scale(1.05);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(34, 211, 238, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(15, 23, 42, 0.9) 0%,
|
||||
rgba(2, 6, 23, 0.75) 100%
|
||||
);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.fade-up {
|
||||
animation: fadeUp 450ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,27 +5,43 @@ 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 { Locale } from "@/lib/i18n";
|
||||
import type { CityDetail } from "@/lib/types";
|
||||
|
||||
type CityDetailPanelProps = {
|
||||
detail: CityDetail | null;
|
||||
loading: boolean;
|
||||
onRefresh: () => void;
|
||||
locale: Locale;
|
||||
text: {
|
||||
cityDetail: string;
|
||||
selectCityHint: string;
|
||||
refresh: string;
|
||||
currentMax: string;
|
||||
cloud: string;
|
||||
wind: string;
|
||||
topProb: string;
|
||||
noProb: string;
|
||||
aiSummary: string;
|
||||
noAnalysis: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function CityDetailPanel({
|
||||
detail,
|
||||
loading,
|
||||
onRefresh,
|
||||
locale,
|
||||
text,
|
||||
}: CityDetailPanelProps) {
|
||||
if (!detail) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>City Detail</CardTitle>
|
||||
<CardTitle>{text.cityDetail}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-slate-400">
|
||||
Select a city from the left list or map marker.
|
||||
{text.selectCityHint}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -36,12 +52,20 @@ export function CityDetailPanel({
|
||||
detail.current?.max_so_far != null
|
||||
? detail.current.max_so_far
|
||||
: detail.current?.temp;
|
||||
const topProbabilities = (detail.probabilities?.distribution ?? [])
|
||||
.slice(0, 3)
|
||||
.map((item) => ({
|
||||
value: item.value,
|
||||
pct: Math.round(item.probability * 100),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<Card className="glass h-full fade-up">
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="text-xl uppercase tracking-wide text-cyan-200">
|
||||
<CardTitle
|
||||
className={`text-lg text-cyan-200 ${locale === "zh" ? "tracking-[0.06em]" : "uppercase tracking-[0.16em]"}`}
|
||||
>
|
||||
{detail.display_name}
|
||||
</CardTitle>
|
||||
<Button
|
||||
@@ -55,7 +79,7 @@ export function CityDetailPanel({
|
||||
) : (
|
||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
Refresh
|
||||
{text.refresh}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -66,33 +90,33 @@ export function CityDetailPanel({
|
||||
</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">
|
||||
<div className="rounded-2xl border border-cyan-900/40 bg-gradient-to-br from-cyan-950/40 to-slate-900/80 p-4">
|
||||
<div className="mb-1 flex items-center gap-2 text-slate-300">
|
||||
<Thermometer className="h-4 w-4" />
|
||||
<span>Current / Max</span>
|
||||
<span>{text.currentMax}</span>
|
||||
</div>
|
||||
<div className="text-2xl font-semibold text-white">
|
||||
<div className="text-3xl font-semibold text-white">
|
||||
{displayTemp ?? "--"}
|
||||
{symbol}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-400">
|
||||
<div className="mt-1 text-xs text-slate-300">
|
||||
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="rounded-xl 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>
|
||||
<span>{text.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="rounded-xl 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>
|
||||
<span>{text.wind}</span>
|
||||
</div>
|
||||
<p className="font-medium">
|
||||
{detail.current?.wind_speed_kt != null
|
||||
@@ -102,12 +126,39 @@ export function CityDetailPanel({
|
||||
</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="rounded-xl border border-slate-800 bg-slate-900/70 p-3">
|
||||
<p className="mb-2 text-slate-300">{text.topProb}</p>
|
||||
{topProbabilities.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">{text.noProb}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{topProbabilities.map((item) => (
|
||||
<div key={item.value} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-slate-200">
|
||||
{item.value}
|
||||
{symbol}
|
||||
</span>
|
||||
<span className="text-cyan-300">{item.pct}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-slate-800">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-cyan-400 to-blue-400"
|
||||
style={{ width: `${Math.max(item.pct, 6)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900/70 p-3">
|
||||
<p className="mb-2 text-slate-400">{text.aiSummary}</p>
|
||||
<div
|
||||
className="max-h-44 overflow-y-auto leading-6 text-slate-200"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: detail.ai_analysis || "No analysis yet.",
|
||||
__html: detail.ai_analysis || text.noAnalysis,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { ThermometerSun } from "lucide-react";
|
||||
import { AlertTriangle, GaugeCircle, ShieldCheck, ThermometerSun } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { Locale } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CitySummary } from "@/lib/types";
|
||||
|
||||
@@ -11,6 +12,15 @@ type CityListProps = {
|
||||
cities: CitySummary[];
|
||||
selectedCity: string | null;
|
||||
onSelectCity: (cityName: string) => void;
|
||||
locale: Locale;
|
||||
text: {
|
||||
monitoredCities: string;
|
||||
high: string;
|
||||
medium: string;
|
||||
low: string;
|
||||
fahrenheit: string;
|
||||
celsius: string;
|
||||
};
|
||||
};
|
||||
|
||||
function riskVariant(level: CitySummary["risk_level"]): "success" | "warning" | "danger" {
|
||||
@@ -19,37 +29,76 @@ function riskVariant(level: CitySummary["risk_level"]): "success" | "warning" |
|
||||
return "success";
|
||||
}
|
||||
|
||||
export function CityList({ cities, selectedCity, onSelectCity }: CityListProps) {
|
||||
export function CityList({
|
||||
cities,
|
||||
selectedCity,
|
||||
onSelectCity,
|
||||
locale,
|
||||
text,
|
||||
}: CityListProps) {
|
||||
const high = cities.filter((c) => c.risk_level === "high").length;
|
||||
const medium = cities.filter((c) => c.risk_level === "medium").length;
|
||||
const low = cities.filter((c) => c.risk_level === "low").length;
|
||||
|
||||
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>
|
||||
<Card className="glass h-full overflow-hidden">
|
||||
<CardHeader className="space-y-3 pb-3">
|
||||
<CardTitle className="flex items-center justify-between text-xs uppercase tracking-[0.2em] text-slate-300">
|
||||
<span className={locale === "zh" ? "tracking-[0.08em]" : ""}>
|
||||
{text.monitoredCities}
|
||||
</span>
|
||||
<Badge variant="default">{cities.length}</Badge>
|
||||
</CardTitle>
|
||||
<div className="grid grid-cols-3 gap-2 text-[11px]">
|
||||
<div className="rounded-lg border border-red-900/70 bg-red-950/40 p-2 text-red-200">
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
<span>{text.high}</span>
|
||||
</div>
|
||||
<p className="text-base font-semibold">{high}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-amber-900/70 bg-amber-950/40 p-2 text-amber-200">
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
<GaugeCircle className="h-3 w-3" />
|
||||
<span>{text.medium}</span>
|
||||
</div>
|
||||
<p className="text-base font-semibold">{medium}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-emerald-900/70 bg-emerald-950/40 p-2 text-emerald-200">
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
<span>{text.low}</span>
|
||||
</div>
|
||||
<p className="text-base font-semibold">{low}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[calc(100%-64px)] overflow-y-auto">
|
||||
<div className="space-y-2">
|
||||
<CardContent className="h-[calc(100%-128px)] overflow-y-auto pb-3">
|
||||
<div className="space-y-1.5">
|
||||
{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",
|
||||
"w-full rounded-xl border px-3 py-2 text-left transition-all",
|
||||
selectedCity === city.name
|
||||
? "border-cyan-500 bg-cyan-950/40"
|
||||
: "border-slate-800 bg-slate-900/70 hover:bg-slate-800/80",
|
||||
? "translate-x-1 border-cyan-400/60 bg-cyan-950/40 shadow-[inset_0_0_0_1px_rgba(34,211,238,0.28)]"
|
||||
: "border-slate-800/90 bg-slate-900/60 hover:border-slate-700 hover:bg-slate-800/80",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="truncate font-medium text-slate-100">
|
||||
<div className="truncate text-sm font-semibold 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">
|
||||
<div className="mt-1.5 flex items-center gap-1 text-[11px] text-slate-400">
|
||||
<ThermometerSun className="h-3.5 w-3.5" />
|
||||
<span>{city.temp_unit === "fahrenheit" ? "Fahrenheit" : "Celsius"}</span>
|
||||
<span>
|
||||
{city.temp_unit === "fahrenheit"
|
||||
? text.fahrenheit
|
||||
: text.celsius}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -11,20 +11,19 @@ type MapCanvasProps = {
|
||||
onSelectCity: (cityName: string) => void;
|
||||
};
|
||||
|
||||
const tempIcon = (risk: CitySummary["risk_level"]) =>
|
||||
function shortName(name: string) {
|
||||
return name.length > 8 ? `${name.slice(0, 7)}.` : name;
|
||||
}
|
||||
|
||||
const tempIcon = (
|
||||
city: CitySummary,
|
||||
selected: boolean,
|
||||
) =>
|
||||
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],
|
||||
html: `<div class="map-pill ${city.risk_level} ${selected ? "active" : ""}">${shortName(city.display_name)}</div>`,
|
||||
iconSize: [78, 30],
|
||||
iconAnchor: [39, 15],
|
||||
});
|
||||
|
||||
export function MapCanvas({ cities, selectedCity, onSelectCity }: MapCanvasProps) {
|
||||
@@ -38,7 +37,7 @@ export function MapCanvas({ cities, selectedCity, onSelectCity }: MapCanvasProps
|
||||
<Marker
|
||||
key={city.name}
|
||||
position={[city.lat, city.lon]}
|
||||
icon={tempIcon(city.risk_level)}
|
||||
icon={tempIcon(city, selectedCity === city.name)}
|
||||
eventHandlers={{ click: () => onSelectCity(city.name) }}
|
||||
>
|
||||
<Popup>
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AlertTriangle, Bot, Globe2 } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bot,
|
||||
Globe2,
|
||||
Languages,
|
||||
Radar,
|
||||
Signal,
|
||||
Waves,
|
||||
} 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 { copy, type Locale } from "@/lib/i18n";
|
||||
import type { CityDetail, CitySummary } from "@/lib/types";
|
||||
|
||||
export function PolyWeatherDashboard() {
|
||||
const [locale, setLocale] = useState<Locale>("zh");
|
||||
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);
|
||||
const t = copy[locale];
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
@@ -64,6 +75,14 @@ export function PolyWeatherDashboard() {
|
||||
);
|
||||
}, [cities]);
|
||||
|
||||
const riskStats = useMemo(() => {
|
||||
return {
|
||||
high: cities.filter((c) => c.risk_level === "high").length,
|
||||
medium: cities.filter((c) => c.risk_level === "medium").length,
|
||||
low: cities.filter((c) => c.risk_level === "low").length,
|
||||
};
|
||||
}, [cities]);
|
||||
|
||||
async function refreshCurrentCity() {
|
||||
if (!selectedCity) return;
|
||||
setLoading(true);
|
||||
@@ -77,35 +96,94 @@ export function PolyWeatherDashboard() {
|
||||
}
|
||||
}
|
||||
|
||||
function localizeError(raw: string) {
|
||||
if (raw.includes("POLYWEATHER_API_BASE_URL")) {
|
||||
return t.backendConfigMissing;
|
||||
}
|
||||
if (raw.toLowerCase().includes("failed to load cities")) {
|
||||
return `${t.loadCitiesFailed}: ${raw}`;
|
||||
}
|
||||
if (raw.toLowerCase().includes("failed to load city detail")) {
|
||||
return `${t.loadCityDetailFailed}: ${raw}`;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
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">
|
||||
<main className="h-screen w-full overflow-hidden p-2 md:p-4">
|
||||
<div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[290px_1fr_370px]">
|
||||
<section className="min-h-0 lg:block">
|
||||
<CityList
|
||||
cities={orderedCities}
|
||||
selectedCity={selectedCity}
|
||||
onSelectCity={setSelectedCity}
|
||||
locale={locale}
|
||||
text={{
|
||||
monitoredCities: t.monitoredCities,
|
||||
high: t.high,
|
||||
medium: t.medium,
|
||||
low: t.low,
|
||||
fahrenheit: t.fahrenheit,
|
||||
celsius: t.celsius,
|
||||
}}
|
||||
/>
|
||||
</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">
|
||||
<div className="glass fade-up absolute left-2 right-2 top-2 z-[900] rounded-2xl border border-slate-700/80 p-2.5 md:left-4 md:right-4 md:top-4 md:p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Globe2 className="h-4 w-4 shrink-0 text-cyan-300" />
|
||||
<h1 className="shrink-0 text-base font-semibold uppercase tracking-[0.16em] text-cyan-100 md:text-lg">
|
||||
PolyWeather
|
||||
</h1>
|
||||
<span className="hidden text-xs text-slate-400 md:inline">
|
||||
Global Weather Risk Intelligence
|
||||
<span className="hidden truncate text-xs text-slate-400 xl:inline">
|
||||
{t.brandSubtitle}
|
||||
</span>
|
||||
</div>
|
||||
<Button size="sm" variant="secondary">
|
||||
<Bot className="mr-1 h-4 w-4" />
|
||||
Technical Guide
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="hidden items-center gap-2 rounded-full border border-slate-700 bg-slate-900/70 px-2 py-1 text-[11px] text-emerald-300 md:flex">
|
||||
<Signal className="h-3 w-3" />
|
||||
{t.live}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setLocale((v) => (v === "zh" ? "en" : "zh"))}
|
||||
>
|
||||
<Languages className="mr-1 h-4 w-4" />
|
||||
{locale === "zh" ? "EN" : "ZH"}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary">
|
||||
<Bot className="mr-1 h-4 w-4" />
|
||||
{t.technicalGuide}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-2 left-2 right-2 z-[900] md:bottom-4 md:left-4 md:right-auto">
|
||||
<div className="glass fade-up inline-flex flex-wrap items-center gap-2 rounded-xl border border-slate-700/80 p-2">
|
||||
<div className="rounded-lg border border-red-900/60 bg-red-950/50 px-2.5 py-1 text-xs text-red-200">
|
||||
H {riskStats.high}
|
||||
</div>
|
||||
<div className="rounded-lg border border-amber-900/60 bg-amber-950/50 px-2.5 py-1 text-xs text-amber-200">
|
||||
M {riskStats.medium}
|
||||
</div>
|
||||
<div className="rounded-lg border border-emerald-900/60 bg-emerald-950/50 px-2.5 py-1 text-xs text-emerald-200">
|
||||
L {riskStats.low}
|
||||
</div>
|
||||
<div className="hidden items-center gap-1 rounded-lg border border-cyan-900/60 bg-cyan-950/40 px-2.5 py-1 text-xs text-cyan-200 sm:flex">
|
||||
<Radar className="h-3.5 w-3.5" />
|
||||
{cities.length} {t.cities}
|
||||
</div>
|
||||
<div className="hidden items-center gap-1 rounded-lg border border-slate-700 px-2.5 py-1 text-xs text-slate-300 sm:flex">
|
||||
<Waves className="h-3.5 w-3.5" />
|
||||
AI + DEB
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MapView
|
||||
cities={orderedCities}
|
||||
selectedCity={selectedCity}
|
||||
@@ -113,11 +191,45 @@ export function PolyWeatherDashboard() {
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="min-h-0">
|
||||
<section className="min-h-0 hidden lg:block">
|
||||
<CityDetailPanel
|
||||
detail={selectedDetail}
|
||||
loading={loading}
|
||||
onRefresh={refreshCurrentCity}
|
||||
locale={locale}
|
||||
text={{
|
||||
cityDetail: t.cityDetail,
|
||||
selectCityHint: t.selectCityHint,
|
||||
refresh: t.refresh,
|
||||
currentMax: t.currentMax,
|
||||
cloud: t.cloud,
|
||||
wind: t.wind,
|
||||
topProb: t.topProb,
|
||||
noProb: t.noProb,
|
||||
aiSummary: t.aiSummary,
|
||||
noAnalysis: t.noAnalysis,
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="min-h-0 lg:hidden">
|
||||
<CityDetailPanel
|
||||
detail={selectedDetail}
|
||||
loading={loading}
|
||||
onRefresh={refreshCurrentCity}
|
||||
locale={locale}
|
||||
text={{
|
||||
cityDetail: t.cityDetail,
|
||||
selectCityHint: t.selectCityHint,
|
||||
refresh: t.refresh,
|
||||
currentMax: t.currentMax,
|
||||
cloud: t.cloud,
|
||||
wind: t.wind,
|
||||
topProb: t.topProb,
|
||||
noProb: t.noProb,
|
||||
aiSummary: t.aiSummary,
|
||||
noAnalysis: t.noAnalysis,
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
@@ -126,7 +238,7 @@ export function PolyWeatherDashboard() {
|
||||
<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>
|
||||
<span>{localizeError(error)}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
+15
-2
@@ -1,9 +1,20 @@
|
||||
import type { CityDetail, CitySummary } from "@/lib/types";
|
||||
|
||||
async function readError(res: Response, fallback: string) {
|
||||
try {
|
||||
const data = await res.json();
|
||||
const msg = data?.error ? String(data.error) : fallback;
|
||||
const detail = data?.detail ? ` ${String(data.detail)}` : "";
|
||||
return `${msg}${detail}`.trim();
|
||||
} catch {
|
||||
return `${fallback}: ${res.status}`;
|
||||
}
|
||||
}
|
||||
|
||||
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}`);
|
||||
throw new Error(await readError(res, `Failed to load cities (${res.status})`));
|
||||
}
|
||||
const data = await res.json();
|
||||
return data.cities ?? [];
|
||||
@@ -21,7 +32,9 @@ export async function getCityDetail(
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load city detail: ${res.status}`);
|
||||
throw new Error(
|
||||
await readError(res, `Failed to load city detail (${res.status})`),
|
||||
);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
export type Locale = "zh" | "en";
|
||||
|
||||
export const copy = {
|
||||
en: {
|
||||
brandSubtitle: "Global Weather Risk Intelligence",
|
||||
technicalGuide: "Technical Guide",
|
||||
monitoredCities: "Monitored Cities",
|
||||
cityDetail: "City Detail",
|
||||
selectCityHint: "Select a city from the left list or map marker.",
|
||||
live: "Live",
|
||||
cities: "Cities",
|
||||
currentMax: "Current / Max",
|
||||
cloud: "Cloud",
|
||||
wind: "Wind",
|
||||
topProb: "Top Settlement Probabilities",
|
||||
noProb: "No probability data yet.",
|
||||
aiSummary: "AI Summary",
|
||||
noAnalysis: "No analysis yet.",
|
||||
refresh: "Refresh",
|
||||
fahrenheit: "Fahrenheit",
|
||||
celsius: "Celsius",
|
||||
high: "High",
|
||||
medium: "Med",
|
||||
low: "Low",
|
||||
loadCitiesFailed: "Failed to load cities",
|
||||
loadCityDetailFailed: "Failed to load city detail",
|
||||
backendConfigMissing:
|
||||
"Server config missing: POLYWEATHER_API_BASE_URL is not set.",
|
||||
},
|
||||
zh: {
|
||||
brandSubtitle: "全球天气风险智能分析",
|
||||
technicalGuide: "技术说明",
|
||||
monitoredCities: "监控城市",
|
||||
cityDetail: "城市详情",
|
||||
selectCityHint: "请从左侧列表或地图标记选择城市。",
|
||||
live: "实时",
|
||||
cities: "城市",
|
||||
currentMax: "当前 / 最高",
|
||||
cloud: "云量",
|
||||
wind: "风速",
|
||||
topProb: "结算概率Top3",
|
||||
noProb: "暂无概率数据",
|
||||
aiSummary: "AI 分析",
|
||||
noAnalysis: "暂无分析",
|
||||
refresh: "刷新",
|
||||
fahrenheit: "华氏",
|
||||
celsius: "摄氏",
|
||||
high: "高",
|
||||
medium: "中",
|
||||
low: "低",
|
||||
loadCitiesFailed: "城市列表加载失败",
|
||||
loadCityDetailFailed: "城市详情加载失败",
|
||||
backendConfigMissing: "服务端未配置 POLYWEATHER_API_BASE_URL。",
|
||||
},
|
||||
} as const;
|
||||
Reference in New Issue
Block a user