This commit is contained in:
eric
2026-06-08 06:05:31 -05:00
parent ebfec37e47
commit 06ab949d81
77 changed files with 3646 additions and 358 deletions

View File

@@ -0,0 +1,63 @@
"use client";
import { useEffect, useState } from "react";
import { Link } from "@/i18n/navigation";
import { fetchConversations } from "@/app/lib/chat";
import { resolveAuthUser } from "@/app/lib/api-client";
export default function ChatNavLink() {
const [unread, setUnread] = useState(0);
const [visible, setVisible] = useState(false);
useEffect(() => {
let cancelled = false;
const load = async () => {
const session = await resolveAuthUser();
if (!session?.user?.id) {
if (!cancelled) {
setVisible(false);
setUnread(0);
}
return;
}
try {
const data = await fetchConversations();
if (!cancelled) {
setVisible(true);
setUnread(data.unread || 0);
}
} catch {
if (!cancelled) setVisible(false);
}
};
load();
const timer = window.setInterval(load, 15000);
const onAuth = () => load();
window.addEventListener("auth:updated", onAuth);
return () => {
cancelled = true;
window.clearInterval(timer);
window.removeEventListener("auth:updated", onAuth);
};
}, []);
if (!visible) return null;
return (
<Link
href="/chat"
className="relative inline-flex items-center justify-center rounded-lg p-2 text-gray-600 transition hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
aria-label="私信"
title="私信"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 10h8M8 14h5m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{unread > 0 ? (
<span className="absolute -right-0.5 -top-0.5 min-w-[18px] rounded-full bg-[#ff4d4f] px-1 text-center text-[10px] font-bold leading-[18px] text-white">
{unread > 9 ? "9+" : unread}
</span>
) : null}
</Link>
);
}

View File

