'x'
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import { memo, useState } from "react";
|
||||
import { City } from "../data/cities";
|
||||
import { useTranslation } from "@/i18n/navigation";
|
||||
|
||||
function ratingToPercent(rating: string): number {
|
||||
const m: Record<string, number> = {
|
||||
@@ -55,6 +56,7 @@ interface CityCardProps {
|
||||
|
||||
function CityCardComponent({ city, rank, onClick }: CityCardProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const { t } = useTranslation("cityCard");
|
||||
|
||||
const scores = [
|
||||
{
|
||||
@@ -130,7 +132,7 @@ function CityCardComponent({ city, rank, onClick }: CityCardProps) {
|
||||
<div>
|
||||
<div className="flex items-center gap-1 mb-1.5">
|
||||
<span className="text-[9px] sm:text-[11px] bg-white/15 backdrop-blur-sm rounded-full px-1.5 py-0.5 text-white/80">
|
||||
👥 {city.nomadsNow}人在此
|
||||
👥 {city.nomadsNow}{t("nomadsHere")}
|
||||
</span>
|
||||
<span className="text-[9px] sm:text-[11px] bg-white/15 backdrop-blur-sm rounded-full px-1.5 py-0.5 text-white/80">
|
||||
🌡 {city.temperature}°C
|
||||
@@ -141,7 +143,7 @@ function CityCardComponent({ city, rank, onClick }: CityCardProps) {
|
||||
{city.description}
|
||||
</span>
|
||||
<span className="font-semibold shrink-0">
|
||||
¥{city.costPerMonth.toLocaleString()}/月
|
||||
¥{city.costPerMonth.toLocaleString()}/{t("perMonth")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,14 +2,37 @@
|
||||
|
||||
import { memo } from "react";
|
||||
import { allTags, FILTER_OPTIONS } from "../data/cities";
|
||||
import { useTranslation } from "@/i18n/navigation";
|
||||
|
||||
interface FilterBarProps {
|
||||
activeFilter: string;
|
||||
onFilterChange: (filter: string) => void;
|
||||
sortBy: string;
|
||||
onSortChange: (sort: string) => void;
|
||||
resultCount: number;
|
||||
}
|
||||
const TAG_TO_I18N: Record<string, string> = {
|
||||
全部: "all",
|
||||
热门: "hot",
|
||||
便宜: "cheap",
|
||||
高速网络: "fastInternet",
|
||||
温暖: "warm",
|
||||
安全: "safe",
|
||||
海滨: "beach",
|
||||
山城: "mountain",
|
||||
都市: "urban",
|
||||
美食: "food",
|
||||
文化: "culture",
|
||||
一线城市: "tier1",
|
||||
新一线: "newTier1",
|
||||
宜居: "livable",
|
||||
历史名城: "historical",
|
||||
友好社区: "friendly",
|
||||
户外运动: "outdoor",
|
||||
};
|
||||
|
||||
const SORT_TO_I18N: Record<string, string> = {
|
||||
score: "sortByScore",
|
||||
"cost-asc": "sortByCostAsc",
|
||||
"cost-desc": "sortByCostDesc",
|
||||
internet: "sortByInternet",
|
||||
safety: "sortBySafety",
|
||||
nomads: "sortByNomads",
|
||||
temperature: "sortByTemp",
|
||||
};
|
||||
|
||||
const tagIcons: Record<string, string> = {
|
||||
全部: "🌍",
|
||||
@@ -31,6 +54,14 @@ const tagIcons: Record<string, string> = {
|
||||
户外运动: "🏄",
|
||||
};
|
||||
|
||||
interface FilterBarProps {
|
||||
activeFilter: string;
|
||||
onFilterChange: (filter: string) => void;
|
||||
sortBy: string;
|
||||
onSortChange: (sort: string) => void;
|
||||
resultCount: number;
|
||||
}
|
||||
|
||||
function FilterBarComponent({
|
||||
activeFilter,
|
||||
onFilterChange,
|
||||
@@ -38,48 +69,50 @@ function FilterBarComponent({
|
||||
onSortChange,
|
||||
resultCount,
|
||||
}: FilterBarProps) {
|
||||
const { t } = useTranslation("filter");
|
||||
|
||||
return (
|
||||
<div className="mb-6" id="cities">
|
||||
{/* Top bar: result count + sort */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
发现目的地
|
||||
<h2 className="text-base sm:text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
{t("discoverDest")}
|
||||
</h2>
|
||||
<span className="text-sm text-gray-400 dark:text-gray-500">
|
||||
{resultCount} 个城市
|
||||
{resultCount} {t("citiesCount")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 hidden sm:block">排序:</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">{t("sortLabel")}:</span>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => onSortChange(e.target.value)}
|
||||
className="text-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-1.5 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-700 cursor-pointer"
|
||||
className="text-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-2 sm:py-1.5 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 focus:border-[#ff4d4f] cursor-pointer min-h-[2.5rem] sm:min-h-0"
|
||||
>
|
||||
{FILTER_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
{t(SORT_TO_I18N[opt.value] ?? opt.value)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter tags */}
|
||||
<div className="flex gap-2 overflow-x-auto hide-scrollbar pb-2">
|
||||
{/* Filter tags - horizontal scroll on mobile */}
|
||||
<div className="flex gap-2 overflow-x-auto hide-scrollbar pb-2 -mx-1 px-1">
|
||||
{allTags.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => onFilterChange(tag)}
|
||||
className={`filter-btn shrink-0 flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium border transition-all ${
|
||||
className={`filter-btn shrink-0 flex items-center gap-1 px-3 py-2 sm:py-1.5 rounded-full text-sm font-medium border transition-all min-h-[2.5rem] sm:min-h-0 ${
|
||||
activeFilter === tag
|
||||
? "active border-gray-900 dark:border-gray-100"
|
||||
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-600"
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs">{tagIcons[tag] || "🏷️"}</span>
|
||||
<span>{tag}</span>
|
||||
<span>{TAG_TO_I18N[tag] ? t(`tags.${TAG_TO_I18N[tag]}`) : tag}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { Link, useLocale } from "@/i18n/navigation";
|
||||
import { useTranslation } from "@/i18n/navigation";
|
||||
|
||||
const MEDIA_NAMES_ZH = ["36氪", "少数派", "极客公园", "虎嗅", "创业邦", "澎湃新闻"];
|
||||
const MEDIA_NAMES_EN = ["36Kr", "sspai", "GeekPark", "Huxiu", "Cyzone", "The Paper"];
|
||||
|
||||
const avatarPhotos = [
|
||||
"https://i.pravatar.cc/150?img=32",
|
||||
"https://i.pravatar.cc/150?img=33",
|
||||
@@ -29,12 +32,13 @@ const features = [
|
||||
export default function HeroSection() {
|
||||
const { t } = useTranslation("common");
|
||||
const { t: tHero } = useTranslation("hero");
|
||||
const locale = useLocale();
|
||||
const [email, setEmail] = useState("");
|
||||
|
||||
const handleJoin = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (email.trim()) {
|
||||
alert(`欢迎加入游牧中国!我们将发送确认邮件到 ${email}`);
|
||||
alert(locale === "zh" ? `欢迎加入游牧中国!我们将发送确认邮件到 ${email}` : `Welcome! We'll send a confirmation email to ${email}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -94,29 +98,29 @@ export default function HeroSection() {
|
||||
key={i}
|
||||
src={src}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded-full border-2 border-white dark:border-gray-800 shadow-sm object-cover"
|
||||
className="w-8 h-8 sm:w-9 sm:h-9 rounded-full border-2 border-white dark:border-gray-800 shadow-sm object-cover"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<strong className="text-gray-900 dark:text-gray-100">5,000+</strong> 成员已加入
|
||||
{tHero("membersJoined")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="pt-7 border-t border-gray-100 dark:border-gray-800">
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">
|
||||
媒体报道
|
||||
{tHero("mediaPress")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-x-8 gap-y-2">
|
||||
{["36氪", "少数派", "极客公园", "虎嗅", "创业邦", "澎湃新闻"].map(
|
||||
<div className="flex flex-wrap items-center gap-x-6 sm:gap-x-8 gap-y-2">
|
||||
{(locale === "zh" ? MEDIA_NAMES_ZH : MEDIA_NAMES_EN).map(
|
||||
(name) => (
|
||||
<span
|
||||
key={name}
|
||||
className="text-sm font-semibold text-gray-300 dark:text-gray-600 hover:text-gray-400 dark:hover:text-gray-500 transition-colors cursor-default"
|
||||
>
|
||||
<span
|
||||
key={name}
|
||||
className="text-sm font-semibold text-gray-300 dark:text-gray-600 hover:text-gray-400 dark:hover:text-gray-500 transition-colors cursor-default"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
),
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,50 +147,50 @@ export default function HeroSection() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleJoin} className="p-5">
|
||||
<form onSubmit={handleJoin} className="p-4 sm:p-5">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="输入你的邮箱..."
|
||||
className="w-full border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-3 text-sm placeholder-gray-400 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 focus:border-[#ff4d4f] transition-colors mb-3"
|
||||
placeholder={tHero("emailPlaceholder")}
|
||||
className="w-full border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-3 text-sm placeholder-gray-400 dark:placeholder-gray-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 focus:border-[#ff4d4f] transition-colors mb-3"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-[#ff4d4f] hover:bg-[#ff3333] text-white py-3 rounded-lg text-sm font-semibold transition-colors active:scale-[0.98]"
|
||||
className="w-full bg-[#ff4d4f] hover:bg-[#ff3333] dark:hover:bg-[#ff5c5e] text-white py-3 rounded-lg text-sm font-semibold transition-colors active:scale-[0.98]"
|
||||
>
|
||||
加入游牧中国 →
|
||||
{tHero("ctaJoin")}
|
||||
</button>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 text-center mt-3">
|
||||
已有账号?输入邮箱即可自动登录
|
||||
{tHero("loginHint")}
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2 justify-center">
|
||||
<Link href="/dashboard" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors">
|
||||
📍 下一站
|
||||
<Link href="/dashboard" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors min-h-[2rem] inline-flex items-center">
|
||||
📍 {tHero("quickLinks.nextStop")}
|
||||
</Link>
|
||||
<Link href="/meetups" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors">
|
||||
🍹 线下聚会
|
||||
<Link href="/meetups" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors min-h-[2rem] inline-flex items-center">
|
||||
🍹 {tHero("quickLinks.meetups")}
|
||||
</Link>
|
||||
<Link href="/dating" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors">
|
||||
❤️ 智能匹配
|
||||
<Link href="/dating" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors min-h-[2rem] inline-flex items-center">
|
||||
❤️ {tHero("quickLinks.dating")}
|
||||
</Link>
|
||||
<Link href="/tools" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors">
|
||||
📊 工具箱
|
||||
<Link href="/tools" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors min-h-[2rem] inline-flex items-center">
|
||||
📊 {tHero("quickLinks.tools")}
|
||||
</Link>
|
||||
<Link href="/ai" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors">
|
||||
🤖 AI 助理
|
||||
<Link href="/ai" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors min-h-[2rem] inline-flex items-center">
|
||||
🤖 {tHero("quickLinks.ai")}
|
||||
</Link>
|
||||
<Link href="/gigs" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors">
|
||||
💰 赏金墙
|
||||
<Link href="/gigs" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors min-h-[2rem] inline-flex items-center">
|
||||
💰 {tHero("quickLinks.gigs")}
|
||||
</Link>
|
||||
<Link href="/report" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors">
|
||||
📊 年报
|
||||
<Link href="/report" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors min-h-[2rem] inline-flex items-center">
|
||||
📊 {tHero("quickLinks.report")}
|
||||
</Link>
|
||||
<Link href="/join" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors">
|
||||
💬 加入社群
|
||||
<Link href="/join" className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 bg-white dark:bg-gray-800 rounded-full px-3 py-1.5 border border-gray-100 dark:border-gray-700 transition-colors min-h-[2rem] inline-flex items-center">
|
||||
💬 {tHero("quickLinks.join")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,21 +46,12 @@ export default function NavbarComponent() {
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="hidden lg:flex items-center gap-1 overflow-x-auto max-w-[calc(100vw-400px)] scrollbar-hide">
|
||||
<button
|
||||
onClick={() => setMenuOpen(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-full text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors border border-gray-200 dark:border-gray-700 whitespace-nowrap"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
<span className="hidden xl:inline">{tNav("menu")}</span>
|
||||
</button>
|
||||
<div className="hidden lg:flex items-center gap-2 overflow-x-auto max-w-[calc(100vw-400px)] hide-scrollbar">
|
||||
{navLinks.slice(0, 5).map((link) => (
|
||||
<Link
|
||||
key={link.labelKey}
|
||||
href={link.href}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-full text-sm font-medium transition-colors whitespace-nowrap ${
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-full text-sm font-medium transition-colors whitespace-nowrap shrink-0 ${
|
||||
isActive(link.href)
|
||||
? "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"
|
||||
@@ -70,6 +61,23 @@ export default function NavbarComponent() {
|
||||
<span className="hidden 2xl:inline">{tNav(link.labelKey)}</span>
|
||||
</Link>
|
||||
))}
|
||||
<span className="w-px h-5 bg-gray-200 dark:bg-gray-700 shrink-0" aria-hidden />
|
||||
<button
|
||||
onClick={() => setMenuOpen(true)}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-full text-sm font-medium transition-colors whitespace-nowrap shrink-0 ${
|
||||
menuOpen
|
||||
? "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"
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
</svg>
|
||||
<span className="hidden xl:inline">{tNav("menu")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
@@ -77,8 +85,11 @@ export default function NavbarComponent() {
|
||||
className="lg:hidden p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
onClick={() => setMenuOpen(true)}
|
||||
>
|
||||
<svg className="w-5 h-5 text-gray-600 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
<svg className="w-5 h-5 text-gray-600 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
{userEmail ? (
|
||||
|
||||
17
app/components/SetLangAttr.tsx
Normal file
17
app/components/SetLangAttr.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useLocale } from "@/i18n/navigation";
|
||||
|
||||
/**
|
||||
* 根据当前 locale 设置 document.documentElement.lang,以支持无障碍与 SEO
|
||||
*/
|
||||
export default function SetLangAttr() {
|
||||
const locale = useLocale();
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = locale === "zh" ? "zh-CN" : "en";
|
||||
}, [locale]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useState, useEffect, useRef } from "react";
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import { Link, usePathname, useTranslation } from "@/i18n/navigation";
|
||||
import { getStoredUserEmail, pbLogout } from "@/app/lib/pocketbase";
|
||||
import ThemeToggle from "./ThemeToggle";
|
||||
|
||||
interface MenuItem {
|
||||
labelKey: string;
|
||||
@@ -30,7 +29,6 @@ export default memo(function SiteMenu({
|
||||
const { t } = useTranslation("menu");
|
||||
const pathname = usePathname();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [userEmail, setUserEmail] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -58,32 +56,27 @@ export default memo(function SiteMenu({
|
||||
{
|
||||
titleKey: "community",
|
||||
items: [
|
||||
{ labelKey: "dating", href: "/dating", icon: "❤️" },
|
||||
{ labelKey: "chat", href: "/join", icon: "💬" },
|
||||
{ labelKey: "hostMeetup", href: "/meetups", icon: "🎤", new: true },
|
||||
{ labelKey: "friendFinder", href: "/dating", icon: "💛" },
|
||||
{ labelKey: "membersMap", href: "#", icon: "🌐" },
|
||||
{ labelKey: "attendMeetup", href: "/meetups", icon: "🍹" },
|
||||
{ labelKey: "hostMeetup", href: "/meetups/host", icon: "🎤", new: true },
|
||||
{ labelKey: "membersMap", href: "/map", icon: "🌐" },
|
||||
],
|
||||
},
|
||||
{
|
||||
titleKey: "tools",
|
||||
items: [
|
||||
{ labelKey: "explore", href: "/", icon: "📍" },
|
||||
{ labelKey: "nextStop", href: "/dashboard", icon: "🗺️" },
|
||||
{ labelKey: "climateFinder", href: "/tools", icon: "🌤️" },
|
||||
{ labelKey: "fireCalculator", href: "/tools", icon: "🌱" },
|
||||
{ labelKey: "costCalculator", href: "/tools", icon: "💱" },
|
||||
{ labelKey: "aiAssistant", href: "/ai", icon: "🤖", new: true },
|
||||
{ labelKey: "yearInReview", href: "/report", icon: "🥂" },
|
||||
{ labelKey: "nomadStats", href: "/report", icon: "📊", new: true },
|
||||
{ labelKey: "nomadStats", href: "/report/data", icon: "📊", new: true },
|
||||
{ labelKey: "gigBoard", href: "/gigs", icon: "💰" },
|
||||
{ labelKey: "costCalculator", href: "/tools", icon: "💱" },
|
||||
],
|
||||
},
|
||||
{
|
||||
titleKey: "help",
|
||||
items: [
|
||||
{ labelKey: "ideasBugs", href: "#", icon: "💡" },
|
||||
{ labelKey: "ideasBugs", href: "/feedback", icon: "💡" },
|
||||
{ labelKey: "faq", href: "#", icon: "🆘" },
|
||||
{ labelKey: "terms", href: "#", icon: "📄" },
|
||||
{ labelKey: "changelog", href: "#", icon: "🚀" },
|
||||
@@ -106,14 +99,7 @@ export default memo(function SiteMenu({
|
||||
{ labelKey: "nomadInsurance", href: "#", icon: "🛡️", ad: true },
|
||||
];
|
||||
|
||||
const filteredSections = sections.map((sec) => ({
|
||||
...sec,
|
||||
items: search
|
||||
? sec.items.filter((i) =>
|
||||
t(i.labelKey).toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: sec.items,
|
||||
})).filter((sec) => sec.items.length > 0);
|
||||
const filteredSections = sections;
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -124,59 +110,7 @@ export default memo(function SiteMenu({
|
||||
ref={ref}
|
||||
className="fixed left-4 right-4 top-20 md:left-auto md:right-4 md:top-16 md:w-[480px] z-50 rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-2xl overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-100 dark:border-gray-800 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{userEmail ? (
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-[#ff4d4f] to-orange-500 flex items-center justify-center text-white text-sm font-bold shrink-0">
|
||||
{userEmail[0].toUpperCase()}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{userEmail}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={onLoginClick}
|
||||
className="px-4 py-2 rounded-lg bg-[#ff4d4f] text-white text-sm font-medium hover:bg-[#ff3333] transition-colors"
|
||||
>
|
||||
{t("login")}
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
href="/dashboard"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm font-medium hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
{t("filters")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/join"
|
||||
onClick={onClose}
|
||||
className="w-9 h-9 rounded-full bg-[#ff4d4f] text-white flex items-center justify-center hover:bg-[#ff3333] transition-colors shrink-0"
|
||||
aria-label={t("add")}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 text-sm focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 focus:border-[#ff4d4f]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile + Dark mode */}
|
||||
{/* Profile links */}
|
||||
<div className="px-4 py-3 border-b border-gray-100 dark:border-gray-800 flex flex-wrap items-center gap-2">
|
||||
{profileItems.map((item) => (
|
||||
<Link
|
||||
@@ -189,10 +123,6 @@ export default memo(function SiteMenu({
|
||||
<span>{t(item.labelKey)}</span>
|
||||
</Link>
|
||||
))}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{t("darkMode")}</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { useTheme } from "@/app/context/ThemeContext";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "@/i18n/navigation";
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { t } = useTranslation("common");
|
||||
const isDark = theme === "dark";
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
@@ -17,7 +19,7 @@ export default function ThemeToggle() {
|
||||
<button
|
||||
type="button"
|
||||
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="切换主题"
|
||||
aria-label={t("themeLight")}
|
||||
>
|
||||
<div className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -29,8 +31,8 @@ export default function ThemeToggle() {
|
||||
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={isDark ? "切换到日间模式" : "切换到夜间模式"}
|
||||
title={isDark ? "日间模式" : "夜间模式"}
|
||||
aria-label={isDark ? t("themeDark") : t("themeLight")}
|
||||
title={isDark ? t("themeDark") : t("themeLight")}
|
||||
>
|
||||
{isDark ? (
|
||||
<svg
|
||||
|
||||
Reference in New Issue
Block a user