180 lines
15 KiB
TypeScript
180 lines
15 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { Link, useTranslation } from "@/i18n/navigation";
|
||
import { apiFetch } from "@/app/lib/api-client";
|
||
|
||
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;
|
||
}
|
||
|
||
const allMeetups: Meetup[] = [
|
||
{ id: "1", city: "大理", emoji: "🏯", date: "2026-03-15", time: "14:00", venue: "大理古城人民路咖啡馆", address: "大理市大理古城人民路中段", description: "苍山脚下,洱海边,与大理的慢生活游民们一起喝咖啡、晒太阳、聊人生。", rsvpCount: 12, gradientFrom: "#3b82f6", gradientTo: "#4f46e5", attendees: [{ name: "林晓", photo: "https://i.pravatar.cc/150?img=1" }, { name: "张明", photo: "https://i.pravatar.cc/150?img=2" }, { name: "陈静", photo: "https://i.pravatar.cc/150?img=3" }, { name: "周杰", photo: "https://i.pravatar.cc/150?img=4" }, { name: "吴芳", photo: "https://i.pravatar.cc/150?img=5" }], isUpcoming: true },
|
||
{ id: "2", city: "成都", emoji: "🐼", date: "2026-03-18", time: "19:00", venue: "方所书店", address: "成都市锦江区中纱帽街8号远洋太古里", description: "天府之国游民聚会,在文艺书店里品茶聊天,探讨成都的慢生活与远程工作可能性。", rsvpCount: 9, gradientFrom: "#22c55e", gradientTo: "#10b981", attendees: [{ name: "杨帆", photo: "https://i.pravatar.cc/150?img=6" }, { name: "赵磊", photo: "https://i.pravatar.cc/150?img=7" }, { name: "黄薇", photo: "https://i.pravatar.cc/150?img=8" }, { name: "孙浩", photo: "https://i.pravatar.cc/150?img=9" }], isUpcoming: true },
|
||
{ id: "3", city: "深圳", emoji: "🌃", date: "2026-03-20", time: "19:30", venue: "WeWork 卓越前海壹号", address: "深圳市南山区前海深港合作区卓越前海壹号", description: "深圳数字游民线下见面会,探讨创业、远程工作与湾区发展机遇。", rsvpCount: 15, gradientFrom: "#ff4d4f", gradientTo: "#ff7a45", attendees: [{ name: "李娜", photo: "https://i.pravatar.cc/150?img=10" }, { name: "王强", photo: "https://i.pravatar.cc/150?img=11" }, { name: "刘洋", photo: "https://i.pravatar.cc/150?img=12" }, { name: "陈思", photo: "https://i.pravatar.cc/150?img=13" }, { name: "郑凯", photo: "https://i.pravatar.cc/150?img=14" }, { name: "何琳", photo: "https://i.pravatar.cc/150?img=15" }], isUpcoming: true },
|
||
{ id: "4", city: "上海", emoji: "🏙️", date: "2026-03-22", time: "19:00", venue: "裸心社 新天地", address: "上海市黄浦区马当路388号复兴SOHO广场", description: "魔都数字游民月度聚会,在时尚共享空间交流国际化远程工作与创业心得。", rsvpCount: 18, gradientFrom: "#ec4899", gradientTo: "#ef4444", attendees: [{ name: "许婷", photo: "https://i.pravatar.cc/150?img=16" }, { name: "马超", photo: "https://i.pravatar.cc/150?img=17" }, { name: "罗敏", photo: "https://i.pravatar.cc/150?img=18" }, { name: "林晓", photo: "https://i.pravatar.cc/150?img=19" }, { name: "张明", photo: "https://i.pravatar.cc/150?img=20" }], isUpcoming: true },
|
||
{ id: "5", city: "杭州", emoji: "⛲", date: "2026-03-25", time: "18:00", venue: "梦想小镇·良仓咖啡", address: "杭州市余杭区梦想小镇互联网村", description: "西湖畔数字游民聚会,在创业氛围浓厚的梦想小镇交流产品与运营经验。", rsvpCount: 11, gradientFrom: "#06b6d4", gradientTo: "#0d9488", attendees: [{ name: "陈静", photo: "https://i.pravatar.cc/150?img=21" }, { name: "周杰", photo: "https://i.pravatar.cc/150?img=22" }, { name: "吴芳", photo: "https://i.pravatar.cc/150?img=23" }, { name: "杨帆", photo: "https://i.pravatar.cc/150?img=24" }], isUpcoming: true },
|
||
{ id: "6", city: "厦门", emoji: "🌊", date: "2026-03-28", time: "15:00", venue: "鼓浪屿海角8号咖啡馆", address: "厦门市思明区鼓浪屿龙头路", description: "海岛慢生活聚会,在鼓浪屿的文艺咖啡馆里结识热爱海边远程工作的游民朋友。", rsvpCount: 7, gradientFrom: "#0ea5e9", gradientTo: "#0284c7", attendees: [{ name: "赵磊", photo: "https://i.pravatar.cc/150?img=25" }, { name: "黄薇", photo: "https://i.pravatar.cc/150?img=26" }, { name: "孙浩", photo: "https://i.pravatar.cc/150?img=27" }], isUpcoming: true },
|
||
{ id: "7", city: "北京", emoji: "🏛️", date: "2026-03-30", time: "19:00", venue: "氪空间 望京SOHO", address: "北京市朝阳区望京街10号望京SOHO T3", description: "首都数字游民聚会,在望京科技圈交流北漂远程工作与创业故事。", rsvpCount: 14, gradientFrom: "#6366f1", gradientTo: "#4f46e5", attendees: [{ name: "李娜", photo: "https://i.pravatar.cc/150?img=28" }, { name: "王强", photo: "https://i.pravatar.cc/150?img=29" }, { name: "刘洋", photo: "https://i.pravatar.cc/150?img=30" }, { name: "陈思", photo: "https://i.pravatar.cc/150?img=31" }, { name: "郑凯", photo: "https://i.pravatar.cc/150?img=32" }], isUpcoming: true },
|
||
{ id: "8", city: "昆明", emoji: "🌸", date: "2026-04-02", time: "14:00", venue: "翠湖边上咖啡馆", address: "昆明市五华区翠湖东路", description: "春城数字游民聚会,在四季如春的昆明享受慢节奏,交流旅居与远程工作心得。", rsvpCount: 8, gradientFrom: "#f472b6", gradientTo: "#ec4899", attendees: [{ name: "何琳", photo: "https://i.pravatar.cc/150?img=33" }, { name: "许婷", photo: "https://i.pravatar.cc/150?img=34" }, { name: "马超", photo: "https://i.pravatar.cc/150?img=35" }, { name: "罗敏", photo: "https://i.pravatar.cc/150?img=36" }], isUpcoming: true },
|
||
{ id: "9", city: "广州", emoji: "🌺", date: "2026-04-05", time: "19:00", venue: "TIMETABLE 天德广场", address: "广州市天河区临江大道395号天德广场", description: "羊城数字游民聚会,在珠江新城的高端共享空间探讨粤港澳大湾区远程工作机遇。", rsvpCount: 12, gradientFrom: "#f59e0b", gradientTo: "#ea580c", attendees: [{ name: "林晓", photo: "https://i.pravatar.cc/150?img=37" }, { name: "张明", photo: "https://i.pravatar.cc/150?img=38" }, { name: "陈静", photo: "https://i.pravatar.cc/150?img=39" }, { name: "周杰", photo: "https://i.pravatar.cc/150?img=40" }, { name: "吴芳", photo: "https://i.pravatar.cc/150?img=41" }], isUpcoming: true },
|
||
{ id: "10", city: "南京", emoji: "🍃", date: "2026-03-05", time: "19:00", venue: "先锋书店 五台山店", address: "南京市鼓楼区广州路173号五台山体育馆地下", description: "金陵数字游民聚会,在南京最具文艺气息的书店里交流古都文化与远程工作。", rsvpCount: 6, gradientFrom: "#84cc16", gradientTo: "#65a30d", attendees: [{ name: "杨帆", photo: "https://i.pravatar.cc/150?img=42" }, { name: "赵磊", photo: "https://i.pravatar.cc/150?img=43" }], isUpcoming: false },
|
||
{ id: "11", city: "重庆", emoji: "🌶️", date: "2026-03-02", time: "18:30", venue: "洪崖洞观景咖啡馆", address: "重庆市渝中区嘉滨路88号洪崖洞", description: "山城数字游民聚会,在魔幻8D城市里结识热爱火锅与远程工作的游民朋友。", rsvpCount: 10, gradientFrom: "#dc2626", gradientTo: "#b91c1c", attendees: [{ name: "黄薇", photo: "https://i.pravatar.cc/150?img=44" }, { name: "孙浩", photo: "https://i.pravatar.cc/150?img=45" }, { name: "李娜", photo: "https://i.pravatar.cc/150?img=46" }, { name: "王强", photo: "https://i.pravatar.cc/150?img=47" }], isUpcoming: false },
|
||
{ id: "12", city: "长沙", emoji: "🎪", date: "2026-02-28", time: "19:00", venue: "梅溪湖创新中心 星巴克", address: "长沙市岳麓区梅溪湖国际新城", description: "星城数字游民聚会,在网红城市长沙交流新媒体、直播与远程工作新业态。", rsvpCount: 8, gradientFrom: "#f97316", gradientTo: "#ea580c", attendees: [{ name: "刘洋", photo: "https://i.pravatar.cc/150?img=48" }, { name: "陈思", photo: "https://i.pravatar.cc/150?img=49" }, { name: "郑凯", photo: "https://i.pravatar.cc/150?img=50" }], isUpcoming: false },
|
||
];
|
||
|
||
const weekdays = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
|
||
|
||
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);
|
||
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 mapMeetup(item: Partial<Meetup> & { id: string; status?: string }): Meetup {
|
||
return {
|
||
id: item.id,
|
||
city: item.city || "线上",
|
||
emoji: item.emoji || "📍",
|
||
date: item.date || new Date().toISOString().slice(0, 10),
|
||
time: item.time || "19:00",
|
||
venue: item.venue || "待定地点",
|
||
address: item.address || "活动地址确认中",
|
||
description: item.description || "数字游民同城交流活动。",
|
||
rsvpCount: Number(item.rsvpCount || 0),
|
||
gradientFrom: item.gradientFrom || "#ff4d4f",
|
||
gradientTo: item.gradientTo || "#ff7a45",
|
||
attendees: Array.isArray(item.attendees) ? item.attendees : [],
|
||
isUpcoming: Boolean(item.isUpcoming ?? true),
|
||
};
|
||
}
|
||
|
||
function MeetupCard({ meetup }: { meetup: Meetup }) {
|
||
const formattedDate = formatMeetupDate(meetup.date, meetup.time);
|
||
return (
|
||
<article className="group bg-white dark:bg-gray-900 rounded-2xl overflow-hidden shadow-[0_2px_8px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.2),0_0_0_1px_rgba(255,255,255,0.05)] hover:shadow-[0_12px_28px_rgba(0,0,0,0.12),0_0_0_1px_rgba(0,0,0,0.05)] dark:hover:shadow-[0_12px_28px_rgba(0,0,0,0.3)] hover:-translate-y-2 transition-all duration-300 cursor-pointer flex flex-col h-full">
|
||
<div className="h-24 sm:h-28 flex flex-col justify-end p-4 text-white" style={{ background: `linear-gradient(135deg, ${meetup.gradientFrom}, ${meetup.gradientTo})` }}>
|
||
<p className="text-xs sm:text-sm text-white/90 font-medium">{formattedDate}</p>
|
||
</div>
|
||
<div className="flex-1 flex flex-col p-4 sm:p-5">
|
||
<div className="flex items-center justify-between gap-3 mb-2">
|
||
<h3 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">{meetup.emoji} {meetup.city}</h3>
|
||
<span className="shrink-0 bg-[#ff4d4f] text-white text-xs font-semibold px-2.5 py-1 rounded-full">{meetup.rsvpCount} 人报名</span>
|
||
</div>
|
||
<p className="text-sm text-gray-600 dark:text-gray-400 font-medium mb-0.5">@ {meetup.venue}</p>
|
||
<p className="text-xs text-gray-500 dark:text-gray-500 mb-3">{meetup.address}</p>
|
||
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2 flex-1 mb-2">{meetup.description}</p>
|
||
<button className="text-sm font-medium text-[#ff4d4f] hover:text-[#ff7a45] transition-colors text-left w-fit">查看详情 →</button>
|
||
<div className="flex items-center gap-1 mt-4 pt-4 border-t border-gray-100 dark:border-gray-700">
|
||
<div className="flex -space-x-2">
|
||
{meetup.attendees.map((a) => (
|
||
<img key={a.name} src={a.photo} alt={a.name} className="w-7 h-7 sm:w-8 sm:h-8 rounded-full border-2 border-white dark:border-gray-800 shadow-sm object-cover" title={a.name} />
|
||
))}
|
||
</div>
|
||
<span className="text-xs text-gray-400 dark:text-gray-500 ml-1">已报名成员</span>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
export default function MeetupsPage() {
|
||
const { t } = useTranslation("meetups");
|
||
const [activeTab, setActiveTab] = useState<"upcoming" | "previous">("upcoming");
|
||
const [meetups, setMeetups] = useState<Meetup[]>(allMeetups);
|
||
|
||
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));
|
||
}, []);
|
||
|
||
const upcomingMeetups = useMemo(() => meetups.filter((m) => m.isUpcoming), [meetups]);
|
||
const previousMeetups = useMemo(() => meetups.filter((m) => !m.isUpcoming), [meetups]);
|
||
const displayedMeetups = activeTab === "upcoming" ? upcomingMeetups : previousMeetups;
|
||
const totalCount = activeTab === "upcoming" ? upcomingMeetups.length : previousMeetups.length;
|
||
|
||
return (
|
||
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
|
||
<main className="max-w-[1400px] mx-auto px-4 sm:px-6 md:px-10 py-8 sm:py-12">
|
||
<header className="mb-8 flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||
<div>
|
||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||
<span className="w-1 h-8 bg-[#ff4d4f] rounded-full" />
|
||
{t("title")}
|
||
</h1>
|
||
<p className="text-gray-500 dark:text-gray-400 mt-1 text-sm sm:text-base">
|
||
{t("subtitle")}
|
||
</p>
|
||
</div>
|
||
<Link
|
||
href="/meetups/host"
|
||
className="shrink-0 inline-flex items-center gap-2 px-4 py-2.5 rounded-xl bg-[#ff4d4f] text-white text-sm font-medium hover:bg-[#ff7a45] transition-colors"
|
||
>
|
||
<span>🎤</span>
|
||
{t("hostMeetup")}
|
||
</Link>
|
||
</header>
|
||
|
||
<div className="flex gap-1 p-1 bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-100 dark:border-gray-800 mb-8 inline-flex">
|
||
<button onClick={() => setActiveTab("upcoming")} className={`px-5 py-2.5 rounded-lg text-sm font-medium transition-all ${activeTab === "upcoming" ? "bg-[#ff4d4f] text-white shadow-sm" : "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800"}`}>
|
||
{t("upcoming")}
|
||
</button>
|
||
<button onClick={() => setActiveTab("previous")} className={`px-5 py-2.5 rounded-lg text-sm font-medium transition-all ${activeTab === "previous" ? "bg-[#ff4d4f] text-white shadow-sm" : "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800"}`}>
|
||
{t("previous")}
|
||
</button>
|
||
</div>
|
||
|
||
<p className="text-gray-500 dark:text-gray-400 text-sm mb-6">
|
||
{activeTab === "upcoming" ? "共" : "共"}
|
||
<span className="font-semibold text-gray-700 dark:text-gray-300 mx-1">{totalCount}</span>
|
||
{activeTab === "upcoming" ? "场即将举办的聚会" : "场往期聚会"}
|
||
</p>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 sm:gap-6">
|
||
{displayedMeetups.map((meetup) => (
|
||
<MeetupCard key={meetup.id} meetup={meetup} />
|
||
))}
|
||
</div>
|
||
|
||
{displayedMeetups.length === 0 && (
|
||
<div className="text-center py-16 sm:py-24">
|
||
<span className="text-5xl sm:text-6xl mb-4 block">🍹</span>
|
||
<p className="text-gray-500 dark:text-gray-400 text-base sm:text-lg">
|
||
{activeTab === "upcoming" ? "暂无即将举办的聚会" : "暂无往期聚会记录"}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|