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";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
const API_BASE =
|
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||||
process.env.POLYWEATHER_API_BASE_URL || "http://127.0.0.1:8000";
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
if (!API_BASE) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/api/cities`, {
|
const res = await fetch(`${API_BASE}/api/cities`, {
|
||||||
headers: { Accept: "application/json" },
|
headers: { Accept: "application/json" },
|
||||||
next: { revalidate: 120 },
|
next: { revalidate: 120 },
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
const raw = await res.text();
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `Backend returned ${res.status}` },
|
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
||||||
{ status: 502 },
|
{ status: 502 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
const API_BASE =
|
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||||
process.env.POLYWEATHER_API_BASE_URL || "http://127.0.0.1:8000";
|
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
context: { params: Promise<{ name: string }> },
|
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 { name } = await context.params;
|
||||||
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
|
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
|
||||||
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}?force_refresh=${forceRefresh}`;
|
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}?force_refresh=${forceRefresh}`;
|
||||||
@@ -17,8 +23,9 @@ export async function GET(
|
|||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
const raw = await res.text();
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `Backend returned ${res.status}` },
|
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
||||||
{ status: 502 },
|
{ status: 502 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,17 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: 222 47% 6%;
|
--background: 223 53% 4%;
|
||||||
--foreground: 210 40% 98%;
|
--foreground: 210 40% 98%;
|
||||||
--card: 222 47% 9%;
|
--card: 223 46% 8%;
|
||||||
--card-foreground: 210 40% 98%;
|
--card-foreground: 210 40% 98%;
|
||||||
--primary: 190 95% 52%;
|
--primary: 190 95% 56%;
|
||||||
--primary-foreground: 222 47% 8%;
|
--primary-foreground: 222 47% 8%;
|
||||||
--secondary: 223 36% 16%;
|
--secondary: 224 30% 14%;
|
||||||
--secondary-foreground: 210 40% 98%;
|
--secondary-foreground: 210 40% 98%;
|
||||||
--accent: 217 33% 18%;
|
--accent: 217 30% 18%;
|
||||||
--accent-foreground: 210 40% 98%;
|
--accent-foreground: 210 40% 98%;
|
||||||
--border: 220 33% 20%;
|
--border: 221 38% 22%;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -21,9 +21,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
font-family: "Sora", "Avenir Next", "Segoe UI", sans-serif;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 15% 5%, rgba(6, 78, 99, 0.2), transparent 45%),
|
radial-gradient(circle at 10% -10%, rgba(34, 211, 238, 0.2), transparent 40%),
|
||||||
radial-gradient(circle at 85% 85%, rgba(30, 41, 59, 0.4), transparent 42%),
|
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));
|
hsl(var(--background));
|
||||||
color: hsl(var(--foreground));
|
color: hsl(var(--foreground));
|
||||||
}
|
}
|
||||||
@@ -33,3 +35,73 @@ body {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
font-family: inherit;
|
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 { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import type { Locale } from "@/lib/i18n";
|
||||||
import type { CityDetail } from "@/lib/types";
|
import type { CityDetail } from "@/lib/types";
|
||||||
|
|
||||||
type CityDetailPanelProps = {
|
type CityDetailPanelProps = {
|
||||||
detail: CityDetail | null;
|
detail: CityDetail | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
onRefresh: () => void;
|
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({
|
export function CityDetailPanel({
|
||||||
detail,
|
detail,
|
||||||
loading,
|
loading,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
|
locale,
|
||||||
|
text,
|
||||||
}: CityDetailPanelProps) {
|
}: CityDetailPanelProps) {
|
||||||
if (!detail) {
|
if (!detail) {
|
||||||
return (
|
return (
|
||||||
<Card className="h-full">
|
<Card className="h-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>City Detail</CardTitle>
|
<CardTitle>{text.cityDetail}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="text-sm text-slate-400">
|
<CardContent className="text-sm text-slate-400">
|
||||||
Select a city from the left list or map marker.
|
{text.selectCityHint}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -36,12 +52,20 @@ export function CityDetailPanel({
|
|||||||
detail.current?.max_so_far != null
|
detail.current?.max_so_far != null
|
||||||
? detail.current.max_so_far
|
? detail.current.max_so_far
|
||||||
: detail.current?.temp;
|
: detail.current?.temp;
|
||||||
|
const topProbabilities = (detail.probabilities?.distribution ?? [])
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((item) => ({
|
||||||
|
value: item.value,
|
||||||
|
pct: Math.round(item.probability * 100),
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="h-full">
|
<Card className="glass h-full fade-up">
|
||||||
<CardHeader className="space-y-3">
|
<CardHeader className="space-y-3">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<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}
|
{detail.display_name}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Button
|
<Button
|
||||||
@@ -55,7 +79,7 @@ export function CityDetailPanel({
|
|||||||
) : (
|
) : (
|
||||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
Refresh
|
{text.refresh}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -66,33 +90,33 @@ export function CityDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4 text-sm">
|
<CardContent className="space-y-4 text-sm">
|
||||||
<div className="rounded-lg border border-slate-800 bg-slate-900/70 p-3">
|
<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-400">
|
<div className="mb-1 flex items-center gap-2 text-slate-300">
|
||||||
<Thermometer className="h-4 w-4" />
|
<Thermometer className="h-4 w-4" />
|
||||||
<span>Current / Max</span>
|
<span>{text.currentMax}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-semibold text-white">
|
<div className="text-3xl font-semibold text-white">
|
||||||
{displayTemp ?? "--"}
|
{displayTemp ?? "--"}
|
||||||
{symbol}
|
{symbol}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-slate-400">
|
<div className="mt-1 text-xs text-slate-300">
|
||||||
DEB: {detail.deb?.prediction ?? "--"}
|
DEB: {detail.deb?.prediction ?? "--"}
|
||||||
{symbol}
|
{symbol}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<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">
|
<div className="mb-1 flex items-center gap-2 text-slate-400">
|
||||||
<CloudSun className="h-4 w-4" />
|
<CloudSun className="h-4 w-4" />
|
||||||
<span>Cloud</span>
|
<span>{text.cloud}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="font-medium">{detail.current?.cloud_desc || "N/A"}</p>
|
<p className="font-medium">{detail.current?.cloud_desc || "N/A"}</p>
|
||||||
</div>
|
</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">
|
<div className="mb-1 flex items-center gap-2 text-slate-400">
|
||||||
<Gauge className="h-4 w-4" />
|
<Gauge className="h-4 w-4" />
|
||||||
<span>Wind</span>
|
<span>{text.wind}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{detail.current?.wind_speed_kt != null
|
{detail.current?.wind_speed_kt != null
|
||||||
@@ -102,12 +126,39 @@ export function CityDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
||||||
<p className="mb-2 text-slate-400">AI Summary</p>
|
<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
|
<div
|
||||||
className="max-h-44 overflow-y-auto leading-6 text-slate-200"
|
className="max-h-44 overflow-y-auto leading-6 text-slate-200"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: detail.ai_analysis || "No analysis yet.",
|
__html: detail.ai_analysis || text.noAnalysis,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ThermometerSun } from "lucide-react";
|
import { AlertTriangle, GaugeCircle, ShieldCheck, ThermometerSun } from "lucide-react";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import type { Locale } from "@/lib/i18n";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CitySummary } from "@/lib/types";
|
import type { CitySummary } from "@/lib/types";
|
||||||
|
|
||||||
@@ -11,6 +12,15 @@ type CityListProps = {
|
|||||||
cities: CitySummary[];
|
cities: CitySummary[];
|
||||||
selectedCity: string | null;
|
selectedCity: string | null;
|
||||||
onSelectCity: (cityName: string) => void;
|
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" {
|
function riskVariant(level: CitySummary["risk_level"]): "success" | "warning" | "danger" {
|
||||||
@@ -19,37 +29,76 @@ function riskVariant(level: CitySummary["risk_level"]): "success" | "warning" |
|
|||||||
return "success";
|
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 (
|
return (
|
||||||
<Card className="h-full">
|
<Card className="glass h-full overflow-hidden">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="space-y-3 pb-3">
|
||||||
<CardTitle className="flex items-center justify-between text-sm uppercase tracking-wider text-slate-300">
|
<CardTitle className="flex items-center justify-between text-xs uppercase tracking-[0.2em] text-slate-300">
|
||||||
<span>Monitored Cities</span>
|
<span className={locale === "zh" ? "tracking-[0.08em]" : ""}>
|
||||||
|
{text.monitoredCities}
|
||||||
|
</span>
|
||||||
<Badge variant="default">{cities.length}</Badge>
|
<Badge variant="default">{cities.length}</Badge>
|
||||||
</CardTitle>
|
</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>
|
</CardHeader>
|
||||||
<CardContent className="h-[calc(100%-64px)] overflow-y-auto">
|
<CardContent className="h-[calc(100%-128px)] overflow-y-auto pb-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
{cities.map((city) => (
|
{cities.map((city) => (
|
||||||
<button
|
<button
|
||||||
key={city.name}
|
key={city.name}
|
||||||
onClick={() => onSelectCity(city.name)}
|
onClick={() => onSelectCity(city.name)}
|
||||||
className={cn(
|
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
|
selectedCity === city.name
|
||||||
? "border-cyan-500 bg-cyan-950/40"
|
? "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 bg-slate-900/70 hover:bg-slate-800/80",
|
: "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="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}
|
{city.display_name}
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={riskVariant(city.risk_level)}>{city.risk_level}</Badge>
|
<Badge variant={riskVariant(city.risk_level)}>{city.risk_level}</Badge>
|
||||||
</div>
|
</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" />
|
<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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -11,20 +11,19 @@ type MapCanvasProps = {
|
|||||||
onSelectCity: (cityName: string) => void;
|
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({
|
L.divIcon({
|
||||||
className: "",
|
className: "",
|
||||||
html: `<div style="
|
html: `<div class="map-pill ${city.risk_level} ${selected ? "active" : ""}">${shortName(city.display_name)}</div>`,
|
||||||
background:${risk === "high" ? "#ef4444" : risk === "medium" ? "#f59e0b" : "#10b981"};
|
iconSize: [78, 30],
|
||||||
color:#fff;
|
iconAnchor: [39, 15],
|
||||||
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) {
|
export function MapCanvas({ cities, selectedCity, onSelectCity }: MapCanvasProps) {
|
||||||
@@ -38,7 +37,7 @@ export function MapCanvas({ cities, selectedCity, onSelectCity }: MapCanvasProps
|
|||||||
<Marker
|
<Marker
|
||||||
key={city.name}
|
key={city.name}
|
||||||
position={[city.lat, city.lon]}
|
position={[city.lat, city.lon]}
|
||||||
icon={tempIcon(city.risk_level)}
|
icon={tempIcon(city, selectedCity === city.name)}
|
||||||
eventHandlers={{ click: () => onSelectCity(city.name) }}
|
eventHandlers={{ click: () => onSelectCity(city.name) }}
|
||||||
>
|
>
|
||||||
<Popup>
|
<Popup>
|
||||||
|
|||||||
@@ -1,21 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { CityDetailPanel } from "@/components/city-detail-panel";
|
import { CityDetailPanel } from "@/components/city-detail-panel";
|
||||||
import { CityList } from "@/components/city-list";
|
import { CityList } from "@/components/city-list";
|
||||||
import { MapView } from "@/components/map-view";
|
import { MapView } from "@/components/map-view";
|
||||||
import { getCities, getCityDetail } from "@/lib/api";
|
import { getCities, getCityDetail } from "@/lib/api";
|
||||||
|
import { copy, type Locale } from "@/lib/i18n";
|
||||||
import type { CityDetail, CitySummary } from "@/lib/types";
|
import type { CityDetail, CitySummary } from "@/lib/types";
|
||||||
|
|
||||||
export function PolyWeatherDashboard() {
|
export function PolyWeatherDashboard() {
|
||||||
|
const [locale, setLocale] = useState<Locale>("zh");
|
||||||
const [cities, setCities] = useState<CitySummary[]>([]);
|
const [cities, setCities] = useState<CitySummary[]>([]);
|
||||||
const [selectedCity, setSelectedCity] = useState<string | null>(null);
|
const [selectedCity, setSelectedCity] = useState<string | null>(null);
|
||||||
const [selectedDetail, setSelectedDetail] = useState<CityDetail | null>(null);
|
const [selectedDetail, setSelectedDetail] = useState<CityDetail | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const t = copy[locale];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
@@ -64,6 +75,14 @@ export function PolyWeatherDashboard() {
|
|||||||
);
|
);
|
||||||
}, [cities]);
|
}, [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() {
|
async function refreshCurrentCity() {
|
||||||
if (!selectedCity) return;
|
if (!selectedCity) return;
|
||||||
setLoading(true);
|
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 (
|
return (
|
||||||
<main className="h-screen w-full p-3 md:p-4">
|
<main className="h-screen w-full overflow-hidden p-2 md:p-4">
|
||||||
<div className="grid h-full grid-cols-1 gap-3 md:grid-cols-[280px_1fr_360px]">
|
<div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[290px_1fr_370px]">
|
||||||
<section className="min-h-0">
|
<section className="min-h-0 lg:block">
|
||||||
<CityList
|
<CityList
|
||||||
cities={orderedCities}
|
cities={orderedCities}
|
||||||
selectedCity={selectedCity}
|
selectedCity={selectedCity}
|
||||||
onSelectCity={setSelectedCity}
|
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>
|
||||||
|
|
||||||
<section className="relative min-h-0">
|
<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="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">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<Globe2 className="h-4 w-4 text-cyan-300" />
|
<Globe2 className="h-4 w-4 shrink-0 text-cyan-300" />
|
||||||
<h1 className="text-lg font-semibold uppercase tracking-wide text-cyan-100 md:text-xl">
|
<h1 className="shrink-0 text-base font-semibold uppercase tracking-[0.16em] text-cyan-100 md:text-lg">
|
||||||
PolyWeather
|
PolyWeather
|
||||||
</h1>
|
</h1>
|
||||||
<span className="hidden text-xs text-slate-400 md:inline">
|
<span className="hidden truncate text-xs text-slate-400 xl:inline">
|
||||||
Global Weather Risk Intelligence
|
{t.brandSubtitle}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" variant="secondary">
|
<div className="flex items-center gap-2">
|
||||||
<Bot className="mr-1 h-4 w-4" />
|
<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">
|
||||||
Technical Guide
|
<Signal className="h-3 w-3" />
|
||||||
</Button>
|
{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>
|
</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
|
<MapView
|
||||||
cities={orderedCities}
|
cities={orderedCities}
|
||||||
selectedCity={selectedCity}
|
selectedCity={selectedCity}
|
||||||
@@ -113,11 +191,45 @@ export function PolyWeatherDashboard() {
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="min-h-0">
|
<section className="min-h-0 hidden lg:block">
|
||||||
<CityDetailPanel
|
<CityDetailPanel
|
||||||
detail={selectedDetail}
|
detail={selectedDetail}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onRefresh={refreshCurrentCity}
|
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>
|
</section>
|
||||||
</div>
|
</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="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">
|
<div className="flex items-center gap-2">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<span>{error}</span>
|
<span>{localizeError(error)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
+15
-2
@@ -1,9 +1,20 @@
|
|||||||
import type { CityDetail, CitySummary } from "@/lib/types";
|
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[]> {
|
export async function getCities(): Promise<CitySummary[]> {
|
||||||
const res = await fetch("/api/cities", { cache: "no-store" });
|
const res = await fetch("/api/cities", { cache: "no-store" });
|
||||||
if (!res.ok) {
|
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();
|
const data = await res.json();
|
||||||
return data.cities ?? [];
|
return data.cities ?? [];
|
||||||
@@ -21,7 +32,9 @@ export async function getCityDetail(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
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();
|
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