@@ -3,7 +3,8 @@
import { memo, useState } from "react";
import { City } from "../data/cities";
import { getCitySocialSummary } from "../data/city-social";
import { useTranslation } from "@/i18n/navigation";
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> = {
@@ -53,12 +54,16 @@ interface CityCardProps {
city: City;
rank: number;
onClick?: () => void;
liveWeather?: CityWeatherLive | null;
}
function CityCardComponent({ city, rank, onClick }: CityCardProps) {
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 = [
{
@@ -67,14 +72,14 @@ function CityCardComponent({ city, rank, onClick }: CityCardProps) {
percent: (city.overallScore / 5) * 100,
},
{
icon: "💵",
label: "费用",
percent: Math.max(15, 100 - city.costPerMonth / 200),
icon: "👥",
label: "游民",
percent: Math.min(95, (city.nomadsNow / 1200) * 100),
},
{
icon: "📡",
label: "网速",
percent: Math.min(95, (city.internetSpeed / 120) * 100),
icon: "🌡",
label: "天气",
percent: Math.max(20, Math.min(95, 100 - Math.abs(temperature - 22) * 4)),
},
{
icon: "👍",
@@ -115,31 +120,26 @@ function CityCardComponent({ city, rank, onClick }: CityCardProps) {
<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">
📡 {city.internetSpeed}Mbps
<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 + country */}
{/* 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>
<p className="text-xs sm:text-sm text-white/60 mt-0.5">
{city.countryEmoji} {city.country}
</p>
</div>
{/* Bottom: temp + cost + nomads */}
<div>
<div className="mb-1 flex flex-wrap items-center gap-1">
<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}{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
</span>
</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}
@@ -156,7 +156,7 @@ function CityCardComponent({ city, rank, onClick }: CityCardProps) {
{city.description}
</span>
<span className="font-semibold shrink-0">
¥{city.costPerMonth.toLocaleString()}/{t("perMonth")}
👥 {city.nomadsNow.toLocaleString()}{t("nomadsHere")}
</span>
</div>
</div>
@@ -204,7 +204,7 @@ function CityCardComponent({ city, rank, onClick }: CityCardProps) {
<span className="rounded-full bg-white/10 px-2 py-1">🥾 {socialSummary.cityWalk}</span>
</div>
<div className="text-white/40">
{city.name} · {city.countryEmoji} {city.country}
{city.name}
</div>
</div>
</div>

View File

@@ -18,6 +18,7 @@ import {
import { apiFetch } from "@/app/lib/api-client";
import { cityVolunteerHref } from "@/app/lib/city-slugs";
import { mediaUrl } from "@/app/lib/media";
import SocialGreetButton from "@/app/components/SocialGreetButton";
const CITY_COORDS: Record<string, [number, number]> = {
: [25.7, 100.2],
@@ -653,7 +654,9 @@ function SocialMemberCard({ member, mode }: { member: CitySocialMember; mode: So
<img src={mediaUrl(member.photo)} alt={member.name} className="h-14 w-14 rounded-full object-cover shadow-sm" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-bold text-gray-900 dark:text-gray-100">{member.name}{member.age ? `${member.age}` : ""}</h3>
<Link href={`/members/${member.id}`} className="font-bold text-gray-900 hover:text-[#ff4d4f] dark:text-gray-100">
{member.name}{member.age ? `${member.age}` : ""}
</Link>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${meta.tone}`}>{meta.badge}</span>
</div>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
@@ -671,9 +674,7 @@ function SocialMemberCard({ member, mode }: { member: CitySocialMember; mode: So
</div>
<div className="mt-4 flex items-center justify-between gap-3 border-t border-gray-100 pt-3 dark:border-gray-800">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">{member.availability}</span>
<Link href="/messages" className="rounded-full bg-[#ff4d4f] px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-500">
</Link>
<SocialGreetButton profileId={member.id} />
</div>
</article>
);

View File

@@ -0,0 +1,154 @@
"use client";
import { useMemo } from "react";
import { getDebugFeatureCategories } from "@/config/debug-features";
import { useDevDebug } from "@/app/context/DevDebugContext";
function ToggleSwitch({
checked,
onChange,
label,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
label: string;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
onClick={() => onChange(!checked)}
className={`relative h-6 w-11 shrink-0 rounded-full transition-colors ${
checked ? "bg-[#ff4d4f]" : "bg-gray-300 dark:bg-gray-600"
}`}
>
<span
className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform ${
checked ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
);
}
export default function DevDebugFab() {
const {
isDev,
panelOpen,
setPanelOpen,
features,
isFeatureVisible,
setFeatureVisible,
resetAll,
overrides,
} = useDevDebug();
const categories = useMemo(() => getDebugFeatureCategories(), []);
const overrideCount = Object.keys(overrides).length;
if (!isDev) return null;
return (
<>
{panelOpen && (
<button
type="button"
aria-label="关闭调试面板"
className="fixed inset-0 z-[9998] bg-black/20 backdrop-blur-[1px]"
onClick={() => setPanelOpen(false)}
/>
)}
<div className="fixed bottom-5 right-5 z-[9999] flex flex-col items-end gap-3">
{panelOpen && (
<div className="w-[min(92vw,360px)] rounded-2xl border border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-900">
<div className="border-b border-gray-100 px-4 py-3 dark:border-gray-800">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-sm font-bold text-gray-900 dark:text-gray-100"></h2>
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
·
</p>
</div>
<button
type="button"
onClick={() => setPanelOpen(false)}
className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-200"
aria-label="关闭"
>
</button>
</div>
</div>
<div className="max-h-[min(60vh,420px)] overflow-y-auto p-4 space-y-5">
{categories.map((category) => (
<section key={category}>
<h3 className="mb-2 text-[11px] font-bold uppercase tracking-wider text-gray-400 dark:text-gray-500">
{category}
</h3>
<ul className="space-y-2">
{features
.filter((feature) => feature.category === category)
.map((feature) => {
const visible = isFeatureVisible(feature.id);
return (
<li
key={feature.id}
className="flex items-center justify-between gap-3 rounded-xl border border-gray-100 bg-gray-50 px-3 py-2.5 dark:border-gray-800 dark:bg-gray-800/60"
>
<div className="min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{feature.label}
</p>
<p className="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400">
{feature.description}
</p>
</div>
<ToggleSwitch
checked={visible}
onChange={(next) => setFeatureVisible(feature.id, next)}
label={`${feature.label} ${visible ? "显示" : "隐藏"}`}
/>
</li>
);
})}
</ul>
</section>
))}
</div>
<div className="flex items-center justify-between border-t border-gray-100 px-4 py-3 dark:border-gray-800">
<span className="text-[11px] text-gray-400 dark:text-gray-500">
{overrideCount > 0 ? `已覆盖 ${overrideCount}` : "使用默认配置"}
</span>
<button
type="button"
onClick={resetAll}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
>
</button>
</div>
</div>
)}
<button
type="button"
onClick={() => setPanelOpen(!panelOpen)}
className={`flex h-12 w-12 items-center justify-center rounded-full shadow-lg transition-all ${
panelOpen
? "bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900"
: "bg-[#ff4d4f] text-white hover:bg-[#ff7a45]"
}`}
aria-label={panelOpen ? "关闭调试模式" : "打开调试模式"}
title="调试模式"
>
<span className="text-lg">{panelOpen ? "✕" : "🛠️"}</span>
</button>
</div>
</>
);
}

View File

@@ -0,0 +1,14 @@
"use client";
import { type ReactNode } from "react";
import { DevDebugProvider } from "@/app/context/DevDebugContext";
import DevDebugFab from "./DevDebugFab";
export default function DevDebugTools({ children }: { children: ReactNode }) {
return (
<DevDebugProvider>
{children}
<DevDebugFab />
</DevDebugProvider>
);
}

View File

@@ -4,13 +4,19 @@ import { memo } from "react";
import { Link, useTranslation } from "@/i18n/navigation";
import { FOOTER_SECTIONS } from "@/config";
function FooterComponent() {
interface FooterProps {
/** Hide link columns (about, support, legal, social) — used on homepage */
minimal?: boolean;
}
function FooterComponent({ minimal = false }: FooterProps) {
const { t } = useTranslation("footer");
const { t: tCommon } = useTranslation("common");
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">
{!minimal && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 sm:gap-8 mb-8">
{FOOTER_SECTIONS.map((section) => (
<div key={section.titleKey}>
@@ -41,7 +47,8 @@ function FooterComponent() {
</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 flex-col sm:flex-row items-center justify-between gap-4${minimal ? "" : " pt-8 border-t border-gray-200 dark:border-gray-800"}`}>
<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">{tCommon("siteNameShort")}</span>

View File

@@ -13,6 +13,7 @@ import type { PayChannel } from "@/app/lib/payment";
import { apiFetch, fetchAuthMe, syncAuthSession } from "@/app/lib/api-client";
import { mediaUrl } from "@/app/lib/media";
import { avatarForIndex } from "@/app/data/avatars";
import { useDevDebug } from "@/app/context/DevDebugContext";
const MEDIA_NAMES_ZH = ["36氪", "少数派", "极客公园", "虎嗅", "创业邦", "澎湃新闻"];
const MEDIA_NAMES_EN = ["36Kr", "sspai", "GeekPark", "Huxiu", "Cyzone", "The Paper"];
@@ -65,6 +66,8 @@ function buildPendingUserId(email: string): string {
export default function HeroSection() {
const { t: tHero } = useTranslation("hero");
const locale = useLocale();
const { isFeatureVisible } = useDevDebug();
const showMediaPress = isFeatureVisible("home.mediaPress");
const [email, setEmail] = useState("");
const [joinModalOpen, setJoinModalOpen] = useState(false);
const [joinModalVipOnly, setJoinModalVipOnly] = useState(false);
@@ -240,23 +243,25 @@ export default function HeroSection() {
</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-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"
>
{name}
</span>
)
)}
{showMediaPress && (
<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-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"
>
{name}
</span>
)
)}
</div>
</div>
</div>
)}
</div>
{/* Right Column: Video + Email */}

View File

@@ -1,15 +1,18 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useMemo } from "react";
import { Link, useLocale, usePathname, useRouter, useTranslation } from "@/i18n/navigation";
import { getStoredUserEmail, pbLogout } from "@/app/lib/pocketbase";
import { resolveAuthUser } from "@/app/lib/api-client";
import { getNavLinks } from "@/config";
import { getAllNavLinks } from "@/config";
import { getNavLabelDebugFeatureId } from "@/config/debug-features";
import { useDevDebug } from "@/app/context/DevDebugContext";
import AuthModal from "./AuthModal";
import ThemeToggle from "./ThemeToggle";
import SiteMenu from "./SiteMenu";
import UserAvatar from "./UserAvatar";
import NotificationBell from "./NotificationBell";
import ChatNavLink from "./ChatNavLink";
export default function NavbarComponent() {
const { t } = useTranslation("common");
@@ -23,7 +26,18 @@ export default function NavbarComponent() {
const [userEmail, setUserEmail] = useState<string | null>(null);
const [userVip, setUserVip] = useState(false);
const navLinks = getNavLinks();
const { isFeatureVisible } = useDevDebug();
const showTopMenuButton = isFeatureVisible("nav.menu");
const openSiteMenu = useCallback(() => setMenuOpen(true), []);
const navLinks = useMemo(
() =>
getAllNavLinks().filter((link) => {
if (!link.hidden) return true;
const featureId = getNavLabelDebugFeatureId(link.labelKey);
return featureId ? isFeatureVisible(featureId) : true;
}),
[isFeatureVisible]
);
const fetchAuth = useCallback(async () => {
const session = await resolveAuthUser();
@@ -79,68 +93,94 @@ export default function NavbarComponent() {
</span>
</Link>
<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 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"
}`}
>
<span>{link.icon}</span>
<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>
{(navLinks.length > 0 || showTopMenuButton) && (
<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 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"
}`}
>
<span>{link.icon}</span>
<span className="hidden 2xl:inline">{tNav(link.labelKey)}</span>
</Link>
))}
{navLinks.length > 0 && showTopMenuButton && (
<span className="w-px h-5 bg-gray-200 dark:bg-gray-700 shrink-0" aria-hidden />
)}
{showTopMenuButton && (
<button
onClick={openSiteMenu}
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">
<button
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" 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>
{showTopMenuButton && (
<button
className="lg:hidden p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
onClick={openSiteMenu}
aria-label={tNav("menu")}
>
<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 ? (
<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>
<UserAvatar email={userEmail} isVip={userVip} onLogout={handleLogout} />
<UserAvatar
email={userEmail}
isVip={userVip}
onLogout={handleLogout}
onOpenMenu={openSiteMenu}
/>
</div>
) : (
<button
type="button"
onClick={() => setAuthOpen(true)}
className="hidden sm:inline-flex shrink-0 items-center justify-center rounded-full border border-gray-200 dark:border-gray-700 px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
{t("loginRegister")}
</button>
<>
<button
type="button"
onClick={openSiteMenu}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gray-200 text-sm text-gray-600 shadow-sm ring-2 ring-white transition hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:ring-gray-900 dark:hover:bg-gray-600"
aria-label={tNav("menu")}
title={tNav("menu")}
>
👤
</button>
<button
type="button"
onClick={() => setAuthOpen(true)}
className="hidden sm:inline-flex shrink-0 items-center justify-center rounded-full border border-gray-200 dark:border-gray-700 px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
{t("loginRegister")}
</button>
</>
)}
<ChatNavLink />
<NotificationBell />
<button
type="button"
@@ -152,12 +192,6 @@ export default function NavbarComponent() {
{locale === "zh" ? "EN" : "中文"}
</button>
<ThemeToggle />
<Link
href="/join"
className="text-xs sm:text-sm bg-[#ff4d4f] text-white px-3 sm:px-4 py-2 rounded-full hover:bg-[#ff7a45] transition-colors font-medium whitespace-nowrap"
>
{tNav("join")}
</Link>
<button
className="md:hidden ml-1 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
@@ -214,7 +248,12 @@ export default function NavbarComponent() {
</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">
<UserAvatar email={userEmail} isVip={userVip} onLogout={() => { handleLogout(); setMobileOpen(false); }} />
<UserAvatar
email={userEmail}
isVip={userVip}
onLogout={() => { handleLogout(); setMobileOpen(false); }}
onOpenMenu={() => { setMobileOpen(false); openSiteMenu(); }}
/>
<span className="truncate text-sm text-gray-600 dark:text-gray-400">{userEmail}</span>
</div>
) : null}

View File

@@ -0,0 +1,49 @@
"use client";
import { useState } from "react";
import { useRouter } from "@/i18n/navigation";
import { startConversation } from "@/app/lib/chat";
interface SocialGreetButtonProps {
profileId: string;
label?: string;
opener?: string;
className?: string;
onNeedLogin?: () => void;
}
export default function SocialGreetButton({
profileId,
label = "打招呼",
opener = "你好,我在同城成员里看到你,想认识一下~",
className = "rounded-full bg-[#ff4d4f] px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-500",
onNeedLogin,
}: SocialGreetButtonProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const handleClick = async () => {
if (profileId.startsWith("fallback-")) {
router.push("/dating");
return;
}
setLoading(true);
try {
const conversation = await startConversation({ profileId, opener, intent: "friends" });
router.push(`/chat/${conversation.id}`);
} catch (err) {
const message = err instanceof Error ? err.message : "";
if (message.includes("登录") && onNeedLogin) {
onNeedLogin();
}
} finally {
setLoading(false);
}
};
return (
<button type="button" onClick={handleClick} disabled={loading} className={className}>
{loading ? "连接中..." : label}
</button>
);
}

View File

@@ -14,11 +14,14 @@ interface UserAvatarProps {
email: string;
isVip?: boolean;
onLogout?: () => void;
/** 设置后点击头像打开外部菜单(如 SiteMenu不再展开内置下拉 */
onOpenMenu?: () => void;
}
/** 圆形头像 + 账户菜单 */
export default function UserAvatar({ email, isVip = false, onLogout }: UserAvatarProps) {
export default function UserAvatar({ email, isVip = false, onLogout, onOpenMenu }: UserAvatarProps) {
const { t } = useTranslation("menu");
const { t: tNav } = useTranslation("nav");
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
@@ -40,10 +43,10 @@ export default function UserAvatar({ email, isVip = false, onLogout }: UserAvata
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen((o) => !o)}
onClick={() => (onOpenMenu ? onOpenMenu() : setOpen((o) => !o))}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#ff4d4f] text-sm font-semibold text-white shadow-sm ring-2 ring-white transition hover:bg-[#ff7a45] dark:ring-gray-900"
aria-label={t("yourProfile")}
aria-expanded={open}
aria-label={onOpenMenu ? tNav("menu") : t("yourProfile")}
aria-expanded={onOpenMenu ? undefined : open}
>
{getAvatarLetter(email)}
</button>
@@ -52,7 +55,7 @@ export default function UserAvatar({ email, isVip = false, onLogout }: UserAvata
VIP
</span>
)}
{open && (
{!onOpenMenu && open && (
<div className="absolute right-0 top-full z-50 mt-2 w-[260px] overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800">
<div className="border-b border-gray-100 px-4 py-3 dark:border-gray-700">
<p className="truncate text-sm font-semibold text-gray-900 dark:text-white">{email}</p>

View File

@@ -7,6 +7,7 @@ import {
WEB_CHAT_PROVIDER_LABEL,
type MeetupSession,
} from "@/app/lib/meetups";
import MeetupSocialPanel from "@/app/components/meetups/MeetupSocialPanel";
interface EventLiveRoomProps {
locale: string;
@@ -198,6 +199,12 @@ export default function EventLiveRoom({ locale, meetup, session, formattedDate }
</section>
) : null}
</main>
<footer className="border-t border-white/10 bg-[#111827] px-4 py-5 sm:px-6">
<div className="mx-auto max-w-[1600px]">
<MeetupSocialPanel meetupId={meetup.id} city={meetup.city} locale={locale} />
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,95 @@
"use client";
import { useEffect, useState } from "react";
import { Link } from "@/i18n/navigation";
import SocialGreetButton from "@/app/components/SocialGreetButton";
import { mediaUrl } from "@/app/lib/media";
import { fetchMeetupSocialSuggestions, type PublicProfile } from "@/app/lib/social";
interface MeetupSocialPanelProps {
meetupId: string;
city: string;
locale?: string;
compact?: boolean;
}
export default function MeetupSocialPanel({
meetupId,
city,
locale = "zh",
compact = false,
}: MeetupSocialPanelProps) {
const [members, setMembers] = useState<PublicProfile[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!meetupId) return;
fetchMeetupSocialSuggestions(meetupId)
.then((data) => setMembers(data.items || []))
.catch(() => setMembers([]))
.finally(() => setLoading(false));
}, [meetupId]);
if (loading) {
return (
<div className={`rounded-2xl border border-gray-100 bg-white p-4 dark:border-gray-800 dark:bg-gray-900 ${compact ? "" : "mt-6"}`}>
<div className="h-20 animate-pulse rounded-xl bg-gray-100 dark:bg-gray-800" />
</div>
);
}
if (!members.length) return null;
return (
<section className={`rounded-2xl border border-violet-100 bg-violet-50/60 p-4 dark:border-violet-900/40 dark:bg-violet-950/20 ${compact ? "" : "mt-6"}`}>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">
{locale === "zh" ? "活动后继续聊" : "Keep connecting"}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
{locale === "zh" ? `同城 ${city} 成员,优先展示一起报名的伙伴` : `Members in ${city}, RSVP attendees first`}
</p>
</div>
<Link
href={`/dating?intent=friends&city=${encodeURIComponent(city)}`}
className="rounded-full bg-[#ff4d4f] px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-500"
>
{locale === "zh" ? "去匹配" : "Go matching"}
</Link>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{members.slice(0, compact ? 4 : 6).map((member) => (
<article
key={member.id}
className="flex items-center gap-3 rounded-xl border border-white/80 bg-white p-3 shadow-sm dark:border-gray-800 dark:bg-gray-900"
>
<Link href={`/members/${member.id}`}>
<img
src={mediaUrl(member.photo)}
alt={member.name || "member"}
className="h-11 w-11 rounded-full object-cover"
/>
</Link>
<div className="min-w-0 flex-1">
<Link href={`/members/${member.id}`} className="truncate text-sm font-semibold text-gray-900 hover:text-[#ff4d4f] dark:text-gray-100">
{member.name}
</Link>
<p className="truncate text-xs text-gray-500 dark:text-gray-400">{member.location}</p>
{"rsvpAttendee" in member && (member as PublicProfile & { rsvpAttendee?: boolean }).rsvpAttendee ? (
<span className="mt-1 inline-flex rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] font-semibold text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300">
{locale === "zh" ? "同场报名" : "RSVP"}
</span>
) : null}
</div>
<SocialGreetButton
profileId={member.id}
label={locale === "zh" ? "聊" : "Hi"}
className="rounded-full bg-[#ff4d4f] px-2.5 py-1 text-[11px] font-semibold text-white hover:bg-red-500"
/>
</article>
))}
</div>
</section>
);
}