118 lines
4.9 KiB
TypeScript
118 lines
4.9 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState, useMemo } from "react";
|
||
import { useLocale, useTranslation } from "@/i18n/navigation";
|
||
import { useTheme } from "@/app/context/ThemeContext";
|
||
import { MembersMap } from "@/app/lib/charts";
|
||
import { MAP_CITIES } from "@/app/data/map-cities";
|
||
import { apiFetch } from "@/app/lib/api-client";
|
||
|
||
export default function MembersMapPage() {
|
||
const { t } = useTranslation("membersMap");
|
||
const locale = useLocale();
|
||
const { theme } = useTheme();
|
||
const [mapCities, setMapCities] = useState(MAP_CITIES);
|
||
const [hoveredCity, setHoveredCity] = useState<typeof MAP_CITIES[0] | null>(null);
|
||
|
||
useEffect(() => {
|
||
apiFetch<{ items: typeof MAP_CITIES }>("/api/stats/member-map")
|
||
.then((data) => data.items?.length && setMapCities(data.items))
|
||
.catch(() => setMapCities(MAP_CITIES));
|
||
}, []);
|
||
|
||
const totalMembers = useMemo(
|
||
() => mapCities.reduce((sum, c) => sum + c.nomadsNow, 0),
|
||
[mapCities]
|
||
);
|
||
|
||
const sortedCities = useMemo(
|
||
() => [...mapCities].sort((a, b) => b.nomadsNow - a.nomadsNow),
|
||
[mapCities]
|
||
);
|
||
|
||
return (
|
||
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
|
||
<main className="max-w-[1200px] mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||
<header className="mb-8">
|
||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||
<span className="w-1 h-8 bg-[#ff4d4f] rounded-full" />
|
||
{t("title")}
|
||
</h1>
|
||
<p className="text-gray-500 dark:text-gray-400 mt-1 text-sm sm:text-base">
|
||
{t("subtitle")}
|
||
</p>
|
||
</header>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 lg:gap-8">
|
||
<div className="lg:col-span-2 bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-800 overflow-hidden p-4 sm:p-6">
|
||
<div className="rounded-xl overflow-hidden bg-gray-50 dark:bg-gray-800/50">
|
||
<MembersMap
|
||
data={mapCities}
|
||
locale={locale}
|
||
dark={theme === "dark"}
|
||
className="min-h-[400px]"
|
||
onCityHover={setHoveredCity}
|
||
/>
|
||
</div>
|
||
{hoveredCity && (
|
||
<div className="mt-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||
{locale === "zh" ? hoveredCity.name : hoveredCity.nameEn} {hoveredCity.emoji}
|
||
</p>
|
||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||
{hoveredCity.nomadsNow} {t("nomadsInCity")}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-6">
|
||
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-800 p-6">
|
||
<h3 className="text-sm font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">
|
||
{t("totalMembers")}
|
||
</h3>
|
||
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||
{totalMembers.toLocaleString()}+
|
||
</p>
|
||
</div>
|
||
|
||
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-800 p-6">
|
||
<h3 className="text-sm font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-4">
|
||
{t("cities")}
|
||
</h3>
|
||
<ul className="space-y-2 max-h-[280px] overflow-y-auto">
|
||
{sortedCities.map((city) => (
|
||
<li
|
||
key={city.id}
|
||
className={`flex items-center justify-between py-2 px-3 rounded-lg cursor-pointer transition-colors ${
|
||
hoveredCity?.id === city.id
|
||
? "bg-[#ff4d4f]/10 text-[#ff4d4f]"
|
||
: "hover:bg-gray-50 dark:hover:bg-gray-800"
|
||
}`}
|
||
onMouseEnter={() => setHoveredCity(city)}
|
||
onMouseLeave={() => setHoveredCity(null)}
|
||
>
|
||
<span className="flex items-center gap-2">
|
||
<span>{city.emoji}</span>
|
||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||
{locale === "zh" ? city.name : city.nameEn}
|
||
</span>
|
||
</span>
|
||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||
{city.nomadsNow}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-6 text-center">
|
||
数据来自本地 PocketBase 城市集合,按城市成员数匿名聚合。
|
||
</p>
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|