Files
2026-06-08 06:05:31 -05:00

216 lines
7.1 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { memo, useState } from "react";
import { City } from "../data/cities";
import { getCitySocialSummary } from "../data/city-social";
import { useLocale, useTranslation } from "@/i18n/navigation";
import { type CityWeatherLive, weatherCodeLabel } from "@/app/lib/city-weather";
function ratingToPercent(rating: string): number {
const m: Record<string, number> = {
极好: 95,
很好: 80,
: 60,
一般: 40,
: 20,
};
return m[rating] || 50;
}
function getBarColor(p: number): string {
if (p >= 60) return "#22c55e";
if (p >= 35) return "#eab308";
return "#ef4444";
}
interface ScoreBarProps {
icon: string;
label: string;
percent: number;
animate: boolean;
}
const ScoreBar = memo(function ScoreBar({ icon, label, percent, animate }: ScoreBarProps) {
return (
<div className="flex items-center gap-2 sm:gap-3">
<span className="text-white/80 text-xs sm:text-sm w-14 sm:w-16 shrink-0 flex items-center gap-1">
<span>{icon}</span>
<span className="hidden sm:inline">{label}</span>
</span>
<div className="flex-1 h-2 sm:h-2.5 bg-white/10 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-700 ease-out"
style={{
width: animate ? `${percent}%` : "0%",
backgroundColor: getBarColor(percent),
}}
/>
</div>
</div>
);
});
interface CityCardProps {
city: City;
rank: number;
onClick?: () => void;
liveWeather?: CityWeatherLive | null;
}
function CityCardComponent({ city, rank, onClick, liveWeather }: CityCardProps) {
const [hovered, setHovered] = useState(false);
const locale = useLocale();
const { t } = useTranslation("cityCard");
const socialSummary = getCitySocialSummary(city);
const temperature = liveWeather?.temperature ?? city.temperature;
const weatherLabel = weatherCodeLabel(liveWeather?.weatherCode, locale === "zh" ? "zh" : "en");
const scores = [
{
icon: "⭐",
label: "综合",
percent: (city.overallScore / 5) * 100,
},
{
icon: "👥",
label: "游民",
percent: Math.min(95, (city.nomadsNow / 1200) * 100),
},
{
icon: "🌡",
label: "天气",
percent: Math.max(20, Math.min(95, 100 - Math.abs(temperature - 22) * 4)),
},
{
icon: "👍",
label: "好评",
percent: ratingToPercent(city.liked),
},
{
icon: "👮",
label: "安全",
percent: ratingToPercent(city.safety),
},
];
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => e.key === "Enter" && onClick?.()}
className="city-card cursor-pointer"
style={{
background: `linear-gradient(135deg, ${city.gradientFrom}, ${city.gradientTo})`,
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{/* Icon watermark */}
<span className="absolute bottom-8 right-2 text-[70px] sm:text-[90px] opacity-[0.12] select-none pointer-events-none leading-none">
{city.icon}
</span>
{/* ===== Default state ===== */}
<div
className={`absolute inset-0 z-10 flex flex-col justify-between p-3 sm:p-4 transition-opacity duration-300 ${hovered ? "opacity-0" : "opacity-100"}`}
>
{/* Top row */}
<div className="flex items-start justify-between">
<span className="text-white/70 text-base sm:text-xl font-bold">
{rank}
</span>
<span
className="text-[10px] sm:text-xs bg-white/20 backdrop-blur-sm rounded-full px-1.5 sm:px-2 py-0.5 sm:py-1 text-white flex items-center gap-0.5"
title={liveWeather ? (weatherLabel ? `${weatherLabel} · 实时` : "实时天气") : undefined}
>
🌡 {temperature}°C
{weatherLabel ? (
<span className="hidden sm:inline opacity-80">· {weatherLabel}</span>
) : null}
</span>
</div>
{/* Center: city name */}
<div className="text-center -mt-2">
<h3 className="text-xl sm:text-[28px] font-bold text-white drop-shadow-md tracking-wide">
{city.name}
</h3>
</div>
{/* Bottom: temp + cost + nomads */}
<div>
<div className="mb-1.5 grid grid-cols-3 gap-1">
<span className="truncate rounded-full bg-white/20 px-1.5 py-0.5 text-center text-[9px] text-white/90 backdrop-blur-sm sm:text-[10px]">
{socialSummary.coffeechat}
</span>
<span className="truncate rounded-full bg-white/20 px-1.5 py-0.5 text-center text-[9px] text-white/90 backdrop-blur-sm sm:text-[10px]">
🧭 {socialSummary.localGuide}
</span>
<span className="truncate rounded-full bg-white/20 px-1.5 py-0.5 text-center text-[9px] text-white/90 backdrop-blur-sm sm:text-[10px]">
🥾 {socialSummary.cityWalk}
</span>
</div>
<div className="flex items-end justify-between text-white text-xs sm:text-sm">
<span className="text-white/80 text-[10px] sm:text-xs line-clamp-1 max-w-[60%]">
{city.description}
</span>
<span className="font-semibold shrink-0">
👥 {city.nomadsNow.toLocaleString()}{t("nomadsHere")}
</span>
</div>
</div>
</div>
{/* ===== Hover overlay (desktop only) ===== */}
<div
className={`absolute inset-0 z-20 bg-[#2a2a2a]/95 hidden sm:flex flex-col justify-between p-4 sm:p-5 transition-opacity duration-300 ${hovered ? "opacity-100" : "opacity-0 pointer-events-none"}`}
>
{/* Top icons */}
<div className="flex items-center justify-between">
<button className="text-white/50 hover:text-red-400 transition-colors text-base">
</button>
<button
className="text-white/30 hover:text-white text-sm transition-colors"
onClick={(e) => {
e.stopPropagation();
setHovered(false);
}}
type="button"
>
</button>
</div>
{/* Score bars */}
<div className="space-y-2.5 sm:space-y-3">
{scores.map((s) => (
<ScoreBar
key={s.label}
icon={s.icon}
label={s.label}
percent={s.percent}
animate={hovered}
/>
))}
</div>
{/* Bottom: city info small */}
<div className="space-y-2 text-center text-xs">
<div className="grid grid-cols-3 gap-1 text-white/80">
<span className="rounded-full bg-white/10 px-2 py-1"> {socialSummary.coffeechat}</span>
<span className="rounded-full bg-white/10 px-2 py-1">🧭 {socialSummary.localGuide}</span>
<span className="rounded-full bg-white/10 px-2 py-1">🥾 {socialSummary.cityWalk}</span>
</div>
<div className="text-white/40">
{city.name}
</div>
</div>
</div>
</div>
);
}
export default memo(CityCardComponent);