'城市详情'
This commit is contained in:
179
app/components/AuthModal.tsx
Normal file
179
app/components/AuthModal.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, type FormEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
pbLogin,
|
||||
pbRegister,
|
||||
pbSaveAuth,
|
||||
} from "@/app/lib/pocketbase";
|
||||
import { useTranslation } from "@/i18n/navigation";
|
||||
|
||||
interface AuthModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: (email: string) => void;
|
||||
}
|
||||
|
||||
export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps) {
|
||||
const { t } = useTranslation("auth");
|
||||
const [mode, setMode] = useState<"login" | "register">("login");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordConfirm, setPasswordConfirm] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
let result;
|
||||
if (mode === "register") {
|
||||
if (password !== passwordConfirm) {
|
||||
setError(t("passwordMismatch"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
setError(t("passwordMinLength"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
result = await pbRegister(email, password, passwordConfirm);
|
||||
} else {
|
||||
result = await pbLogin(email, password);
|
||||
}
|
||||
pbSaveAuth(result.token, result.record);
|
||||
onSuccess(email);
|
||||
onClose();
|
||||
setEmail("");
|
||||
setPassword("");
|
||||
setPasswordConfirm("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "操作失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
if (!isOpen || !mounted) return null;
|
||||
|
||||
const modalContent = (
|
||||
<div className="fixed inset-0 z-[9999] overflow-y-auto">
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50"
|
||||
onClick={onClose}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="flex min-h-svh items-center justify-center p-4 py-8">
|
||||
<div className="relative w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900 dark:border dark:border-gray-700 shrink-0">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
aria-label={t("close")}
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<h2 className="text-xl font-bold text-gray-800 dark:text-gray-100">
|
||||
{mode === "login" ? t("login") : t("register")}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{mode === "login" ? t("loginHint") : t("registerHint")}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">{t("email")}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
className="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-gray-800 placeholder-gray-400 focus:border-[#ff4d4f] focus:outline-none focus:ring-1 focus:ring-[#ff4d4f] dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">{t("password")}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={mode === "register" ? "至少 8 位" : "••••••••"}
|
||||
required
|
||||
minLength={mode === "register" ? 8 : 1}
|
||||
className="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-gray-800 placeholder-gray-400 focus:border-[#ff4d4f] focus:outline-none focus:ring-1 focus:ring-[#ff4d4f] dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
{mode === "register" && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">{t("passwordConfirm")}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passwordConfirm}
|
||||
onChange={(e) => setPasswordConfirm(e.target.value)}
|
||||
placeholder="再次输入密码"
|
||||
required
|
||||
className="w-full rounded-lg border border-gray-200 px-4 py-2.5 text-gray-800 placeholder-gray-400 focus:border-[#ff4d4f] focus:outline-none focus:ring-1 focus:ring-[#ff4d4f] dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600 dark:bg-red-900/30 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg bg-[#ff4d4f] py-3 font-semibold text-white transition-colors hover:bg-[#ff3333] disabled:opacity-70"
|
||||
>
|
||||
{loading ? "处理中…" : mode === "login" ? t("login") : t("register")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{mode === "login" ? (
|
||||
<>
|
||||
{t("noAccount")}{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode("register");
|
||||
setError(null);
|
||||
}}
|
||||
className="font-medium text-[#ff4d4f] hover:text-[#ff7a45]"
|
||||
>
|
||||
{t("signUpNow")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t("hasAccount")}{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode("login");
|
||||
setError(null);
|
||||
}}
|
||||
className="font-medium text-[#ff4d4f] hover:text-[#ff7a45]"
|
||||
>
|
||||
{t("goLogin")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(modalContent, document.body);
|
||||
}
|
||||
@@ -53,9 +53,11 @@ function ScoreBar({
|
||||
export default function CityCard({
|
||||
city,
|
||||
rank,
|
||||
onClick,
|
||||
}: {
|
||||
city: City;
|
||||
rank: number;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
@@ -89,7 +91,11 @@ export default function CityCard({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="city-card"
|
||||
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})`,
|
||||
}}
|
||||
@@ -161,6 +167,7 @@ export default function CityCard({
|
||||
e.stopPropagation();
|
||||
setHovered(false);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
298
app/components/CityModal.tsx
Normal file
298
app/components/CityModal.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "@/i18n/navigation";
|
||||
import { City } from "@/app/data/cities";
|
||||
|
||||
const CITY_COORDS: Record<string, [number, number]> = {
|
||||
大理: [25.7, 100.2],
|
||||
成都: [30.6, 104.1],
|
||||
深圳: [22.5, 114.1],
|
||||
上海: [31.2, 121.5],
|
||||
杭州: [30.3, 120.2],
|
||||
厦门: [24.5, 118.1],
|
||||
北京: [39.9, 116.4],
|
||||
广州: [23.1, 113.3],
|
||||
重庆: [29.6, 106.6],
|
||||
昆明: [25.0, 102.7],
|
||||
三亚: [18.3, 109.5],
|
||||
丽江: [26.9, 100.2],
|
||||
西安: [34.3, 108.9],
|
||||
南京: [32.1, 118.8],
|
||||
长沙: [28.2, 112.9],
|
||||
青岛: [36.1, 120.4],
|
||||
苏州: [31.3, 120.6],
|
||||
珠海: [22.3, 113.6],
|
||||
};
|
||||
|
||||
const CITY_IMAGES: Record<string, string> = {
|
||||
大理: "https://images.unsplash.com/photo-1590856029826-c7a73142bbf1?w=1200",
|
||||
成都: "https://images.unsplash.com/photo-1547981609-4b6bfe67ca0b?w=1200",
|
||||
深圳: "https://images.unsplash.com/photo-1518837695005-2083093ee35b?w=1200",
|
||||
上海: "https://images.unsplash.com/photo-1542051841857-5f90071e7989?w=1200",
|
||||
杭州: "https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=1200",
|
||||
厦门: "https://images.unsplash.com/photo-1590559899731-a382839e5549?w=1200",
|
||||
北京: "https://images.unsplash.com/photo-1508804185872-d7badad00f7d?w=1200",
|
||||
广州: "https://images.unsplash.com/photo-1596422846543-75c6fc197f07?w=1200",
|
||||
重庆: "https://images.unsplash.com/photo-1563841930606-67e2cce48b78?w=1200",
|
||||
昆明: "https://images.unsplash.com/photo-1589553416260-f586c8f1514f?w=1200",
|
||||
三亚: "https://images.unsplash.com/photo-1590523277543-a94d2e4eb00b?w=1200",
|
||||
丽江: "https://images.unsplash.com/photo-1548013146-72479768bada?w=1200",
|
||||
西安: "https://images.unsplash.com/photo-1590559899731-a382839e5549?w=1200",
|
||||
南京: "https://images.unsplash.com/photo-1589829545856-d10d557cf95f?w=1200",
|
||||
长沙: "https://images.unsplash.com/photo-1589829545856-d10d557cf95f?w=1200",
|
||||
青岛: "https://images.unsplash.com/photo-1552465011-b4e21bf6e79a?w=1200",
|
||||
苏州: "https://images.unsplash.com/photo-1589829545856-d10d557cf95f?w=1200",
|
||||
珠海: "https://images.unsplash.com/photo-1590523277543-a94d2e4eb00b?w=1200",
|
||||
};
|
||||
|
||||
type ScoreLabel = "great" | "good" | "okay" | "bad";
|
||||
|
||||
function scoreToLabel(score: number): ScoreLabel {
|
||||
if (score >= 4.2) return "great";
|
||||
if (score >= 3.5) return "good";
|
||||
if (score >= 2.5) return "okay";
|
||||
return "bad";
|
||||
}
|
||||
|
||||
interface CityModalProps {
|
||||
city: City | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function CityModal({ city, isOpen, onClose }: CityModalProps) {
|
||||
const { t } = useTranslation("cityModal");
|
||||
const [activeTab, setActiveTab] = useState("scores");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [favorited, setFavorited] = useState(false);
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
if (!isOpen || !city || !mounted) return null;
|
||||
|
||||
const coverImage = city.coverImage ?? CITY_IMAGES[city.name] ?? `linear-gradient(135deg, ${city.gradientFrom}, ${city.gradientTo})`;
|
||||
const reviewsCount = city.reviewsCount ?? city.nomadsNow * 4 + Math.floor(Math.random() * 500);
|
||||
const likedCount = city.likedCount ?? Math.floor(reviewsCount * 0.55);
|
||||
const dislikedCount = city.dislikedCount ?? Math.floor(reviewsCount * 0.08);
|
||||
const qualityOfLife = (city.qualityOfLife?.toLowerCase() as ScoreLabel) ?? scoreToLabel(city.overallScore);
|
||||
const familyScore = (city.familyScore?.toLowerCase() as ScoreLabel) ?? scoreToLabel(city.overallScore - 0.2);
|
||||
const communityScore = (city.communityScore?.toLowerCase() as ScoreLabel) ?? scoreToLabel(city.overallScore + 0.1);
|
||||
const nomadPercent = city.nomadPercent ?? Math.min(5, Math.ceil((city.nomadsNow / 500) * 2));
|
||||
const funLabel = (city.funLabel?.toLowerCase() as ScoreLabel) ?? scoreToLabel(city.overallScore);
|
||||
const feelsLike = city.feelsLikeTemp ?? city.temperature + 2;
|
||||
const acPercent = city.acPercent ?? Math.min(95, 60 + city.temperature);
|
||||
const [lat, lng] = city.lat && city.lng ? [city.lat, city.lng] : (CITY_COORDS[city.name] ?? [30, 120]);
|
||||
const region = t("region");
|
||||
const internetInfo = getInternetInfo(city.internetSpeed, t);
|
||||
const tempInfo = getTempInfo(city.temperature, feelsLike, t);
|
||||
const costLabel = getCostLabel(city.costPerMonth, t);
|
||||
const humidityLabel = getHumidityLabel(city.humidity, t);
|
||||
const acLabel = getAcLabel(acPercent, t);
|
||||
|
||||
const tabs = [
|
||||
{ id: "guide", labelKey: "tabs.guide" },
|
||||
{ id: "pros", labelKey: "tabs.pros" },
|
||||
{ id: "reviews", labelKey: "tabs.reviews" },
|
||||
{ id: "cost", labelKey: "tabs.cost" },
|
||||
{ id: "people", labelKey: "tabs.people" },
|
||||
{ id: "chat", labelKey: "tabs.chat" },
|
||||
{ id: "photos", labelKey: "tabs.photos" },
|
||||
{ id: "weather", labelKey: "tabs.weather" },
|
||||
{ id: "trends", labelKey: "tabs.trends" },
|
||||
{ id: "demographics", labelKey: "tabs.demographics" },
|
||||
{ id: "scores", labelKey: "tabs.scores" },
|
||||
];
|
||||
|
||||
const scoresList = [
|
||||
{ icon: "🚩", labelKey: "scores.nomadScore", value: `${Math.round(city.overallScore)}/5 (${t("scores.rank")} #${city.id})`, good: true },
|
||||
{ icon: "❤️", labelKey: "scores.likedByMembers", value: `${likedCount} ${t("scores.likedIt")} ${dislikedCount} ${t("scores.dislikedIt")}`, good: null },
|
||||
{ icon: "⭐", labelKey: "scores.qualityOfLife", value: t(`scores.scoreLabels.${qualityOfLife}`), good: qualityOfLife !== "bad" },
|
||||
{ icon: "👨👩👧", labelKey: "scores.familyScore", value: t(`scores.scoreLabels.${familyScore}`), good: familyScore !== "bad" },
|
||||
{ icon: "👥", labelKey: "scores.communityScore", value: t(`scores.scoreLabels.${communityScore}`), good: communityScore !== "bad" },
|
||||
{ icon: "🏙️", labelKey: "scores.nomadDensity", value: `${nomadPercent}% ${t("scores.nomadHere")}`, good: true },
|
||||
{ icon: "💰", labelKey: "scores.cost", value: `${costLabel}: ¥${city.costPerMonth.toLocaleString()}/${t("scores.perMonth")}`, good: city.costPerMonth <= 6000 },
|
||||
{ icon: "📡", labelKey: "scores.internet", value: internetInfo.text, good: internetInfo.isGood },
|
||||
{ icon: "😊", labelKey: "scores.fun", value: t(`scores.scoreLabels.${funLabel}`), good: funLabel !== "bad" },
|
||||
{ icon: "🌡️", labelKey: "scores.temperature", value: tempInfo.text, good: tempInfo.isGood },
|
||||
{ icon: "💧", labelKey: "scores.humidity", value: `${humidityLabel}: ${city.humidity}%`, good: true },
|
||||
{ icon: "❄️", labelKey: "scores.acPercent", value: `${acLabel}: ${acPercent}%`, good: acPercent >= 70 },
|
||||
];
|
||||
|
||||
const mapUrl = `https://www.openstreetmap.org/export/embed.html?bbox=${lng - 0.2},${lat - 0.15},${lng + 0.2},${lat + 0.15}&layer=mapnik&marker=${lat},${lng}`;
|
||||
|
||||
const modalContent = (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-3 sm:p-4 md:p-6">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className="relative z-10 flex flex-col w-full max-w-5xl max-h-[90vh] rounded-2xl overflow-hidden shadow-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header with cover image */}
|
||||
<div className="relative h-36 sm:h-44 md:h-52 shrink-0">
|
||||
{typeof coverImage === "string" && coverImage.startsWith("http") ? (
|
||||
<img
|
||||
src={coverImage}
|
||||
alt={city.name}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="absolute inset-0 w-full h-full"
|
||||
style={{ background: coverImage as string }}
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/30 to-transparent" />
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-3 top-3 sm:right-4 sm:top-4 w-9 h-9 sm:w-10 sm:h-10 rounded-full bg-white/20 hover:bg-white/30 flex items-center justify-center text-white transition-colors"
|
||||
aria-label={t("close")}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3 sm:p-4 flex flex-wrap items-end justify-between gap-2">
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold text-white drop-shadow-lg">
|
||||
{city.name}
|
||||
</h1>
|
||||
<p className="text-white/90 text-sm mt-0.5">
|
||||
{city.countryEmoji} {city.country}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<span className="text-white/90 text-xs sm:text-sm">
|
||||
{reviewsCount.toLocaleString()} {t("reviews")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setFavorited(!favorited)}
|
||||
className={`rounded-full px-3 py-1.5 sm:px-4 sm:py-2 text-xs sm:text-sm font-medium transition-colors ${
|
||||
favorited
|
||||
? "bg-[#ff4d4f] text-white"
|
||||
: "bg-white/20 text-white hover:bg-white/30"
|
||||
}`}
|
||||
>
|
||||
{favorited ? t("favorited") : t("favorite")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="shrink-0 px-3 sm:px-4 py-2 border-b border-gray-100 dark:border-gray-800">
|
||||
<p className="text-xs sm:text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("breadcrumbPrefix")} > {region} > {city.country} > {city.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="shrink-0 border-b border-gray-100 dark:border-gray-800 overflow-x-auto">
|
||||
<div className="flex gap-1 min-w-max px-3 sm:px-4 py-2">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-2 sm:px-3 py-1.5 sm:py-2 text-xs sm:text-sm font-medium whitespace-nowrap rounded-lg transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "text-[#ff4d4f] border-b-2 border-[#ff4d4f] -mb-[2px]"
|
||||
: "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
|
||||
}`}
|
||||
>
|
||||
{t(tab.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content - scrollable */}
|
||||
<div className="flex-1 overflow-y-auto p-3 sm:p-4 md:p-6">
|
||||
{activeTab === "scores" ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6">
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
{scoresList.map((s) => (
|
||||
<div
|
||||
key={s.labelKey}
|
||||
className="flex items-center gap-2 sm:gap-3 p-2 sm:p-3 rounded-xl bg-gray-50 dark:bg-gray-800/50"
|
||||
>
|
||||
<span className="text-lg sm:text-xl w-7 sm:w-8 shrink-0">{s.icon}</span>
|
||||
<span className="text-xs sm:text-sm font-medium text-gray-700 dark:text-gray-300 flex-1 min-w-0">
|
||||
{t(s.labelKey)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium px-2 py-0.5 sm:px-2.5 sm:py-1 rounded-full shrink-0 ${
|
||||
s.good === null
|
||||
? "bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
: s.good
|
||||
? "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-400"
|
||||
: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
{String(s.value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-xl overflow-hidden border border-gray-200 dark:border-gray-700 h-64 sm:h-72 lg:h-80">
|
||||
<iframe
|
||||
title={`${city.name} ${t("map")}`}
|
||||
src={mapUrl}
|
||||
className="w-full h-full"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-6 sm:py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<p className="text-lg">{city.icon}</p>
|
||||
<p className="mt-2 text-sm sm:text-base">
|
||||
「{tabs.find((x) => x.id === activeTab) ? t(tabs.find((x) => x.id === activeTab)!.labelKey) : activeTab}」{t("comingSoon")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(modalContent, document.body);
|
||||
}
|
||||
|
||||
function getInternetInfo(speed: number, t: (k: string) => string): { text: string; isGood: boolean } {
|
||||
const avg = t("scores.avg");
|
||||
if (speed >= 80) return { text: `${t("scores.fast")}: ${speed} Mbps (${avg})`, isGood: true };
|
||||
if (speed >= 50) return { text: `${t("scores.medium")}: ${speed} Mbps (${avg})`, isGood: false };
|
||||
return { text: `${t("scores.slow")}: ${speed} Mbps (${avg})`, isGood: false };
|
||||
}
|
||||
|
||||
function getTempInfo(temp: number, feels: number, t: (k: string) => string): { text: string; isGood: boolean } {
|
||||
const feelsLike = t("scores.feelsLike");
|
||||
if (temp >= 32 || feels >= 38) return { text: `${t("scores.tooHot")}: ${temp}°C (${feelsLike} ${feels}°C)`, isGood: false };
|
||||
if (temp >= 25) return { text: `${t("scores.warm")}: ${temp}°C (${feelsLike} ${feels}°C)`, isGood: true };
|
||||
if (temp >= 15) return { text: `${t("scores.comfortable")}: ${temp}°C (${feelsLike} ${feels}°C)`, isGood: true };
|
||||
return { text: `${t("scores.cold")}: ${temp}°C (${feelsLike} ${feels}°C)`, isGood: false };
|
||||
}
|
||||
|
||||
function getCostLabel(cost: number, t: (k: string) => string): string {
|
||||
if (cost <= 4000) return t("scores.affordable");
|
||||
if (cost <= 7000) return t("scores.moderate");
|
||||
return t("scores.expensive");
|
||||
}
|
||||
|
||||
function getHumidityLabel(h: number, t: (k: string) => string): string {
|
||||
if (h <= 60) return t("scores.comfy");
|
||||
if (h <= 80) return t("scores.moderate");
|
||||
return t("scores.humid");
|
||||
}
|
||||
|
||||
function getAcLabel(pct: number, t: (k: string) => string): string {
|
||||
if (pct >= 85) return t("scores.veryHigh");
|
||||
if (pct >= 70) return t("scores.high");
|
||||
return t("scores.normal");
|
||||
}
|
||||
97
app/components/Footer.tsx
Normal file
97
app/components/Footer.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { Link, useTranslation } from "@/i18n/navigation";
|
||||
|
||||
const footerSections = [
|
||||
{
|
||||
titleKey: "community" as const,
|
||||
links: [
|
||||
{ labelKey: "join" as const, href: "/join" },
|
||||
{ labelKey: "meetups" as const, href: "/meetups" },
|
||||
{ labelKey: "dating" as const, href: "/dating" },
|
||||
{ labelKey: "map" as const, href: "#" },
|
||||
],
|
||||
},
|
||||
{
|
||||
titleKey: "tools" as const,
|
||||
links: [
|
||||
{ labelKey: "climate" as const, href: "#" },
|
||||
{ labelKey: "cost" as const, href: "#" },
|
||||
{ labelKey: "coworking" as const, href: "#" },
|
||||
{ labelKey: "compare" as const, href: "#" },
|
||||
],
|
||||
},
|
||||
{
|
||||
titleKey: "resources" as const,
|
||||
links: [
|
||||
{ labelKey: "guide" as const, href: "#" },
|
||||
{ labelKey: "cityGuide" as const, href: "#" },
|
||||
{ labelKey: "stories" as const, href: "#" },
|
||||
{ labelKey: "faq" as const, href: "#" },
|
||||
],
|
||||
},
|
||||
{
|
||||
titleKey: "about" as const,
|
||||
links: [
|
||||
{ labelKey: "aboutUs" as const, href: "/about" },
|
||||
{ labelKey: "recruit" as const, href: "/jobs" },
|
||||
{ labelKey: "terms" as const, href: "#" },
|
||||
{ labelKey: "privacy" as const, href: "#" },
|
||||
{ labelKey: "contact" as const, href: "#" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function Footer() {
|
||||
const { t } = useTranslation("footer");
|
||||
|
||||
return (
|
||||
<footer className="border-t border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900">
|
||||
<div className="max-w-[1400px] mx-auto px-5 sm:px-10 py-10 sm:py-12">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 sm:gap-8 mb-8">
|
||||
{footerSections.map((section) => (
|
||||
<div key={section.titleKey}>
|
||||
<h4 className="font-bold text-gray-900 dark:text-gray-100 text-sm mb-3">
|
||||
{t(section.titleKey)}
|
||||
</h4>
|
||||
<ul className="space-y-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{section.links.map((link) => (
|
||||
<li key={link.labelKey}>
|
||||
{link.href.startsWith("/") ? (
|
||||
<Link
|
||||
href={link.href}
|
||||
className="hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
{t(`links.${link.labelKey}`)}
|
||||
</Link>
|
||||
) : (
|
||||
<a
|
||||
href={link.href}
|
||||
className="hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
{t(`links.${link.labelKey}`)}
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="pt-8 border-t border-gray-200 dark:border-gray-800 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">🌏</span>
|
||||
<span className="text-sm font-bold text-gray-900 dark:text-gray-100">游牧中国</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{t("copyright")}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-400 dark:text-gray-500">
|
||||
<span>CNY ¥</span>
|
||||
<span>°C</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
|
||||
const avatarPhotos = [
|
||||
"https://i.pravatar.cc/150?img=32",
|
||||
@@ -37,7 +37,7 @@ export default function HeroSection() {
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-orange-50/70 via-white to-[#fafafa]" />
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-orange-50/70 via-white to-[#fafafa] dark:from-gray-950 dark:via-gray-900 dark:to-gray-950" />
|
||||
<div
|
||||
className="absolute top-0 right-0 w-[500px] h-[500px] -translate-y-1/3 translate-x-1/4 opacity-40 rounded-full"
|
||||
style={{
|
||||
|
||||
@@ -1,19 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link, useLocale, usePathname, useRouter, useTranslation } from "@/i18n/navigation";
|
||||
import { getStoredUserEmail, pbLogout } from "@/app/lib/pocketbase";
|
||||
import AuthModal from "./AuthModal";
|
||||
import ThemeToggle from "./ThemeToggle";
|
||||
|
||||
const navLinks = [
|
||||
{ label: "城市", icon: "🏙️", href: "/" },
|
||||
{ label: "旅行", icon: "✈️", href: "/meetups" },
|
||||
{ label: "社区", icon: "💬", href: "/dating" },
|
||||
{ label: "探索", icon: "🎒", href: "/#community" },
|
||||
{ labelKey: "cities" as const, icon: "🏙️", href: "/" },
|
||||
{ labelKey: "travel" as const, icon: "✈️", href: "/meetups" },
|
||||
{ labelKey: "community" as const, icon: "💬", href: "/dating" },
|
||||
{ labelKey: "explore" as const, icon: "🎒", href: "/#community" },
|
||||
];
|
||||
|
||||
export default function Navbar() {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const { t } = useTranslation("common");
|
||||
const { t: tNav } = useTranslation("nav");
|
||||
const locale = useLocale();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [authOpen, setAuthOpen] = useState(false);
|
||||
const [userEmail, setUserEmail] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setUserEmail(getStoredUserEmail());
|
||||
}, []);
|
||||
|
||||
const handleLogout = () => {
|
||||
pbLogout();
|
||||
setUserEmail(null);
|
||||
};
|
||||
|
||||
function isActive(href: string) {
|
||||
if (href.startsWith("/#")) return false;
|
||||
@@ -22,49 +39,72 @@ export default function Navbar() {
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="sticky top-0 z-50 bg-white/80 backdrop-blur-lg border-b border-gray-100">
|
||||
<nav className="sticky top-0 z-50 bg-white/80 dark:bg-gray-900/80 backdrop-blur-lg border-b border-gray-100 dark:border-gray-800">
|
||||
<div className="max-w-[1600px] mx-auto px-4 sm:px-6 h-16 flex items-center justify-between">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-2xl">🌏</span>
|
||||
<span className="text-lg font-bold text-gray-900 tracking-tight">
|
||||
游牧中国
|
||||
<span className="text-lg font-bold text-gray-900 dark:text-gray-100 tracking-tight">
|
||||
{t("siteName")}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Center Nav */}
|
||||
<div className="hidden md:flex items-center gap-1">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.label}
|
||||
key={link.labelKey}
|
||||
href={link.href}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium transition-colors ${
|
||||
isActive(link.href)
|
||||
? "text-gray-900 bg-gray-100"
|
||||
: "text-gray-500 hover:text-gray-900 hover:bg-gray-50"
|
||||
? "text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-800"
|
||||
: "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
<span>{link.icon}</span>
|
||||
<span>{link.label}</span>
|
||||
<span>{tNav(link.labelKey)}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right Side */}
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<button className="hidden sm:block text-sm text-gray-600 hover:text-gray-900 px-3 py-2 rounded-full hover:bg-gray-50 transition-colors">
|
||||
登录
|
||||
{userEmail ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="hidden sm:block max-w-[120px] truncate text-sm text-gray-600 dark:text-gray-400">
|
||||
{userEmail}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="rounded-full border border-gray-200 dark:border-gray-700 px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
{t("logout")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setAuthOpen(true)}
|
||||
className="hidden sm:block text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 px-3 py-2 rounded-full hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
{t("loginRegister")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.replace(pathname, { locale: locale === "zh" ? "en" : "zh" })}
|
||||
className="inline-flex shrink-0 items-center justify-center rounded-lg p-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
aria-label={locale === "zh" ? "Switch to English" : "切换到中文"}
|
||||
title={locale === "zh" ? "EN" : "中文"}
|
||||
>
|
||||
{locale === "zh" ? "EN" : "中文"}
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
<Link
|
||||
href="/join"
|
||||
className="text-sm bg-[#ff4d4f] text-white px-4 py-2 rounded-full hover:bg-[#ff7a45] transition-colors font-medium"
|
||||
>
|
||||
加入我们 →
|
||||
{t("explore")}
|
||||
</Link>
|
||||
|
||||
{/* Mobile hamburger */}
|
||||
<button
|
||||
className="md:hidden ml-1 p-2 rounded-lg hover:bg-gray-100"
|
||||
className="md:hidden ml-1 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
>
|
||||
{mobileOpen ? (
|
||||
@@ -80,31 +120,53 @@ export default function Navbar() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{mobileOpen && (
|
||||
<div className="md:hidden border-t border-gray-100 bg-white px-4 py-3 space-y-1">
|
||||
<div className="md:hidden border-t border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900 px-4 py-3 space-y-1">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.label}
|
||||
key={link.labelKey}
|
||||
href={link.href}
|
||||
className={`flex items-center gap-2 px-4 py-3 rounded-xl text-sm font-medium ${
|
||||
isActive(link.href)
|
||||
? "text-gray-900 bg-gray-50"
|
||||
: "text-gray-500 hover:bg-gray-50"
|
||||
? "text-gray-900 dark:text-gray-100 bg-gray-50 dark:bg-gray-800"
|
||||
: "text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
}`}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
<span className="text-lg">{link.icon}</span>
|
||||
<span>{link.label}</span>
|
||||
<span>{tNav(link.labelKey)}</span>
|
||||
</Link>
|
||||
))}
|
||||
<div className="pt-2 border-t border-gray-100">
|
||||
<button className="w-full text-left px-4 py-3 text-sm text-gray-600 hover:bg-gray-50 rounded-xl">
|
||||
登录
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.replace(pathname, { locale: locale === "zh" ? "en" : "zh" })}
|
||||
className="block w-full text-left px-4 py-3 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
|
||||
>
|
||||
{locale === "zh" ? "EN" : "中文"}
|
||||
</button>
|
||||
{userEmail ? (
|
||||
<div className="pt-2 border-t border-gray-100 dark:border-gray-800 flex items-center justify-between px-4 py-2.5 rounded-lg bg-gray-50 dark:bg-gray-800">
|
||||
<span className="truncate text-sm text-gray-600 dark:text-gray-400">{userEmail}</span>
|
||||
<button onClick={() => { handleLogout(); setMobileOpen(false); }} className="text-sm font-medium text-[#ff4d4f]">
|
||||
{t("logout")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => { setMobileOpen(false); setAuthOpen(true); }}
|
||||
className="w-full text-left px-4 py-3 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
|
||||
>
|
||||
{t("loginRegister")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AuthModal
|
||||
isOpen={authOpen}
|
||||
onClose={() => setAuthOpen(false)}
|
||||
onSuccess={(email) => setUserEmail(email)}
|
||||
/>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
47
app/components/ThemeToggle.tsx
Normal file
47
app/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
className="rounded-lg p-2 text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100"
|
||||
aria-label={theme === "dark" ? "切换到日间模式" : "切换到夜间模式"}
|
||||
title={theme === "dark" ? "日间模式" : "夜间模式"}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user