This commit is contained in:
eric
2026-03-09 05:07:59 -05:00
parent 302a072036
commit eb852fb11d
27 changed files with 1523 additions and 169 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 ? (

View 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;
}

View File

@@ -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 */}

View File

@@ -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