218 lines
9.2 KiB
TypeScript
218 lines
9.2 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useMemo } from "react";
|
||
import { useTranslation } from "@/i18n/navigation";
|
||
import { Link } from "@/i18n/navigation";
|
||
import { cities } from "@/app/data/cities";
|
||
import CityCard from "@/app/components/CityCard";
|
||
import CityModal from "@/app/components/CityModal";
|
||
import Footer from "@/app/components/Footer";
|
||
import type { City } from "@/app/data/cities";
|
||
|
||
const TAGS = ["远程工作者", "创业者", "设计师", "开发者", "内容创作者", "带宠物"];
|
||
const TIMEZONES = ["UTC+8 中国", "UTC+7 东南亚", "UTC+1 欧洲", "UTC-5 美东", "UTC-8 美西", "任意"];
|
||
const VISA_OPTIONS = ["免签优先", "落地签可", "需提前办", "任意"];
|
||
|
||
export default function DashboardPage() {
|
||
const { t } = useTranslation("dashboard");
|
||
const [budget, setBudget] = useState(5000);
|
||
const [internet, setInternet] = useState(50);
|
||
const [timezone, setTimezone] = useState("UTC+8 中国");
|
||
const [visa, setVisa] = useState("任意");
|
||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||
const [cityModal, setCityModal] = useState<City | null>(null);
|
||
|
||
const filteredCities = useMemo(() => {
|
||
return [...cities]
|
||
.filter((c) => c.costPerMonth <= budget * 1.2 && c.internetSpeed >= internet)
|
||
.sort((a, b) => b.overallScore - a.overallScore)
|
||
.slice(0, 12);
|
||
}, [budget, internet]);
|
||
|
||
const toggleTag = (tag: string) => {
|
||
setSelectedTags((prev) =>
|
||
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
|
||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||
<div className="mb-8">
|
||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||
{t("title")}
|
||
</h1>
|
||
<p className="mt-1 text-gray-500 dark:text-gray-400">
|
||
{t("subtitle")}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||
<aside className="lg:col-span-1 space-y-6">
|
||
<div className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 sm:p-6">
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
{t("budget")}
|
||
</label>
|
||
<input
|
||
type="range"
|
||
min="2000"
|
||
max="15000"
|
||
step="500"
|
||
value={budget}
|
||
onChange={(e) => setBudget(Number(e.target.value))}
|
||
className="w-full accent-[#ff4d4f]"
|
||
/>
|
||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||
¥{budget.toLocaleString()}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 sm:p-6">
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
{t("timezone")}
|
||
</label>
|
||
<select
|
||
value={timezone}
|
||
onChange={(e) => setTimezone(e.target.value)}
|
||
className="w-full rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||
>
|
||
{TIMEZONES.map((z) => (
|
||
<option key={z} value={z}>{z}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 sm:p-6">
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
{t("visa")}
|
||
</label>
|
||
<select
|
||
value={visa}
|
||
onChange={(e) => setVisa(e.target.value)}
|
||
className="w-full rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||
>
|
||
{VISA_OPTIONS.map((v) => (
|
||
<option key={v} value={v}>{v}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 sm:p-6">
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
{t("internet")}
|
||
</label>
|
||
<input
|
||
type="range"
|
||
min="30"
|
||
max="150"
|
||
step="10"
|
||
value={internet}
|
||
onChange={(e) => setInternet(Number(e.target.value))}
|
||
className="w-full accent-[#ff4d4f]"
|
||
/>
|
||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||
{internet} Mbps
|
||
</p>
|
||
</div>
|
||
|
||
<div className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 sm:p-6">
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||
{t("tags")}
|
||
</label>
|
||
<div className="flex flex-wrap gap-2">
|
||
{TAGS.map((tag) => (
|
||
<button
|
||
key={tag}
|
||
onClick={() => toggleTag(tag)}
|
||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
||
selectedTags.includes(tag)
|
||
? "bg-[#ff4d4f] text-white"
|
||
: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
|
||
}`}
|
||
>
|
||
{tag}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-2xl bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-950/30 dark:to-orange-950/30 border border-amber-200 dark:border-amber-800/50 p-4">
|
||
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||
🤖 {t("aiTip")}
|
||
</p>
|
||
<p className="mt-1 text-xs text-amber-700 dark:text-amber-300">
|
||
你和 80% 大理用户重合度高,推荐 3 月去清迈…
|
||
</p>
|
||
</div>
|
||
</aside>
|
||
|
||
<main className="lg:col-span-2">
|
||
<div className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 sm:p-6 mb-6">
|
||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||
{t("search")}
|
||
</h2>
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3" style={{ gridAutoRows: "160px" }}>
|
||
{filteredCities.map((city, i) => (
|
||
<div key={city.id} className="h-full min-h-[140px]">
|
||
<CityCard
|
||
city={city}
|
||
rank={i + 1}
|
||
onClick={() => setCityModal(city)}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{filteredCities.length === 0 && (
|
||
<p className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||
{t("noMatch")}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<div className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 h-40 flex items-center justify-center">
|
||
<span className="text-gray-400 dark:text-gray-500 text-sm">
|
||
{t("costCurve")} {t("devSuffix")}
|
||
</span>
|
||
</div>
|
||
<div className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 h-40 flex items-center justify-center">
|
||
<span className="text-gray-400 dark:text-gray-500 text-sm">
|
||
{t("nomadHeatmap")} {t("devSuffix")}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-6 rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4">
|
||
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||
{t("nearbyEvents")}
|
||
</h3>
|
||
<div className="flex gap-2 overflow-x-auto pb-2">
|
||
{[
|
||
{ city: "大理", date: "3月9日", emoji: "🏯" },
|
||
{ city: "成都", date: "3月12日", emoji: "🐼" },
|
||
{ city: "深圳", date: "3月14日", emoji: "🌃" },
|
||
].map((e) => (
|
||
<Link
|
||
key={e.city}
|
||
href="/meetups"
|
||
className="shrink-0 px-4 py-2 rounded-xl bg-gray-100 dark:bg-gray-800 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700"
|
||
>
|
||
{e.emoji} {e.city} {e.date}
|
||
</Link>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
</div>
|
||
|
||
<CityModal
|
||
city={cityModal}
|
||
isOpen={!!cityModal}
|
||
onClose={() => setCityModal(null)}
|
||
/>
|
||
<Footer />
|
||
</div>
|
||
);
|
||
}
|