's'
This commit is contained in:
63
app/components/ChatNavLink.tsx
Normal file
63
app/components/ChatNavLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
154
app/components/DevDebugFab.tsx
Normal file
154
app/components/DevDebugFab.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
14
app/components/DevDebugTools.tsx
Normal file
14
app/components/DevDebugTools.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
49
app/components/SocialGreetButton.tsx
Normal file
49
app/components/SocialGreetButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
95
app/components/meetups/MeetupSocialPanel.tsx
Normal file
95
app/components/meetups/MeetupSocialPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user