Files
gitlab-instance-0a899031_cn…/app/[locale]/meetups/page.tsx
root b65908e328 s
2026-06-08 10:02:16 +08:00

890 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { Link, useLocale, useTranslation } from "@/i18n/navigation";
import { apiFetch } from "@/app/lib/api-client";
import {
buildMeetupChatUrl,
buildMiroTalkJoinUrl,
DEFAULT_ONLINE_FEATURES,
featureLabels,
getLoungeServerLabel,
getMiroTalkServerLabel,
MIROTALK_PROVIDER_LABEL,
type MeetupAccessLevel,
type MeetupMode,
WEB_CHAT_PROVIDER_LABEL,
} from "@/app/lib/meetups";
import { mediaUrl } from "@/app/lib/media";
import { avatarForIndex } from "@/app/data/avatars";
interface Attendee {
name: string;
photo: string;
}
interface Meetup {
id: string;
city: string;
emoji: string;
date: string;
time: string;
venue: string;
address: string;
description: string;
rsvpCount: number;
gradientFrom: string;
gradientTo: string;
attendees: Attendee[];
isUpcoming: boolean;
mode: MeetupMode;
accessLevel: MeetupAccessLevel;
meetingProvider: string;
mirotalkRoom: string;
meetingUrl: string;
replayUrl: string;
features: string[];
maxAttendees: number;
organizer: string;
}
interface ViewerUser {
id?: string;
email?: string;
name?: string;
vip?: boolean;
role?: string;
[key: string]: unknown;
}
interface ViewerState {
loaded: boolean;
user: ViewerUser | null;
vip: boolean;
}
type ModeFilter = "all" | MeetupMode;
const allMeetups: Meetup[] = [
{
id: "online-1",
city: "线上",
emoji: "💻",
date: "2026-07-09",
time: "20:00",
venue: "MiroTalk 多人视频房",
address: "线上活动 · 自动生成入会链接",
description: "围绕远程工作的节奏、异地协作和旅居规划做一场开放圆桌,适合刚加入社区的新成员。",
rsvpCount: 36,
gradientFrom: "#0f766e",
gradientTo: "#2563eb",
attendees: [{ name: "林晓", photo: avatarForIndex(0) }, { name: "张明", photo: avatarForIndex(4) }, { name: "陈静", photo: avatarForIndex(2) }],
isUpcoming: true,
mode: "online",
accessLevel: "members",
meetingProvider: "mirotalk",
mirotalkRoom: "nomadcna-remote-work-open-office",
meetingUrl: "",
replayUrl: "",
features: DEFAULT_ONLINE_FEATURES,
maxAttendees: 80,
organizer: "NomadCNA",
},
{
id: "1",
city: "大理",
emoji: "🏯",
date: "2026-07-15",
time: "14:00",
venue: "大理古城人民路咖啡馆",
address: "大理市大理古城人民路中段",
description: "苍山脚下,洱海边,与大理的慢生活游民们一起喝咖啡、晒太阳、聊人生。",
rsvpCount: 12,
gradientFrom: "#3b82f6",
gradientTo: "#4f46e5",
attendees: [{ name: "林晓", photo: avatarForIndex(0) }, { name: "张明", photo: avatarForIndex(4) }, { name: "陈静", photo: avatarForIndex(2) }],
isUpcoming: true,
mode: "hybrid",
accessLevel: "vip",
meetingProvider: "mirotalk",
mirotalkRoom: "nomadcna-dali-slow-life-roundtable",
meetingUrl: "",
replayUrl: "",
features: ["group_video", "screen_share", "whiteboard", "recording", "host_controls"],
maxAttendees: 32,
organizer: "大理城市主理人",
},
{
id: "2",
city: "成都",
emoji: "🐼",
date: "2026-07-18",
time: "19:00",
venue: "方所书店",
address: "成都市锦江区中纱帽街8号远洋太古里",
description: "天府之国游民聚会,在文艺书店里品茶聊天,探讨成都的慢生活与远程工作可能性。",
rsvpCount: 9,
gradientFrom: "#22c55e",
gradientTo: "#10b981",
attendees: [{ name: "杨帆", photo: avatarForIndex(28) }, { name: "赵磊", photo: avatarForIndex(10) }],
isUpcoming: true,
mode: "offline",
accessLevel: "public",
meetingProvider: "",
mirotalkRoom: "",
meetingUrl: "",
replayUrl: "",
features: [],
maxAttendees: 20,
organizer: "成都游民小组",
},
{
id: "3",
city: "深圳",
emoji: "🌃",
date: "2026-07-20",
time: "19:30",
venue: "WeWork 卓越前海壹号",
address: "深圳市南山区前海深港合作区卓越前海壹号",
description: "深圳数字游民线下见面会,探讨创业、远程工作与湾区发展机遇。",
rsvpCount: 15,
gradientFrom: "#ff4d4f",
gradientTo: "#ff7a45",
attendees: [{ name: "李娜", photo: avatarForIndex(12) }, { name: "王强", photo: avatarForIndex(18) }, { name: "刘洋", photo: avatarForIndex(30) }],
isUpcoming: true,
mode: "hybrid",
accessLevel: "members",
meetingProvider: "mirotalk",
mirotalkRoom: "nomadcna-shenzhen-maker-night",
meetingUrl: "",
replayUrl: "",
features: ["group_video", "screen_share", "breakout", "host_controls"],
maxAttendees: 48,
organizer: "深圳湾区主理人",
},
{
id: "online-2",
city: "亚太",
emoji: "🌐",
date: "2026-07-24",
time: "21:00",
venue: "MiroTalk 公开直播间",
address: "线上活动 · 亚洲时区友好",
description: "跨城市远程工作者分享工具栈、客户沟通和时区协作经验,公开旁听,登录成员可参与发言。",
rsvpCount: 54,
gradientFrom: "#7c3aed",
gradientTo: "#0d9488",
attendees: [{ name: "许婷", photo: avatarForIndex(6) }, { name: "马超", photo: avatarForIndex(17) }, { name: "罗敏", photo: avatarForIndex(15) }],
isUpcoming: true,
mode: "online",
accessLevel: "public",
meetingProvider: "mirotalk",
mirotalkRoom: "nomadcna-asia-remote-stack",
meetingUrl: "",
replayUrl: "",
features: ["group_video", "screen_share", "whiteboard"],
maxAttendees: 120,
organizer: "NomadCNA",
},
{
id: "5",
city: "杭州",
emoji: "⛲",
date: "2026-07-25",
time: "18:00",
venue: "梦想小镇·良仓咖啡",
address: "杭州市余杭区梦想小镇互联网村",
description: "西湖畔数字游民聚会,在创业氛围浓厚的梦想小镇交流产品与运营经验。",
rsvpCount: 11,
gradientFrom: "#06b6d4",
gradientTo: "#0d9488",
attendees: [{ name: "周杰", photo: avatarForIndex(10) }, { name: "吴芳", photo: avatarForIndex(24) }],
isUpcoming: true,
mode: "offline",
accessLevel: "public",
meetingProvider: "",
mirotalkRoom: "",
meetingUrl: "",
replayUrl: "",
features: [],
maxAttendees: 24,
organizer: "杭州产品游民",
},
{
id: "6",
city: "厦门",
emoji: "🌊",
date: "2026-07-28",
time: "15:00",
venue: "鼓浪屿海角8号咖啡馆",
address: "厦门市思明区鼓浪屿龙头路",
description: "海岛慢生活聚会,在鼓浪屿的文艺咖啡馆里结识热爱海边远程工作的游民朋友。",
rsvpCount: 7,
gradientFrom: "#0ea5e9",
gradientTo: "#0284c7",
attendees: [{ name: "赵磊", photo: avatarForIndex(10) }, { name: "黄薇", photo: avatarForIndex(22) }],
isUpcoming: true,
mode: "offline",
accessLevel: "public",
meetingProvider: "",
mirotalkRoom: "",
meetingUrl: "",
replayUrl: "",
features: [],
maxAttendees: 18,
organizer: "厦门海岛游民",
},
{
id: "7",
city: "北京",
emoji: "🏛️",
date: "2026-07-30",
time: "19:00",
venue: "氪空间 望京SOHO",
address: "北京市朝阳区望京街10号望京SOHO T3",
description: "首都数字游民聚会,在望京科技圈交流北漂远程工作与创业故事。",
rsvpCount: 14,
gradientFrom: "#6366f1",
gradientTo: "#4f46e5",
attendees: [{ name: "陈思", photo: avatarForIndex(15) }, { name: "郑凯", photo: avatarForIndex(29) }],
isUpcoming: true,
mode: "hybrid",
accessLevel: "hosts",
meetingProvider: "mirotalk",
mirotalkRoom: "nomadcna-beijing-host-lab",
meetingUrl: "",
replayUrl: "",
features: ["group_video", "recording", "host_controls"],
maxAttendees: 26,
organizer: "北京活动主持组",
},
{
id: "10",
city: "南京",
emoji: "🍃",
date: "2026-05-05",
time: "19:00",
venue: "先锋书店 五台山店",
address: "南京市鼓楼区广州路173号五台山体育馆地下",
description: "金陵数字游民聚会,在南京最具文艺气息的书店里交流古都文化与远程工作。",
rsvpCount: 6,
gradientFrom: "#84cc16",
gradientTo: "#65a30d",
attendees: [{ name: "杨帆", photo: avatarForIndex(28) }],
isUpcoming: false,
mode: "offline",
accessLevel: "public",
meetingProvider: "",
mirotalkRoom: "",
meetingUrl: "",
replayUrl: "",
features: [],
maxAttendees: 18,
organizer: "南京游民小组",
},
{
id: "11",
city: "重庆",
emoji: "🌶️",
date: "2026-05-12",
time: "18:30",
venue: "洪崖洞观景咖啡馆",
address: "重庆市渝中区嘉滨路88号洪崖洞",
description: "山城数字游民聚会在魔幻8D城市里结识热爱火锅与远程工作的游民朋友。",
rsvpCount: 10,
gradientFrom: "#dc2626",
gradientTo: "#b91c1c",
attendees: [{ name: "黄薇", photo: avatarForIndex(22) }, { name: "孙浩", photo: avatarForIndex(18) }],
isUpcoming: false,
mode: "hybrid",
accessLevel: "members",
meetingProvider: "mirotalk",
mirotalkRoom: "nomadcna-chongqing-recap",
meetingUrl: "",
replayUrl: "",
features: ["group_video", "recording"],
maxAttendees: 30,
organizer: "重庆游民小组",
},
];
const weekdays = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
const copy = {
zh: {
center: "活动中心",
onlineReady: "线上活动已接入 MiroTalk P2P 视频房",
dataPolicy: "使用自建 MiroTalk 作为 WebRTC 信令/中继入口,按用户身份展示报名数据、视频入口、回放和主持人控场能力。",
onlineStats: "线上 / 混合",
mirotalkStats: "P2P 房间",
permissionStats: "权限层级",
allModes: "全部",
offline: "线下",
online: "线上",
hybrid: "混合",
people: "人报名",
max: "上限",
details: "查看详情",
joinRoom: "进入视频房",
joinChat: "进入群聊",
embedRoom: "在当前页打开",
closeEmbed: "收起房间",
loginRequired: "登录成员可进入",
vipRequired: "VIP 专属活动",
hostRequired: "主持人权限",
publicAccess: "公开",
membersAccess: "登录成员",
vipAccess: "VIP",
hostsAccess: "主持人",
organizer: "主办方",
featureTitle: "活动功能",
permissionTitle: "数据与权限",
visiblePublic: "公开:时间、城市、主题、报名数",
visibleMember: "登录成员:参会名单、线上房间、活动提醒",
visibleVip: "VIP回放、资料和专属圆桌",
visibleHost: "主持人:房间管理、录制和权限配置",
room: "房间",
provider: "视频服务",
chatService: "Web 群聊",
chatServer: "群聊入口",
relayServer: "信令/中继",
noAccess: "当前身份不能进入该视频房",
countPrefix: "共",
upcomingCountSuffix: "场即将举办的活动",
previousCountSuffix: "场往期活动",
emptyUpcoming: "暂无即将举办的活动",
emptyPrevious: "暂无往期活动记录",
authPublicHint: "公开活动可直接打开,登录后会使用你的社区身份进入房间。",
chatHint: "群聊默认进入 #nomadcna 与 #online-events。",
},
en: {
center: "Events Center",
onlineReady: "Online events now support MiroTalk P2P rooms",
dataPolicy: "Self-hosted MiroTalk provides the WebRTC signaling/relay entry while user identity controls attendee data, video access, replays, and host controls.",
onlineStats: "Online / hybrid",
mirotalkStats: "P2P rooms",
permissionStats: "Access tiers",
allModes: "All",
offline: "Offline",
online: "Online",
hybrid: "Hybrid",
people: "RSVPs",
max: "Limit",
details: "Details",
joinRoom: "Join video room",
joinChat: "Join group chat",
embedRoom: "Open inline",
closeEmbed: "Hide room",
loginRequired: "Members only",
vipRequired: "VIP only",
hostRequired: "Hosts only",
publicAccess: "Public",
membersAccess: "Members",
vipAccess: "VIP",
hostsAccess: "Hosts",
organizer: "Organizer",
featureTitle: "Event features",
permissionTitle: "Data & permissions",
visiblePublic: "Public: time, city, topic, RSVP count",
visibleMember: "Members: attendee list, online room, event reminders",
visibleVip: "VIP: replays, resources, private roundtables",
visibleHost: "Hosts: room management, recording, access rules",
room: "Room",
provider: "Video service",
chatService: "Web group chat",
chatServer: "Chat entry",
relayServer: "Signaling / relay",
noAccess: "Your current role cannot enter this video room",
countPrefix: "",
upcomingCountSuffix: "upcoming events",
previousCountSuffix: "past events",
emptyUpcoming: "No upcoming events yet",
emptyPrevious: "No past events yet",
authPublicHint: "Public events open directly. After login, your community identity is used in the room.",
chatHint: "Chat opens with #nomadcna and #online-events by default.",
},
};
function getCopy(locale: string) {
return locale === "zh" ? copy.zh : copy.en;
}
function formatMeetupDate(dateStr: string, time: string): string {
const normalizedDate = dateStr.includes("T") || dateStr.includes(" ")
? dateStr.slice(0, 10)
: dateStr;
const d = new Date(normalizedDate + "T" + time);
if (Number.isNaN(d.getTime())) return `${normalizedDate} ${time.slice(0, 5)}`;
const weekday = weekdays[d.getDay()];
const year = d.getFullYear();
const month = d.getMonth() + 1;
const day = d.getDate();
const timeFormatted = time.slice(0, 5);
return `${weekday}, ${year}${month}${day}日, ${timeFormatted}`;
}
function toMeetupMode(value: unknown): MeetupMode {
return value === "online" || value === "hybrid" || value === "offline" ? value : "offline";
}
function toAccessLevel(value: unknown): MeetupAccessLevel {
return value === "members" || value === "vip" || value === "hosts" || value === "public" ? value : "public";
}
function mapMeetup(item: Partial<Meetup> & { id: string; status?: string }): Meetup {
const mode = toMeetupMode(item.mode || (item.meetingUrl || item.mirotalkRoom ? "online" : "offline"));
return {
id: item.id,
city: item.city || (mode === "online" ? "线上" : "同城"),
emoji: item.emoji || (mode === "online" ? "💻" : "📍"),
date: item.date || new Date().toISOString().slice(0, 10),
time: item.time || "19:00",
venue: item.venue || (mode === "online" ? "MiroTalk 视频房" : "待定地点"),
address: item.address || (mode === "online" ? "线上活动" : "活动地址确认中"),
description: item.description || "数字游民交流活动。",
rsvpCount: Number(item.rsvpCount || 0),
gradientFrom: item.gradientFrom || (mode === "online" ? "#0f766e" : "#ff4d4f"),
gradientTo: item.gradientTo || (mode === "online" ? "#2563eb" : "#ff7a45"),
attendees: Array.isArray(item.attendees) ? item.attendees : [],
isUpcoming: Boolean(item.isUpcoming ?? true),
mode,
accessLevel: toAccessLevel(item.accessLevel),
meetingProvider: item.meetingProvider || (mode === "offline" ? "" : "mirotalk"),
mirotalkRoom: item.mirotalkRoom || "",
meetingUrl: item.meetingUrl || "",
replayUrl: item.replayUrl || "",
features: Array.isArray(item.features) ? item.features.map(String) : mode === "offline" ? [] : DEFAULT_ONLINE_FEATURES,
maxAttendees: Number(item.maxAttendees || 0),
organizer: item.organizer || "NomadCNA",
};
}
function modeLabel(mode: MeetupMode, locale: string) {
const c = getCopy(locale);
return mode === "online" ? c.online : mode === "hybrid" ? c.hybrid : c.offline;
}
function accessLabel(accessLevel: MeetupAccessLevel, locale: string) {
const c = getCopy(locale);
return {
public: c.publicAccess,
members: c.membersAccess,
vip: c.vipAccess,
hosts: c.hostsAccess,
}[accessLevel];
}
function viewerDisplayName(viewer: ViewerState): string {
const name = String(viewer.user?.name || "").trim();
if (name) return name;
const email = String(viewer.user?.email || "").trim();
return email ? email.split("@")[0] : "NomadCNA";
}
function isHostViewer(viewer: ViewerState): boolean {
const role = String(viewer.user?.role || viewer.user?.["memberRole"] || viewer.user?.["siteRole"] || "").toLowerCase();
return ["admin", "owner", "host", "organizer", "moderator"].includes(role);
}
function canAccessMeetup(meetup: Meetup, viewer: ViewerState): boolean {
if (meetup.accessLevel === "public") return true;
if (meetup.accessLevel === "members") return Boolean(viewer.user);
if (meetup.accessLevel === "vip") return Boolean(viewer.vip || viewer.user?.vip);
return isHostViewer(viewer);
}
function lockedReason(meetup: Meetup, locale: string) {
const c = getCopy(locale);
if (meetup.accessLevel === "members") return c.loginRequired;
if (meetup.accessLevel === "vip") return c.vipRequired;
if (meetup.accessLevel === "hosts") return c.hostRequired;
return c.noAccess;
}
function Badge({ children, tone = "gray" }: { children: ReactNode; tone?: "gray" | "red" | "green" | "blue" | "purple" }) {
const toneClass = {
gray: "border-gray-200 bg-gray-50 text-gray-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300",
red: "border-red-200 bg-red-50 text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300",
green: "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-300",
blue: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/60 dark:bg-blue-950/40 dark:text-blue-300",
purple: "border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-900/60 dark:bg-violet-950/40 dark:text-violet-300",
}[tone];
return (
<span className={`inline-flex min-h-7 items-center rounded-full border px-2.5 py-1 text-xs font-semibold ${toneClass}`}>
{children}
</span>
);
}
function MeetupCard({ meetup, locale, viewer, onSelect }: { meetup: Meetup; locale: string; viewer: ViewerState; onSelect: (meetup: Meetup) => void }) {
const c = getCopy(locale);
const formattedDate = formatMeetupDate(meetup.date, meetup.time);
const canJoin = canAccessMeetup(meetup, viewer);
const modeTone = meetup.mode === "online" ? "green" : meetup.mode === "hybrid" ? "blue" : "gray";
const accessTone = meetup.accessLevel === "vip" ? "purple" : meetup.accessLevel === "hosts" ? "red" : meetup.accessLevel === "members" ? "blue" : "gray";
return (
<article className="group flex h-full flex-col overflow-hidden rounded-2xl bg-white shadow-[0_2px_8px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)] transition-all duration-300 hover:-translate-y-1 hover:shadow-[0_12px_28px_rgba(0,0,0,0.12),0_0_0_1px_rgba(0,0,0,0.05)] dark:bg-gray-900 dark:shadow-[0_2px_8px_rgba(0,0,0,0.2),0_0_0_1px_rgba(255,255,255,0.05)]">
<div className="flex h-28 flex-col justify-between p-4 text-white" style={{ background: `linear-gradient(135deg, ${meetup.gradientFrom}, ${meetup.gradientTo})` }}>
<div className="flex flex-wrap gap-2">
<span className="rounded-full bg-white/18 px-2.5 py-1 text-xs font-semibold backdrop-blur">{modeLabel(meetup.mode, locale)}</span>
{meetup.meetingProvider === "mirotalk" && <span className="rounded-full bg-white/18 px-2.5 py-1 text-xs font-semibold backdrop-blur">MiroTalk</span>}
</div>
<p className="text-xs font-medium text-white/90 sm:text-sm">{formattedDate}</p>
</div>
<div className="flex flex-1 flex-col p-4 sm:p-5">
<div className="mb-3 flex items-start justify-between gap-3">
<h3 className="min-w-0 text-xl font-bold text-gray-900 dark:text-gray-100 sm:text-2xl">
<span className="mr-1">{meetup.emoji}</span>
{meetup.city}
</h3>
<span className="shrink-0 rounded-full bg-[#ff4d4f] px-2.5 py-1 text-xs font-semibold text-white">{meetup.rsvpCount} {c.people}</span>
</div>
<div className="mb-3 flex flex-wrap gap-2">
<Badge tone={modeTone}>{modeLabel(meetup.mode, locale)}</Badge>
<Badge tone={accessTone}>{accessLabel(meetup.accessLevel, locale)}</Badge>
{!!meetup.maxAttendees && <Badge>{c.max} {meetup.maxAttendees}</Badge>}
</div>
<p className="mb-0.5 text-sm font-medium text-gray-600 dark:text-gray-400">@ {meetup.venue}</p>
<p className="mb-3 text-xs text-gray-500 dark:text-gray-500">{meetup.address}</p>
<p className="mb-3 line-clamp-2 flex-1 text-sm text-gray-600 dark:text-gray-400">{meetup.description}</p>
{meetup.features.length > 0 && (
<div className="mb-4 flex flex-wrap gap-1.5">
{featureLabels(meetup.features, locale).slice(0, 3).map((feature) => (
<span key={feature} className="rounded-md bg-gray-100 px-2 py-1 text-xs text-gray-600 dark:bg-gray-800 dark:text-gray-300">{feature}</span>
))}
</div>
)}
<button
type="button"
onClick={() => onSelect(meetup)}
className="mb-4 inline-flex w-fit cursor-pointer items-center gap-1 text-left text-sm font-semibold text-[#ff4d4f] transition-colors hover:text-[#ff7a45] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#ff4d4f]/40"
>
{meetup.mode === "offline" ? c.details : canJoin ? c.joinRoom : lockedReason(meetup, locale)}
<span aria-hidden></span>
</button>
<div className="mt-auto flex items-center gap-1 border-t border-gray-100 pt-4 dark:border-gray-700">
<div className="flex -space-x-2">
{meetup.attendees.map((a, index) => (
<img key={`${a.name}-${index}`} src={mediaUrl(a.photo)} alt={a.name} className="h-7 w-7 rounded-full border-2 border-white object-cover shadow-sm dark:border-gray-800 sm:h-8 sm:w-8" title={a.name} />
))}
</div>
<span className="ml-1 text-xs text-gray-400 dark:text-gray-500">{locale === "zh" ? "已报名成员" : "attendees"}</span>
</div>
</div>
</article>
);
}
function MeetupDetailModal({ meetup, locale, viewer, onClose }: { meetup: Meetup; locale: string; viewer: ViewerState; onClose: () => void }) {
const c = getCopy(locale);
const [embedOpen, setEmbedOpen] = useState(false);
const canJoin = canAccessMeetup(meetup, viewer);
const joinUrl = buildMiroTalkJoinUrl(meetup, viewerDisplayName(viewer));
const chatUrl = buildMeetupChatUrl();
const mirotalkServer = getMiroTalkServerLabel();
const loungeServer = getLoungeServerLabel();
const isVideoEvent = meetup.mode !== "offline";
return (
<div className="fixed inset-0 z-[9999] overflow-y-auto">
<button type="button" className="fixed inset-0 cursor-default bg-black/55 backdrop-blur-sm" onClick={onClose} aria-label={locale === "zh" ? "关闭活动详情" : "Close event details"} />
<div className="relative mx-auto flex min-h-svh w-full max-w-4xl items-center px-3 py-6 sm:px-6">
<section className="relative w-full overflow-hidden rounded-2xl bg-white shadow-2xl dark:bg-gray-900 dark:ring-1 dark:ring-gray-800">
<div className="p-5 text-white sm:p-7" style={{ background: `linear-gradient(135deg, ${meetup.gradientFrom}, ${meetup.gradientTo})` }}>
<button
type="button"
onClick={onClose}
className="absolute right-4 top-4 inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-full bg-white/15 text-white transition hover:bg-white/25 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
aria-label={locale === "zh" ? "关闭" : "Close"}
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
<div className="flex flex-wrap gap-2 pr-12">
<span className="rounded-full bg-white/18 px-2.5 py-1 text-xs font-semibold backdrop-blur">{modeLabel(meetup.mode, locale)}</span>
<span className="rounded-full bg-white/18 px-2.5 py-1 text-xs font-semibold backdrop-blur">{accessLabel(meetup.accessLevel, locale)}</span>
{meetup.meetingProvider === "mirotalk" && <span className="rounded-full bg-white/18 px-2.5 py-1 text-xs font-semibold backdrop-blur">{MIROTALK_PROVIDER_LABEL}</span>}
</div>
<h2 className="mt-5 text-2xl font-bold sm:text-3xl">{meetup.emoji} {meetup.city} · {meetup.venue}</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-white/88">{meetup.description}</p>
</div>
<div className="grid gap-5 p-5 sm:p-7 lg:grid-cols-[1.1fr_0.9fr]">
<div className="space-y-5">
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-xl border border-gray-100 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-950">
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">{locale === "zh" ? "时间" : "Time"}</p>
<p className="mt-1 text-sm font-semibold text-gray-900 dark:text-gray-100">{formatMeetupDate(meetup.date, meetup.time)}</p>
</div>
<div className="rounded-xl border border-gray-100 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-950">
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">{c.organizer}</p>
<p className="mt-1 text-sm font-semibold text-gray-900 dark:text-gray-100">{meetup.organizer}</p>
</div>
</div>
<div className="rounded-xl border border-gray-100 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-950">
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">{locale === "zh" ? "地点" : "Location"}</p>
<p className="mt-1 text-sm font-semibold text-gray-900 dark:text-gray-100">{meetup.venue}</p>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{meetup.address}</p>
</div>
{isVideoEvent && (
<div className="rounded-xl border border-emerald-100 bg-emerald-50/70 p-4 dark:border-emerald-900/50 dark:bg-emerald-950/30">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-emerald-700 dark:text-emerald-300">{c.provider}</p>
<p className="mt-1 text-sm font-semibold text-gray-900 dark:text-gray-100">{MIROTALK_PROVIDER_LABEL}</p>
<p className="mt-1 break-all text-xs text-gray-500 dark:text-gray-400">{c.room}: {meetup.mirotalkRoom || meetup.id}</p>
<p className="mt-1 break-all text-xs text-gray-500 dark:text-gray-400">{c.relayServer}: {mirotalkServer}</p>
<p className="mt-2 text-xs font-semibold uppercase tracking-wide text-emerald-700 dark:text-emerald-300">{c.chatService}</p>
<p className="mt-1 text-sm font-semibold text-gray-900 dark:text-gray-100">{WEB_CHAT_PROVIDER_LABEL}</p>
<p className="mt-1 break-all text-xs text-gray-500 dark:text-gray-400">{c.chatServer}: {loungeServer}</p>
</div>
{canJoin ? (
<div className="flex flex-wrap gap-2">
<a
href={joinUrl}
target="_blank"
rel="noreferrer"
className="inline-flex min-h-10 cursor-pointer items-center justify-center rounded-lg bg-[#ff4d4f] px-4 py-2 text-sm font-semibold text-white transition hover:bg-[#ff7a45] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#ff4d4f]/40"
>
{c.joinRoom}
</a>
<a
href={chatUrl}
target="_blank"
rel="noreferrer"
className="inline-flex min-h-10 cursor-pointer items-center justify-center rounded-lg border border-emerald-200 bg-white px-4 py-2 text-sm font-semibold text-emerald-700 transition hover:bg-emerald-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400 dark:border-emerald-800 dark:bg-gray-900 dark:text-emerald-300 dark:hover:bg-emerald-950/50"
>
{c.joinChat}
</a>
<button
type="button"
onClick={() => setEmbedOpen((open) => !open)}
className="inline-flex min-h-10 cursor-pointer items-center justify-center rounded-lg border border-emerald-200 bg-white px-4 py-2 text-sm font-semibold text-emerald-700 transition hover:bg-emerald-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400 dark:border-emerald-800 dark:bg-gray-900 dark:text-emerald-300 dark:hover:bg-emerald-950/50"
>
{embedOpen ? c.closeEmbed : c.embedRoom}
</button>
</div>
) : (
<p className="rounded-lg border border-red-200 bg-white px-3 py-2 text-sm font-semibold text-red-600 dark:border-red-900/60 dark:bg-gray-900 dark:text-red-300">{lockedReason(meetup, locale)}</p>
)}
</div>
{canJoin && meetup.accessLevel === "public" && (
<p className="mt-3 text-xs text-emerald-700 dark:text-emerald-300">{c.authPublicHint}</p>
)}
{canJoin && (
<p className="mt-2 text-xs text-emerald-700 dark:text-emerald-300">{c.chatHint}</p>
)}
{embedOpen && canJoin && (
<iframe
title={`MiroTalk ${meetup.city}`}
src={joinUrl}
className="mt-4 h-[360px] w-full rounded-xl border border-emerald-200 bg-black dark:border-emerald-900 sm:h-[460px]"
allow="camera; microphone; display-capture; fullscreen; clipboard-read; clipboard-write"
referrerPolicy="no-referrer-when-downgrade"
/>
)}
</div>
)}
</div>
<aside className="space-y-5">
<div className="rounded-xl border border-gray-100 p-4 dark:border-gray-800">
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">{c.featureTitle}</h3>
<div className="mt-3 flex flex-wrap gap-2">
{(featureLabels(meetup.features, locale).length ? featureLabels(meetup.features, locale) : [modeLabel(meetup.mode, locale)]).map((feature) => (
<Badge key={feature} tone={meetup.mode === "offline" ? "gray" : "green"}>{feature}</Badge>
))}
</div>
</div>
<div className="rounded-xl border border-gray-100 p-4 dark:border-gray-800">
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">{c.permissionTitle}</h3>
<div className="mt-3 space-y-2 text-sm text-gray-600 dark:text-gray-400">
<p>{c.visiblePublic}</p>
<p>{c.visibleMember}</p>
<p>{c.visibleVip}</p>
<p>{c.visibleHost}</p>
</div>
</div>
<div className="rounded-xl border border-gray-100 p-4 dark:border-gray-800">
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-gray-500 dark:text-gray-400">{locale === "zh" ? "当前权限" : "Your access"}</span>
<Badge tone={canJoin ? "green" : "red"}>{canJoin ? accessLabel(meetup.accessLevel, locale) : lockedReason(meetup, locale)}</Badge>
</div>
<div className="mt-3 flex items-center justify-between gap-3">
<span className="text-sm text-gray-500 dark:text-gray-400">{locale === "zh" ? "报名人数" : "RSVPs"}</span>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">{meetup.rsvpCount}{meetup.maxAttendees ? ` / ${meetup.maxAttendees}` : ""}</span>
</div>
</div>
</aside>
</div>
</section>
</div>
</div>
);
}
export default function MeetupsPage() {
const { t } = useTranslation("meetups");
const locale = useLocale();
const c = getCopy(locale);
const [activeTab, setActiveTab] = useState<"upcoming" | "previous">("upcoming");
const [modeFilter, setModeFilter] = useState<ModeFilter>("all");
const [meetups, setMeetups] = useState<Meetup[]>(allMeetups);
const [selectedMeetup, setSelectedMeetup] = useState<Meetup | null>(null);
const [viewer, setViewer] = useState<ViewerState>({ loaded: false, user: null, vip: false });
useEffect(() => {
apiFetch<{ items: Array<Partial<Meetup> & { id: string; status?: string }> }>("/api/meetups")
.then((data) => {
if (data.items?.length) {
setMeetups(data.items.map(mapMeetup));
}
})
.catch(() => setMeetups(allMeetups));
}, []);
useEffect(() => {
let cancelled = false;
apiFetch<{ user: ViewerUser | null }>("/api/auth/me")
.then((data) => {
if (!cancelled) setViewer({ loaded: true, user: data.user, vip: Boolean(data.user?.vip) });
})
.catch(() => {
if (!cancelled) setViewer({ loaded: true, user: null, vip: false });
});
return () => {
cancelled = true;
};
}, []);
const upcomingMeetups = useMemo(() => meetups.filter((m) => m.isUpcoming), [meetups]);
const previousMeetups = useMemo(() => meetups.filter((m) => !m.isUpcoming), [meetups]);
const tabMeetups = activeTab === "upcoming" ? upcomingMeetups : previousMeetups;
const displayedMeetups = useMemo(
() => tabMeetups.filter((meetup) => modeFilter === "all" || meetup.mode === modeFilter),
[modeFilter, tabMeetups]
);
const onlineCount = useMemo(() => upcomingMeetups.filter((m) => m.mode !== "offline").length, [upcomingMeetups]);
const mirotalkCount = useMemo(() => meetups.filter((m) => m.meetingProvider === "mirotalk").length, [meetups]);
const protectedCount = useMemo(() => meetups.filter((m) => m.accessLevel !== "public").length, [meetups]);
const modeFilters: Array<{ value: ModeFilter; label: string }> = [
{ value: "all", label: c.allModes },
{ value: "offline", label: c.offline },
{ value: "online", label: c.online },
{ value: "hybrid", label: c.hybrid },
];
return (
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
<main className="mx-auto max-w-[1400px] px-4 py-8 sm:px-6 sm:py-12 md:px-10">
<header className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="flex items-center gap-2 text-2xl font-bold text-gray-900 dark:text-gray-100 sm:text-3xl">
<span className="h-8 w-1 rounded-full bg-[#ff4d4f]" />
{t("title")}
</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400 sm:text-base">
{t("subtitle")}
</p>
</div>
<Link
href="/meetups/host"
className="inline-flex min-h-11 shrink-0 cursor-pointer items-center justify-center gap-2 rounded-xl bg-[#ff4d4f] px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-[#ff7a45] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#ff4d4f]/40"
>
<span aria-hidden></span>
{t("hostMeetup")}
</Link>
</header>
<section className="mb-8 overflow-hidden border-y border-gray-100 bg-white/70 px-4 py-5 dark:border-gray-800 dark:bg-gray-900/70 sm:px-6">
<div className="grid gap-5 lg:grid-cols-[1.2fr_1fr] lg:items-center">
<div>
<p className="text-sm font-semibold text-[#ff4d4f]">{c.center}</p>
<h2 className="mt-1 text-xl font-bold text-gray-900 dark:text-gray-100 sm:text-2xl">{c.onlineReady}</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-gray-600 dark:text-gray-400">{c.dataPolicy}</p>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="border-l border-gray-200 pl-3 dark:border-gray-700">
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">{onlineCount}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{c.onlineStats}</p>
</div>
<div className="border-l border-gray-200 pl-3 dark:border-gray-700">
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">{mirotalkCount}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{c.mirotalkStats}</p>
</div>
<div className="border-l border-gray-200 pl-3 dark:border-gray-700">
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">{protectedCount}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{c.permissionStats}</p>
</div>
</div>
</div>
</section>
<div className="mb-5 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="inline-flex w-fit gap-1 rounded-xl border border-gray-100 bg-white p-1 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<button type="button" onClick={() => setActiveTab("upcoming")} className={`cursor-pointer rounded-lg px-5 py-2.5 text-sm font-medium transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-[#ff4d4f]/40 ${activeTab === "upcoming" ? "bg-[#ff4d4f] text-white shadow-sm" : "text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100"}`}>
{t("upcoming")}
</button>
<button type="button" onClick={() => setActiveTab("previous")} className={`cursor-pointer rounded-lg px-5 py-2.5 text-sm font-medium transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-[#ff4d4f]/40 ${activeTab === "previous" ? "bg-[#ff4d4f] text-white shadow-sm" : "text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100"}`}>
{t("previous")}
</button>
</div>
<div className="flex flex-wrap gap-2">
{modeFilters.map((filter) => (
<button
key={filter.value}
type="button"
onClick={() => setModeFilter(filter.value)}
className={`min-h-9 cursor-pointer rounded-full border px-3 py-1.5 text-sm font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-[#ff4d4f]/40 ${
modeFilter === filter.value
? "border-[#ff4d4f] bg-[#ff4d4f] text-white"
: "border-gray-200 bg-white text-gray-600 hover:border-gray-300 hover:text-gray-900 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-gray-600"
}`}
>
{filter.label}
</button>
))}
</div>
</div>
<p className="mb-6 text-sm text-gray-500 dark:text-gray-400">
{c.countPrefix}
<span className="mx-1 font-semibold text-gray-700 dark:text-gray-300">{displayedMeetups.length}</span>
{activeTab === "upcoming" ? c.upcomingCountSuffix : c.previousCountSuffix}
</p>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 sm:gap-6 lg:grid-cols-3">
{displayedMeetups.map((meetup) => (
<MeetupCard key={meetup.id} meetup={meetup} locale={locale} viewer={viewer} onSelect={setSelectedMeetup} />
))}
</div>
{displayedMeetups.length === 0 && (
<div className="py-16 text-center sm:py-24">
<span className="mb-4 block text-5xl sm:text-6xl">🍹</span>
<p className="text-base text-gray-500 dark:text-gray-400 sm:text-lg">
{activeTab === "upcoming" ? c.emptyUpcoming : c.emptyPrevious}
</p>
</div>
)}
</main>
{selectedMeetup && (
<MeetupDetailModal key={selectedMeetup.id} meetup={selectedMeetup} locale={locale} viewer={viewer} onClose={() => setSelectedMeetup(null)} />
)}
</div>
);
}