-
-
setForm((f) => ({ ...f, maxAttendees: e.target.value }))}
- className="form-input w-full border border-gray-200 dark:border-gray-700 rounded-xl px-4 py-3 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
- />
+
+ {isOnlineMode && (
+
+
+
{c.onlineSettings}
+
{c.onlineHint}
+
{c.defaultServer}: {mirotalkServer}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
setField("mirotalkRoom", e.target.value)}
+ className="form-input w-full rounded-xl border border-gray-200 bg-white px-4 py-3 text-gray-900 placeholder-gray-400 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
+ />
+
{c.roomSuggestion}: {roomToUse}
+
+
+
+
+ setField("meetingUrl", e.target.value)}
+ className="form-input w-full rounded-xl border border-gray-200 bg-white px-4 py-3 text-gray-900 placeholder-gray-400 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
+ />
+
+
+
+
+ )}
+
- 提交后会进入本机 PocketBase 的 meetups 表,审核通过前状态为 pending。
+ {c.pendingHint}
diff --git a/app/[locale]/meetups/page.tsx b/app/[locale]/meetups/page.tsx
index c6082d0..e0776a1 100644
--- a/app/[locale]/meetups/page.tsx
+++ b/app/[locale]/meetups/page.tsx
@@ -1,8 +1,21 @@
"use client";
-import { useEffect, useMemo, useState } from "react";
-import { Link, useTranslation } from "@/i18n/navigation";
+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";
interface Attendee {
name: string;
@@ -23,30 +36,384 @@ interface Meetup {
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: "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 },
+ {
+ 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: "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" }],
+ 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: "https://i.pravatar.cc/150?img=4" }, { name: "张明", photo: "https://i.pravatar.cc/150?img=5" }, { name: "陈静", photo: "https://i.pravatar.cc/150?img=6" }],
+ 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: "https://i.pravatar.cc/150?img=7" }, { name: "赵磊", photo: "https://i.pravatar.cc/150?img=8" }],
+ 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: "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" }],
+ 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: "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" }],
+ 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: "https://i.pravatar.cc/150?img=22" }, { name: "吴芳", photo: "https://i.pravatar.cc/150?img=23" }],
+ 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: "https://i.pravatar.cc/150?img=25" }, { name: "黄薇", photo: "https://i.pravatar.cc/150?img=26" }],
+ 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: "https://i.pravatar.cc/150?img=31" }, { name: "郑凯", photo: "https://i.pravatar.cc/150?img=32" }],
+ 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: "https://i.pravatar.cc/150?img=42" }],
+ 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: "https://i.pravatar.cc/150?img=44" }, { name: "孙浩", photo: "https://i.pravatar.cc/150?img=45" }],
+ 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;
@@ -55,57 +422,325 @@ function formatMeetupDate(dateStr: string, time: string): string {
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
& { id: string; status?: string }): Meetup {
+ const mode = toMeetupMode(item.mode || (item.meetingUrl || item.mirotalkRoom ? "online" : "offline"));
return {
id: item.id,
- city: item.city || "线上",
- emoji: item.emoji || "📍",
+ 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 || "待定地点",
- address: item.address || "活动地址确认中",
- description: item.description || "数字游民同城交流活动。",
+ venue: item.venue || (mode === "online" ? "MiroTalk 视频房" : "待定地点"),
+ address: item.address || (mode === "online" ? "线上活动" : "活动地址确认中"),
+ description: item.description || "数字游民交流活动。",
rsvpCount: Number(item.rsvpCount || 0),
- gradientFrom: item.gradientFrom || "#ff4d4f",
- gradientTo: item.gradientTo || "#ff7a45",
+ 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 MeetupCard({ meetup }: { meetup: Meetup }) {
- const formattedDate = formatMeetupDate(meetup.date, meetup.time);
+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 (
-
-
-
-
-
{meetup.emoji} {meetup.city}
-
{meetup.rsvpCount} 人报名
+
+ {children}
+
+ );
+}
+
+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 (
+
+
+
+ {modeLabel(meetup.mode, locale)}
+ {meetup.meetingProvider === "mirotalk" && MiroTalk}
-
@ {meetup.venue}
-
{meetup.address}
-
{meetup.description}
-
-
-
- {meetup.attendees.map((a) => (
-

+
{formattedDate}
+
+
+
+
+ {meetup.emoji}
+ {meetup.city}
+
+ {meetup.rsvpCount} {c.people}
+
+
+
+ {modeLabel(meetup.mode, locale)}
+ {accessLabel(meetup.accessLevel, locale)}
+ {!!meetup.maxAttendees && {c.max} {meetup.maxAttendees}}
+
+
+
@ {meetup.venue}
+
{meetup.address}
+
{meetup.description}
+
+ {meetup.features.length > 0 && (
+
+ {featureLabels(meetup.features, locale).slice(0, 3).map((feature) => (
+ {feature}
))}
-
已报名成员
+ )}
+
+
+
+
+
+ {meetup.attendees.map((a, index) => (
+
})
+ ))}
+
+
{locale === "zh" ? "已报名成员" : "attendees"}
);
}
+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 (
+
+
+
+
+
+
+
+ {modeLabel(meetup.mode, locale)}
+ {accessLabel(meetup.accessLevel, locale)}
+ {meetup.meetingProvider === "mirotalk" && {MIROTALK_PROVIDER_LABEL}}
+
+
{meetup.emoji} {meetup.city} · {meetup.venue}
+
{meetup.description}
+
+
+
+
+
+
+
{locale === "zh" ? "时间" : "Time"}
+
{formatMeetupDate(meetup.date, meetup.time)}
+
+
+
{c.organizer}
+
{meetup.organizer}
+
+
+
+
+
{locale === "zh" ? "地点" : "Location"}
+
{meetup.venue}
+
{meetup.address}
+
+
+ {isVideoEvent && (
+
+
+
+
{c.provider}
+
{MIROTALK_PROVIDER_LABEL}
+
{c.room}: {meetup.mirotalkRoom || meetup.id}
+
{c.relayServer}: {mirotalkServer}
+
{c.chatService}
+
{WEB_CHAT_PROVIDER_LABEL}
+
{c.chatServer}: {loungeServer}
+
+ {canJoin ? (
+
+
+ {c.joinRoom}
+
+
+ {c.joinChat}
+
+
+
+ ) : (
+
{lockedReason(meetup, locale)}
+ )}
+
+ {canJoin && meetup.accessLevel === "public" && (
+
{c.authPublicHint}
+ )}
+ {canJoin && (
+
{c.chatHint}
+ )}
+ {embedOpen && canJoin && (
+
+ )}
+
+ )}
+
+
+
+
+
+
+
+ );
+}
+
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
("all");
const [meetups, setMeetups] = useState(allMeetups);
+ const [selectedMeetup, setSelectedMeetup] = useState(null);
+ const [viewer, setViewer] = useState({ loaded: false, user: null, vip: false });
useEffect(() => {
apiFetch<{ items: Array & { id: string; status?: string }> }>("/api/meetups")
@@ -117,63 +752,137 @@ export default function MeetupsPage() {
.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 displayedMeetups = activeTab === "upcoming" ? upcomingMeetups : previousMeetups;
- const totalCount = activeTab === "upcoming" ? upcomingMeetups.length : previousMeetups.length;
+ 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 (
-
-
+
+
-
-
+
+
{t("title")}
-
+
{t("subtitle")}
- 🎤
+ +
{t("hostMeetup")}
-
-
-
+
+
+
+
{c.center}
+
{c.onlineReady}
+
{c.dataPolicy}
+
+
+
+
{onlineCount}
+
{c.onlineStats}
+
+
+
{mirotalkCount}
+
{c.mirotalkStats}
+
+
+
{protectedCount}
+
{c.permissionStats}
+
+
+
+
+
+
+
+
+
+
+
+
+ {modeFilters.map((filter) => (
+
+ ))}
+
-
- {activeTab === "upcoming" ? "共" : "共"}
- {totalCount}
- {activeTab === "upcoming" ? "场即将举办的聚会" : "场往期聚会"}
+
+ {c.countPrefix}
+ {displayedMeetups.length}
+ {activeTab === "upcoming" ? c.upcomingCountSuffix : c.previousCountSuffix}
-
+
{displayedMeetups.map((meetup) => (
-
+
))}
{displayedMeetups.length === 0 && (
-
-
🍹
-
- {activeTab === "upcoming" ? "暂无即将举办的聚会" : "暂无往期聚会记录"}
+
+
🍹
+
+ {activeTab === "upcoming" ? c.emptyUpcoming : c.emptyPrevious}
)}
+
+ {selectedMeetup && (
+
setSelectedMeetup(null)} />
+ )}
);
}
diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx
index dd9f87c..ecc29dd 100644
--- a/app/[locale]/page.tsx
+++ b/app/[locale]/page.tsx
@@ -11,6 +11,7 @@ import Footer from "@/app/components/Footer";
import { cities, type City } from "../data/cities";
import { HOME_CONFIG } from "@/config";
import { apiFetch } from "@/app/lib/api-client";
+import { mediaUrl } from "@/app/lib/media";
const {
memberPhotos: fallbackMemberPhotos,
@@ -275,7 +276,7 @@ export default function Home() {
<>
{meta.header}
-

+
{meta.icon}
@@ -333,7 +334,7 @@ export default function Home() {
{memberPhotos.map((m) => (

(

diff --git a/app/[locale]/press/page.tsx b/app/[locale]/press/page.tsx
index 7f77d52..de5e5eb 100644
--- a/app/[locale]/press/page.tsx
+++ b/app/[locale]/press/page.tsx
@@ -1,5 +1,6 @@
import Footer from "@/app/components/Footer";
import { pressArticles } from "@/app/data/mock";
+import { mediaUrl } from "@/app/lib/media";
export const metadata = {
title: "媒体报道 | 游牧中国",
@@ -31,7 +32,7 @@ export default function PressPage() {
{article.logo ? (

diff --git a/app/[locale]/videos/[slug]/page.tsx b/app/[locale]/videos/[slug]/page.tsx
index 4bd9969..813c1e2 100644
--- a/app/[locale]/videos/[slug]/page.tsx
+++ b/app/[locale]/videos/[slug]/page.tsx
@@ -5,6 +5,7 @@ import { Link, useLocale } from "@/i18n/navigation";
import { apiFetch } from "@/app/lib/api-client";
import Footer from "@/app/components/Footer";
import ContentSubmissionModal from "@/app/components/ContentSubmissionModal";
+import { mediaUrl } from "@/app/lib/media";
interface ContentItem {
slug: string;
@@ -68,7 +69,7 @@ export default function VideoPage({ params }: { params: Promise<{ slug: string }
{item.mediaUrl ? (
-
+
) : (
暂无可播放视频
diff --git a/app/api/media/proxy/route.ts b/app/api/media/proxy/route.ts
new file mode 100644
index 0000000..9e7a66d
--- /dev/null
+++ b/app/api/media/proxy/route.ts
@@ -0,0 +1,118 @@
+import { createHash } from "crypto";
+import { NextRequest, NextResponse } from "next/server";
+import { ensurePublicBucket, getMinioClient } from "@/app/digital/lib/minio/client";
+import { getMinioConfig } from "@/app/digital/lib/minio/config";
+
+export const runtime = "nodejs";
+
+const DEFAULT_MAX_BYTES = 250 * 1024 * 1024;
+const MAX_BYTES = Number(process.env.MEDIA_PROXY_MAX_BYTES || DEFAULT_MAX_BYTES);
+const BLOCKED_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1"]);
+
+const EXT_BY_TYPE: Record
= {
+ "image/jpeg": "jpg",
+ "image/png": "png",
+ "image/webp": "webp",
+ "image/gif": "gif",
+ "image/svg+xml": "svg",
+ "video/mp4": "mp4",
+ "video/webm": "webm",
+ "video/quicktime": "mov",
+ "audio/mpeg": "mp3",
+ "audio/mp4": "m4a",
+ "audio/wav": "wav",
+ "application/pdf": "pdf",
+ "application/zip": "zip",
+};
+
+function safeSource(raw: string | null): URL | null {
+ if (!raw) return null;
+ try {
+ const url = new URL(raw);
+ if (!["http:", "https:"].includes(url.protocol)) return null;
+ if (BLOCKED_HOSTS.has(url.hostname)) return null;
+ if (/^(10\.|127\.|169\.254\.|172\.(1[6-9]|2\d|3[0-1])\.|192\.168\.)/.test(url.hostname)) return null;
+ return url;
+ } catch {
+ return null;
+ }
+}
+
+function extensionFromSource(url: URL, contentType?: string | null): string {
+ const type = (contentType || "").split(";")[0].trim().toLowerCase();
+ if (EXT_BY_TYPE[type]) return EXT_BY_TYPE[type];
+ const match = url.pathname.match(/\.([a-z0-9]{2,8})$/i);
+ return match?.[1]?.toLowerCase() || "bin";
+}
+
+function publicObjectUrl(publicUrl: string, objectKey: string): string {
+ return `${publicUrl.replace(/\/$/, "")}/${objectKey}`;
+}
+
+async function objectExists(client: ReturnType, bucket: string, objectKey: string): Promise {
+ try {
+ await client.statObject(bucket, objectKey);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export async function GET(request: NextRequest) {
+ const source = safeSource(request.nextUrl.searchParams.get("src"));
+ if (!source) {
+ return NextResponse.json({ ok: false, error: "Invalid media source" }, { status: 400 });
+ }
+
+ const cfg = getMinioConfig();
+ if (!cfg.enabled) {
+ return NextResponse.json({ ok: false, error: "MinIO is disabled" }, { status: 503 });
+ }
+
+ const client = getMinioClient(cfg);
+ await ensurePublicBucket(client, cfg.bucket);
+
+ const prefix = `${cfg.uploadPrefix.replace(/\/$/, "")}/remote`;
+ const hash = createHash("sha256").update(source.toString()).digest("hex").slice(0, 32);
+ const guessedExt = extensionFromSource(source);
+ let objectKey = `${prefix}/${hash}.${guessedExt}`;
+
+ if (await objectExists(client, cfg.bucket, objectKey)) {
+ return NextResponse.redirect(publicObjectUrl(cfg.publicUrl, objectKey), 307);
+ }
+
+ let remote: Response;
+ try {
+ remote = await fetch(source, { redirect: "follow" });
+ } catch {
+ return NextResponse.json({ ok: false, error: "Media fetch failed" }, { status: 502 });
+ }
+ if (!remote.ok) {
+ return NextResponse.json({ ok: false, error: `Media fetch failed: ${remote.status}` }, { status: 502 });
+ }
+
+ const contentLength = Number(remote.headers.get("content-length") || 0);
+ if (contentLength > MAX_BYTES) {
+ return NextResponse.json({ ok: false, error: "Media is too large" }, { status: 413 });
+ }
+
+ const contentType = remote.headers.get("content-type") || "application/octet-stream";
+ const ext = extensionFromSource(source, contentType);
+ objectKey = `${prefix}/${hash}.${ext}`;
+
+ if (await objectExists(client, cfg.bucket, objectKey)) {
+ return NextResponse.redirect(publicObjectUrl(cfg.publicUrl, objectKey), 307);
+ }
+
+ const buffer = Buffer.from(await remote.arrayBuffer());
+ if (buffer.length > MAX_BYTES) {
+ return NextResponse.json({ ok: false, error: "Media is too large" }, { status: 413 });
+ }
+
+ await client.putObject(cfg.bucket, objectKey, buffer, buffer.length, {
+ "Content-Type": contentType,
+ "Cache-Control": "public, max-age=31536000, immutable",
+ });
+
+ return NextResponse.redirect(publicObjectUrl(cfg.publicUrl, objectKey), 307);
+}
diff --git a/app/components/CityModal.tsx b/app/components/CityModal.tsx
index b3808cd..14c8351 100644
--- a/app/components/CityModal.tsx
+++ b/app/components/CityModal.tsx
@@ -6,6 +6,7 @@ import { useTranslation } from "@/i18n/navigation";
import { Link } from "@/i18n/navigation";
import { City } from "@/app/data/cities";
import { apiFetch } from "@/app/lib/api-client";
+import { mediaUrl } from "@/app/lib/media";
const CITY_COORDS: Record = {
大理: [25.7, 100.2],
@@ -152,7 +153,7 @@ export default function CityModal({ city, isOpen, onClose }: CityModalProps) {
{typeof coverImage === "string" && coverImage.startsWith("http") ? (

@@ -470,7 +471,7 @@ function CityTabContent({
return (
{photos.map((src) => (
-

+
})
))}
);
@@ -573,7 +574,7 @@ function RelatedContent({ items, compact = false }: { items: Array
- {!compact &&
}
+ {!compact &&
}
{String(item.title)}
{String(item.subtitle || "")}
diff --git a/app/components/ContentSubmissionModal.tsx b/app/components/ContentSubmissionModal.tsx
index 0759503..835a547 100644
--- a/app/components/ContentSubmissionModal.tsx
+++ b/app/components/ContentSubmissionModal.tsx
@@ -2,6 +2,7 @@
import { ChangeEvent, FormEvent, useEffect, useMemo, useState } from "react";
import { apiFetch, apiUrl } from "@/app/lib/api-client";
+import { mediaUrl } from "@/app/lib/media";
type SubmissionType = "ebook" | "video";
@@ -298,7 +299,7 @@ export default function ContentSubmissionModal({ open, locale, onClose, submissi
required
/>
{form.coverImage && (
-

+
})
)}
diff --git a/app/components/HeroSection.tsx b/app/components/HeroSection.tsx
index 68c81c2..814e8a9 100644
--- a/app/components/HeroSection.tsx
+++ b/app/components/HeroSection.tsx
@@ -11,6 +11,7 @@ import {
} from "@/app/lib/payment";
import type { PayChannel, PayEnv } from "@/app/lib/payment";
import { apiUrl } from "@/app/lib/api-client";
+import { mediaUrl } from "@/app/lib/media";
const MEDIA_NAMES_ZH = ["36氪", "少数派", "极客公园", "虎嗅", "创业邦", "澎湃新闻"];
const MEDIA_NAMES_EN = ["36Kr", "sspai", "GeekPark", "Huxiu", "Cyzone", "The Paper"];
@@ -239,7 +240,7 @@ export default function HeroSection() {
{avatarPhotos.slice(0, 8).map((src, i) => (
diff --git a/app/digital/components/ResourceNavigation.tsx b/app/digital/components/ResourceNavigation.tsx
index c764dd0..8f7d8df 100644
--- a/app/digital/components/ResourceNavigation.tsx
+++ b/app/digital/components/ResourceNavigation.tsx
@@ -2,6 +2,7 @@
import { useState, useMemo, useSyncExternalStore } from "react";
import { useTranslation } from "@/app/digital/i18n/navigation";
+import { mediaUrl } from "@/app/lib/media";
import type { ResourceData } from "@/app/digital/lib/theme-data";
import DigitalThemeIcon, { type IconName } from "./DigitalThemeIcon";
@@ -98,7 +99,7 @@ export default function ResourceNavigation({ data }: ResourceNavigationProps) {
style={{ aspectRatio: "2/3" }}
>
@@ -167,7 +168,7 @@ export default function ResourceNavigation({ data }: ResourceNavigationProps) {
style={{ aspectRatio: "1/1" }}
>
diff --git a/app/digital/components/course/VideoPlayer.tsx b/app/digital/components/course/VideoPlayer.tsx
index 5bafd3f..1b038f9 100644
--- a/app/digital/components/course/VideoPlayer.tsx
+++ b/app/digital/components/course/VideoPlayer.tsx
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
+import { mediaUrl } from "@/app/lib/media";
const STORAGE_PREFIX = "lms-video-";
const SAVE_INTERVAL = 5000;
@@ -215,8 +216,8 @@ export function VideoPlayer({ src, poster, title, className = "", videoId, onCom
>