890 lines
40 KiB
TypeScript
890 lines
40 KiB
TypeScript
"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>
|
||
);
|
||
}
|