readme
This commit is contained in:
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import { Link, useLocale } from "@/i18n/navigation";
|
||||
import { apiFetch } from "@/app/lib/api-client";
|
||||
import Footer from "@/app/components/Footer";
|
||||
import { mediaUrl } from "@/app/lib/media";
|
||||
|
||||
interface ContentItem {
|
||||
title: string;
|
||||
@@ -46,7 +47,7 @@ export default function AppLandingPage() {
|
||||
const title = item ? (locale === "zh" ? item.title : item.titleEn || item.title) : "NomadCNA App";
|
||||
const subtitle = item ? (locale === "zh" ? item.subtitle : item.subtitleEn || item.subtitle) : "";
|
||||
const description = item ? (locale === "zh" ? item.description : item.descriptionEn || item.description) : "";
|
||||
const downloadUrl = normalizeDownloadUrl(item?.mediaUrl);
|
||||
const downloadUrl = mediaUrl(normalizeDownloadUrl(item?.mediaUrl));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
|
||||
@@ -68,7 +69,7 @@ export default function AppLandingPage() {
|
||||
</div>
|
||||
<div className="relative mx-auto h-[560px] w-[280px] rounded-[2rem] border-[10px] border-gray-900 bg-gray-950 p-3 shadow-2xl">
|
||||
<div className="h-full overflow-hidden rounded-[1.4rem] bg-white dark:bg-gray-900">
|
||||
{item?.coverImage && <img src={item.coverImage} alt={title} className="h-40 w-full object-cover" />}
|
||||
{item?.coverImage && <img src={mediaUrl(item.coverImage)} alt={title} className="h-40 w-full object-cover" />}
|
||||
<div className="p-4">
|
||||
<p className="text-xs font-semibold text-[#ff4d4f]">今日推荐</p>
|
||||
<h2 className="mt-1 text-lg font-bold text-gray-900 dark:text-gray-100">大理 · 慢生活路线</h2>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Footer from "@/app/components/Footer";
|
||||
import { blogPosts } from "@/app/data/mock";
|
||||
import { mediaUrl } from "@/app/lib/media";
|
||||
|
||||
export const metadata = {
|
||||
title: "博客 | 游牧中国",
|
||||
@@ -29,7 +30,7 @@ export default function BlogPage() {
|
||||
<div className="grid md:grid-cols-2 gap-0">
|
||||
<div className="aspect-video md:aspect-auto relative">
|
||||
<img
|
||||
src={featuredPost.coverImage}
|
||||
src={mediaUrl(featuredPost.coverImage)}
|
||||
alt={featuredPost.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
@@ -46,7 +47,7 @@ export default function BlogPage() {
|
||||
</p>
|
||||
<div className="flex items-center gap-3 mt-auto">
|
||||
<img
|
||||
src={featuredPost.authorAvatar}
|
||||
src={mediaUrl(featuredPost.authorAvatar)}
|
||||
alt={featuredPost.author}
|
||||
className="w-8 h-8 rounded-full"
|
||||
/>
|
||||
@@ -74,7 +75,7 @@ export default function BlogPage() {
|
||||
>
|
||||
<div className="aspect-video relative">
|
||||
<img
|
||||
src={post.coverImage}
|
||||
src={mediaUrl(post.coverImage)}
|
||||
alt={post.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
@@ -91,7 +92,7 @@ export default function BlogPage() {
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={post.authorAvatar}
|
||||
src={mediaUrl(post.authorAvatar)}
|
||||
alt={post.author}
|
||||
className="w-6 h-6 rounded-full"
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useTranslation } from "@/i18n/navigation";
|
||||
import Footer from "@/app/components/Footer";
|
||||
import { apiFetch } from "@/app/lib/api-client";
|
||||
import { mediaUrl } from "@/app/lib/media";
|
||||
|
||||
type TabType = "friends" | "dating" | "partner" | "roommate" | "cofounder" | "explore";
|
||||
|
||||
@@ -216,7 +217,7 @@ export default function DatingPage() {
|
||||
<div className="space-y-3">
|
||||
{matches.map((match) => (
|
||||
<div key={match.id} className="flex items-center gap-3 p-3 rounded-xl bg-white dark:bg-gray-900 border border-gray-100 dark:border-gray-800 shadow-sm hover:shadow-md transition-shadow cursor-pointer">
|
||||
<img src={match.photo} alt={match.name} className="w-12 h-12 rounded-full object-cover shadow-sm flex-shrink-0" />
|
||||
<img src={mediaUrl(match.photo)} alt={match.name} className="w-12 h-12 rounded-full object-cover shadow-sm flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900 dark:text-gray-100 truncate">{match.name}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{match.location}</p>
|
||||
@@ -266,7 +267,7 @@ export default function DatingPage() {
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex justify-center mt-12 mb-6">
|
||||
<img src={currentProfile.photo} alt={currentProfile.name} className="w-28 h-28 rounded-full shadow-lg border-4 border-white/50 object-cover" />
|
||||
<img src={mediaUrl(currentProfile.photo)} alt={currentProfile.name} className="w-28 h-28 rounded-full shadow-lg border-4 border-white/50 object-cover" />
|
||||
</div>
|
||||
<div className="text-center mb-4">
|
||||
<h3 className="text-xl font-bold text-white drop-shadow-sm">{currentProfile.name},{currentProfile.age}</h3>
|
||||
|
||||
@@ -6,6 +6,7 @@ import remarkGfm from "remark-gfm";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
import "highlight.js/styles/atom-one-dark.min.css";
|
||||
import styles from "./lesson-markdown.module.css";
|
||||
import { mediaUrl } from "@/app/lib/media";
|
||||
|
||||
type TocItem = { level: number; text: string; id: string };
|
||||
|
||||
@@ -120,7 +121,7 @@ export function LessonMarkdown({ content }: { content: string }) {
|
||||
),
|
||||
img: ({ src, alt }) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={src || ""} alt={alt || ""} loading="lazy" decoding="async" />
|
||||
<img src={mediaUrl(typeof src === "string" ? src : "")} alt={alt || ""} loading="lazy" decoding="async" />
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "@/i18n/navigation";
|
||||
import Footer from "@/app/components/Footer";
|
||||
import { apiFetch } from "@/app/lib/api-client";
|
||||
import { mediaUrl } from "@/app/lib/media";
|
||||
|
||||
interface Topic {
|
||||
id: string;
|
||||
@@ -296,7 +297,7 @@ export default function DiscussPage() {
|
||||
>
|
||||
<div className="flex items-start gap-3 sm:gap-4">
|
||||
<img
|
||||
src={topic.avatar}
|
||||
src={mediaUrl(topic.avatar)}
|
||||
alt={topic.author}
|
||||
className="w-10 h-10 sm:w-12 sm:h-12 rounded-full object-cover shrink-0"
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
@@ -58,7 +59,7 @@ export default function EbookPage({ params }: { params: Promise<{ slug: string }
|
||||
<main className="mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-12">
|
||||
<div className="grid gap-8 lg:grid-cols-[360px_1fr]">
|
||||
<div className="overflow-hidden rounded-2xl bg-white shadow-lg dark:bg-gray-900">
|
||||
<img src={item.coverImage} alt={title} className="h-96 w-full object-cover" />
|
||||
<img src={mediaUrl(item.coverImage)} alt={title} className="h-96 w-full object-cover" />
|
||||
</div>
|
||||
<section>
|
||||
<p className="text-sm font-semibold text-[#ff4d4f]">电子书</p>
|
||||
@@ -80,7 +81,7 @@ export default function EbookPage({ params }: { params: Promise<{ slug: string }
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
{item.mediaUrl ? (
|
||||
<a href={item.mediaUrl} className="rounded-full bg-[#ff4d4f] px-5 py-3 text-sm font-semibold text-white hover:bg-[#ff7a45]">
|
||||
<a href={mediaUrl(item.mediaUrl)} className="rounded-full bg-[#ff4d4f] px-5 py-3 text-sm font-semibold text-white hover:bg-[#ff7a45]">
|
||||
下载电子书
|
||||
</a>
|
||||
) : (
|
||||
|
||||
@@ -1,18 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Link, useTranslation } from "@/i18n/navigation";
|
||||
import { useMemo, useState, type FormEvent } from "react";
|
||||
import { Link, useLocale, useTranslation } from "@/i18n/navigation";
|
||||
import { cities } from "@/app/data/cities";
|
||||
import { apiFetch } from "@/app/lib/api-client";
|
||||
import {
|
||||
createMiroTalkRoomSlug,
|
||||
DEFAULT_ONLINE_FEATURES,
|
||||
getMiroTalkServerLabel,
|
||||
MIROTALK_PROVIDER_LABEL,
|
||||
MEETUP_FEATURE_OPTIONS,
|
||||
normalizeMiroTalkRoom,
|
||||
type MeetupAccessLevel,
|
||||
type MeetupMode,
|
||||
} from "@/app/lib/meetups";
|
||||
|
||||
const CITY_OPTIONS = cities.map((c) => ({ value: c.name, label: `${c.icon} ${c.name}` }));
|
||||
const CITY_BY_NAME = new Map(cities.map((city) => [city.name, city]));
|
||||
|
||||
interface HostMeetupForm {
|
||||
mode: MeetupMode;
|
||||
city: string;
|
||||
date: string;
|
||||
time: string;
|
||||
venue: string;
|
||||
address: string;
|
||||
description: string;
|
||||
maxAttendees: string;
|
||||
organizer: string;
|
||||
accessLevel: MeetupAccessLevel;
|
||||
meetingProvider: string;
|
||||
mirotalkRoom: string;
|
||||
meetingUrl: string;
|
||||
features: string[];
|
||||
}
|
||||
|
||||
const copy = {
|
||||
zh: {
|
||||
activityType: "活动形式",
|
||||
offline: "线下",
|
||||
online: "线上",
|
||||
hybrid: "混合",
|
||||
organizer: "主办方",
|
||||
organizerPlaceholder: "如:NomadCNA / 城市主理人",
|
||||
onlineSettings: "线上活动配置",
|
||||
provider: "视频服务",
|
||||
accessLevel: "访问权限",
|
||||
publicAccess: "公开",
|
||||
membersAccess: "登录成员",
|
||||
vipAccess: "VIP 成员",
|
||||
hostsAccess: "主持人",
|
||||
room: "MiroTalk 房间名",
|
||||
roomPlaceholder: "留空时自动生成",
|
||||
roomSuggestion: "将使用房间",
|
||||
meetingUrl: "自定义入会链接",
|
||||
meetingUrlPlaceholder: "可选,例如 https://mirotalk.nomadro.cn/join?room=...",
|
||||
features: "开放功能",
|
||||
onlineHint: "线上/混合活动会保存 provider、room、accessLevel、features 等字段,并默认通过自建 MiroTalk P2P 服务生成入会入口。",
|
||||
defaultServer: "默认服务",
|
||||
pendingHint: "提交后会进入本机 PocketBase 的 meetups 表,审核通过前状态为 pending。",
|
||||
submitOnline: "发布活动",
|
||||
crossCity: "线上 / 跨城市",
|
||||
},
|
||||
en: {
|
||||
activityType: "Event type",
|
||||
offline: "Offline",
|
||||
online: "Online",
|
||||
hybrid: "Hybrid",
|
||||
organizer: "Organizer",
|
||||
organizerPlaceholder: "e.g. NomadCNA / city host",
|
||||
onlineSettings: "Online event settings",
|
||||
provider: "Video service",
|
||||
accessLevel: "Access level",
|
||||
publicAccess: "Public",
|
||||
membersAccess: "Members",
|
||||
vipAccess: "VIP members",
|
||||
hostsAccess: "Hosts",
|
||||
room: "MiroTalk room",
|
||||
roomPlaceholder: "Auto-generated when empty",
|
||||
roomSuggestion: "Room to use",
|
||||
meetingUrl: "Custom join URL",
|
||||
meetingUrlPlaceholder: "Optional, e.g. https://mirotalk.nomadro.cn/join?room=...",
|
||||
features: "Enabled features",
|
||||
onlineHint: "Online/hybrid events store provider, room, accessLevel, and features, then generate join links through the self-hosted MiroTalk P2P service by default.",
|
||||
defaultServer: "Default server",
|
||||
pendingHint: "After submission, this writes to the local PocketBase meetups table with pending status.",
|
||||
submitOnline: "Publish event",
|
||||
crossCity: "Online / cross-city",
|
||||
},
|
||||
};
|
||||
|
||||
function getCopy(locale: string) {
|
||||
return locale === "zh" ? copy.zh : copy.en;
|
||||
}
|
||||
|
||||
export default function HostMeetupPage() {
|
||||
const { t } = useTranslation("hostMeetup");
|
||||
const locale = useLocale();
|
||||
const c = getCopy(locale);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
const [form, setForm] = useState<HostMeetupForm>({
|
||||
mode: "offline",
|
||||
city: "",
|
||||
date: "",
|
||||
time: "19:00",
|
||||
@@ -20,29 +109,78 @@ export default function HostMeetupPage() {
|
||||
address: "",
|
||||
description: "",
|
||||
maxAttendees: "20",
|
||||
organizer: "",
|
||||
accessLevel: "members",
|
||||
meetingProvider: "mirotalk",
|
||||
mirotalkRoom: "",
|
||||
meetingUrl: "",
|
||||
features: DEFAULT_ONLINE_FEATURES,
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
const isOnlineMode = form.mode !== "offline";
|
||||
const isOnlineOnly = form.mode === "online";
|
||||
const city = CITY_BY_NAME.get(form.city);
|
||||
const mirotalkServer = getMiroTalkServerLabel();
|
||||
const roomToUse = useMemo(
|
||||
() =>
|
||||
normalizeMiroTalkRoom(form.mirotalkRoom) ||
|
||||
createMiroTalkRoomSlug({
|
||||
id: "new",
|
||||
city: form.city || "online",
|
||||
date: form.date,
|
||||
time: form.time,
|
||||
}),
|
||||
[form.city, form.date, form.mirotalkRoom, form.time]
|
||||
);
|
||||
|
||||
const setField = <K extends keyof HostMeetupForm>(field: K, value: HostMeetupForm[K]) => {
|
||||
setForm((current) => ({ ...current, [field]: value }));
|
||||
};
|
||||
|
||||
const toggleFeature = (feature: string) => {
|
||||
setForm((current) => {
|
||||
const selected = new Set(current.features);
|
||||
if (selected.has(feature)) {
|
||||
selected.delete(feature);
|
||||
} else {
|
||||
selected.add(feature);
|
||||
}
|
||||
return { ...current, features: Array.from(selected) };
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const city = CITY_BY_NAME.get(form.city);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const finalCity = isOnlineOnly && !form.city ? "线上" : form.city;
|
||||
const finalVenue = isOnlineOnly && !form.venue ? "MiroTalk 多人视频房" : form.venue;
|
||||
const finalAddress = isOnlineOnly && !form.address ? "线上活动" : form.address;
|
||||
await apiFetch("/api/meetups", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
city: form.city,
|
||||
emoji: city?.icon || "📍",
|
||||
city: finalCity,
|
||||
emoji: city?.icon || (isOnlineOnly ? "💻" : "📍"),
|
||||
date: form.date,
|
||||
time: form.time,
|
||||
venue: form.venue,
|
||||
address: form.address,
|
||||
venue: finalVenue,
|
||||
address: finalAddress,
|
||||
description: form.description,
|
||||
rsvpCount: 1,
|
||||
gradientFrom: city?.gradientFrom || "#ff4d4f",
|
||||
gradientTo: city?.gradientTo || "#ff7a45",
|
||||
gradientFrom: city?.gradientFrom || (isOnlineMode ? "#0f766e" : "#ff4d4f"),
|
||||
gradientTo: city?.gradientTo || (isOnlineMode ? "#2563eb" : "#ff7a45"),
|
||||
attendees: [],
|
||||
isUpcoming: true,
|
||||
status: "pending",
|
||||
mode: form.mode,
|
||||
maxAttendees: Number(form.maxAttendees || 0),
|
||||
organizer: form.organizer.trim() || "NomadCNA",
|
||||
accessLevel: form.accessLevel,
|
||||
meetingProvider: isOnlineMode ? form.meetingProvider : "",
|
||||
mirotalkRoom: isOnlineMode ? roomToUse : "",
|
||||
meetingUrl: isOnlineMode ? form.meetingUrl.trim() : "",
|
||||
replayUrl: "",
|
||||
features: isOnlineMode ? form.features : [],
|
||||
}),
|
||||
});
|
||||
setSubmitted(true);
|
||||
@@ -54,26 +192,26 @@ export default function HostMeetupPage() {
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
|
||||
<main className="max-w-[600px] mx-auto px-4 sm:px-6 py-12 sm:py-20">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-lg border border-gray-100 dark:border-gray-800 p-8 sm:p-12 text-center">
|
||||
<span className="text-6xl mb-6 block">🎉</span>
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 mb-3">
|
||||
<main className="mx-auto max-w-[600px] px-4 py-12 sm:px-6 sm:py-20">
|
||||
<div className="rounded-2xl border border-gray-100 bg-white p-8 text-center shadow-lg dark:border-gray-800 dark:bg-gray-900 sm:p-12">
|
||||
<span className="mb-6 block text-6xl">🎉</span>
|
||||
<h2 className="mb-3 text-xl font-bold text-gray-900 dark:text-gray-100 sm:text-2xl">
|
||||
{t("successTitle")}
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 mb-8">
|
||||
<p className="mb-8 text-gray-500 dark:text-gray-400">
|
||||
{t("successDesc")}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<div className="flex flex-col justify-center gap-3 sm:flex-row">
|
||||
<Link
|
||||
href="/meetups/host"
|
||||
onClick={() => setSubmitted(false)}
|
||||
className="px-6 py-3 rounded-xl border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 font-medium hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
className="cursor-pointer rounded-xl border border-gray-200 px-6 py-3 font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
>
|
||||
{t("createAnother")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/meetups"
|
||||
className="px-6 py-3 rounded-xl bg-[#ff4d4f] text-white font-medium hover:bg-[#ff7a45] transition-colors"
|
||||
className="cursor-pointer rounded-xl bg-[#ff4d4f] px-6 py-3 font-medium text-white transition-colors hover:bg-[#ff7a45]"
|
||||
>
|
||||
{t("viewMeetups")}
|
||||
</Link>
|
||||
@@ -86,138 +224,267 @@ export default function HostMeetupPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
|
||||
<main className="max-w-[640px] mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||
<main className="mx-auto max-w-[760px] px-4 py-8 sm:px-6 sm:py-12">
|
||||
<header className="mb-8">
|
||||
<Link
|
||||
href="/meetups"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 mb-4"
|
||||
className="mb-4 inline-flex cursor-pointer items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
← {t("backToMeetups")}
|
||||
</Link>
|
||||
<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" />
|
||||
<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="text-gray-500 dark:text-gray-400 mt-1 text-sm sm:text-base">
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400 sm:text-base">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-800 p-6 sm:p-8 space-y-6"
|
||||
className="space-y-6 rounded-2xl border border-gray-100 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900 sm:p-8"
|
||||
>
|
||||
<fieldset>
|
||||
<legend className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{c.activityType} *
|
||||
</legend>
|
||||
<div className="grid grid-cols-3 gap-2 rounded-xl bg-gray-100 p-1 dark:bg-gray-800">
|
||||
{([
|
||||
["offline", c.offline],
|
||||
["online", c.online],
|
||||
["hybrid", c.hybrid],
|
||||
] as const).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setField("mode", value)}
|
||||
className={`min-h-10 cursor-pointer rounded-lg px-3 py-2 text-sm font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-[#ff4d4f]/40 ${
|
||||
form.mode === value
|
||||
? "bg-[#ff4d4f] text-white shadow-sm"
|
||||
: "text-gray-600 hover:bg-white hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-900 dark:hover:text-gray-100"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t("city")} *
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t("city")} {isOnlineOnly ? "" : "*"}
|
||||
</label>
|
||||
<select
|
||||
required
|
||||
required={!isOnlineOnly}
|
||||
value={form.city}
|
||||
onChange={(e) => setForm((f) => ({ ...f, city: 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"
|
||||
onChange={(e) => setField("city", e.target.value)}
|
||||
className="form-input w-full rounded-xl border border-gray-200 bg-white px-4 py-3 text-gray-900 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
>
|
||||
<option value="">{t("selectCity")}</option>
|
||||
{CITY_OPTIONS.map((c) => (
|
||||
<option key={c.value} value={c.value}>
|
||||
{c.label}
|
||||
<option value="">{isOnlineOnly ? c.crossCity : t("selectCity")}</option>
|
||||
{CITY_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t("date")} *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
value={form.date}
|
||||
onChange={(e) => setForm((f) => ({ ...f, date: 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"
|
||||
onChange={(e) => setField("date", e.target.value)}
|
||||
className="form-input w-full rounded-xl border border-gray-200 bg-white px-4 py-3 text-gray-900 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t("time")} *
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
required
|
||||
value={form.time}
|
||||
onChange={(e) => setForm((f) => ({ ...f, time: 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"
|
||||
onChange={(e) => setField("time", e.target.value)}
|
||||
className="form-input w-full rounded-xl border border-gray-200 bg-white px-4 py-3 text-gray-900 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t("venue")} *
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t("venue")} {isOnlineOnly ? "" : "*"}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder={t("venuePlaceholder")}
|
||||
required={!isOnlineOnly}
|
||||
placeholder={isOnlineOnly ? "MiroTalk 多人视频房" : t("venuePlaceholder")}
|
||||
value={form.venue}
|
||||
onChange={(e) => setForm((f) => ({ ...f, venue: 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 placeholder-gray-400"
|
||||
onChange={(e) => setField("venue", 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t("address")} *
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t("address")} {isOnlineOnly ? "" : "*"}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder={t("addressPlaceholder")}
|
||||
required={!isOnlineOnly}
|
||||
placeholder={isOnlineOnly ? "线上活动" : t("addressPlaceholder")}
|
||||
value={form.address}
|
||||
onChange={(e) => setForm((f) => ({ ...f, address: 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 placeholder-gray-400"
|
||||
onChange={(e) => setField("address", 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t("description")}
|
||||
</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
placeholder={t("descriptionPlaceholder")}
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((f) => ({ ...f, description: 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 placeholder-gray-400 resize-none"
|
||||
onChange={(e) => setField("description", e.target.value)}
|
||||
className="form-input w-full resize-none 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t("maxAttendees")}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={2}
|
||||
max={100}
|
||||
value={form.maxAttendees}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t("maxAttendees")}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={2}
|
||||
max={500}
|
||||
value={form.maxAttendees}
|
||||
onChange={(e) => setField("maxAttendees", e.target.value)}
|
||||
className="form-input w-full rounded-xl border border-gray-200 bg-white px-4 py-3 text-gray-900 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{c.organizer}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={c.organizerPlaceholder}
|
||||
value={form.organizer}
|
||||
onChange={(e) => setField("organizer", 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOnlineMode && (
|
||||
<section className="space-y-5 rounded-xl border border-emerald-100 bg-emerald-50/60 p-4 dark:border-emerald-900/50 dark:bg-emerald-950/20 sm:p-5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-gray-100">{c.onlineSettings}</h2>
|
||||
<p className="text-xs leading-5 text-gray-500 dark:text-gray-400">{c.onlineHint}</p>
|
||||
<p className="text-xs font-semibold text-emerald-700 dark:text-emerald-300">{c.defaultServer}: {mirotalkServer}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{c.provider}
|
||||
</label>
|
||||
<select
|
||||
value={form.meetingProvider}
|
||||
onChange={(e) => setField("meetingProvider", e.target.value)}
|
||||
className="form-input w-full rounded-xl border border-gray-200 bg-white px-4 py-3 text-gray-900 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
>
|
||||
<option value="mirotalk">{MIROTALK_PROVIDER_LABEL}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{c.accessLevel}
|
||||
</label>
|
||||
<select
|
||||
value={form.accessLevel}
|
||||
onChange={(e) => setField("accessLevel", e.target.value as MeetupAccessLevel)}
|
||||
className="form-input w-full rounded-xl border border-gray-200 bg-white px-4 py-3 text-gray-900 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
>
|
||||
<option value="public">{c.publicAccess}</option>
|
||||
<option value="members">{c.membersAccess}</option>
|
||||
<option value="vip">{c.vipAccess}</option>
|
||||
<option value="hosts">{c.hostsAccess}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{c.room}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={c.roomPlaceholder}
|
||||
value={form.mirotalkRoom}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<p className="mt-2 break-all text-xs text-emerald-700 dark:text-emerald-300">{c.roomSuggestion}: {roomToUse}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{c.meetingUrl}
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
placeholder={c.meetingUrlPlaceholder}
|
||||
value={form.meetingUrl}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{c.features}
|
||||
</legend>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{MEETUP_FEATURE_OPTIONS.map((feature) => (
|
||||
<label
|
||||
key={feature.key}
|
||||
className="flex min-h-11 cursor-pointer items-center gap-2 rounded-lg border border-emerald-100 bg-white px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-emerald-300 dark:border-emerald-900/50 dark:bg-gray-900 dark:text-gray-300"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.features.includes(feature.key)}
|
||||
onChange={() => toggleFeature(feature.key)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-[#ff4d4f] focus:ring-[#ff4d4f]"
|
||||
/>
|
||||
<span>{locale === "zh" ? feature.zh : feature.en}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
提交后会进入本机 PocketBase 的 meetups 表,审核通过前状态为 pending。
|
||||
{c.pendingHint}
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full py-3.5 rounded-xl bg-[#ff4d4f] text-white font-semibold hover:bg-[#ff7a45] transition-colors"
|
||||
className="w-full cursor-pointer rounded-xl bg-[#ff4d4f] py-3.5 font-semibold text-white transition-colors hover:bg-[#ff7a45] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{submitting ? "提交中..." : t("submit")}
|
||||
{submitting ? "提交中..." : c.submitOnline}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
@@ -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<Meetup> & { 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 (
|
||||
<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>
|
||||
<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-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} />
|
||||
<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>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 ml-1">已报名成员</span>
|
||||
)}
|
||||
|
||||
<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")
|
||||
@@ -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 (
|
||||
<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">
|
||||
<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="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" />
|
||||
<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="text-gray-500 dark:text-gray-400 mt-1 text-sm sm:text-base">
|
||||
<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="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"
|
||||
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>🎤</span>
|
||||
<span aria-hidden>+</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>
|
||||
<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="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 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 sm:grid-cols-2 lg:grid-cols-3 gap-5 sm:gap-6">
|
||||
<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} />
|
||||
<MeetupCard key={meetup.id} meetup={meetup} locale={locale} viewer={viewer} onSelect={setSelectedMeetup} />
|
||||
))}
|
||||
</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" ? "暂无即将举办的聚会" : "暂无往期聚会记录"}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<>
|
||||
<div className="right-item-header">{meta.header}</div>
|
||||
<div className="relative h-[calc(100%-38px)] sm:h-[calc(100%-42px)] overflow-hidden">
|
||||
<img src={item.coverImage} alt={title} className="absolute inset-0 h-full w-full object-cover" />
|
||||
<img src={mediaUrl(item.coverImage)} alt={title} className="absolute inset-0 h-full w-full object-cover" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/35 to-transparent" />
|
||||
<div className="absolute inset-x-0 bottom-0 p-3 sm:p-4 text-white">
|
||||
<div className="mb-1 text-xl sm:text-2xl">{meta.icon}</div>
|
||||
@@ -333,7 +334,7 @@ export default function Home() {
|
||||
{memberPhotos.map((m) => (
|
||||
<img
|
||||
key={m.name}
|
||||
src={m.photo}
|
||||
src={mediaUrl(m.photo)}
|
||||
alt={m.name}
|
||||
title={m.name}
|
||||
className="w-6 h-6 sm:w-10 sm:h-10 rounded-full border-2 border-white dark:border-gray-800 shadow-sm object-cover"
|
||||
@@ -361,7 +362,7 @@ export default function Home() {
|
||||
{memberPhotos.slice(0, 5).map((m) => (
|
||||
<img
|
||||
key={m.name}
|
||||
src={m.photo}
|
||||
src={mediaUrl(m.photo)}
|
||||
alt=""
|
||||
className="w-4 h-4 sm:w-7 sm:h-7 rounded-full border-2 border-white dark:border-gray-800 shadow-sm object-cover"
|
||||
/>
|
||||
|
||||
@@ -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() {
|
||||
<div className="w-12 h-12 rounded-lg bg-gray-100 dark:bg-gray-800 flex items-center justify-center flex-shrink-0 overflow-hidden">
|
||||
{article.logo ? (
|
||||
<img
|
||||
src={article.logo}
|
||||
src={mediaUrl(article.logo)}
|
||||
alt={article.outlet}
|
||||
className="w-8 h-8 object-contain"
|
||||
/>
|
||||
|
||||
@@ -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 }
|
||||
</div>
|
||||
<div className="mt-6 overflow-hidden rounded-2xl bg-black shadow-2xl">
|
||||
{item.mediaUrl ? (
|
||||
<video src={item.mediaUrl} poster={item.coverImage} controls playsInline className="aspect-video w-full bg-black object-contain" />
|
||||
<video src={mediaUrl(item.mediaUrl)} poster={mediaUrl(item.coverImage)} controls playsInline className="aspect-video w-full bg-black object-contain" />
|
||||
) : (
|
||||
<div className="flex aspect-video w-full items-center justify-center bg-black text-sm text-white/60">
|
||||
暂无可播放视频
|
||||
|
||||
118
app/api/media/proxy/route.ts
Normal file
118
app/api/media/proxy/route.ts
Normal file
@@ -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<string, string> = {
|
||||
"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<typeof getMinioClient>, bucket: string, objectKey: string): Promise<boolean> {
|
||||
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);
|
||||
}
|
||||
@@ -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<string, [number, number]> = {
|
||||
大理: [25.7, 100.2],
|
||||
@@ -152,7 +153,7 @@ export default function CityModal({ city, isOpen, onClose }: CityModalProps) {
|
||||
<div className="relative h-36 sm:h-44 md:h-52 shrink-0">
|
||||
{typeof coverImage === "string" && coverImage.startsWith("http") ? (
|
||||
<img
|
||||
src={coverImage}
|
||||
src={mediaUrl(coverImage)}
|
||||
alt={city.name}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
@@ -470,7 +471,7 @@ function CityTabContent({
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{photos.map((src) => (
|
||||
<img key={src} src={src} alt={city.name} className="h-56 w-full rounded-2xl object-cover shadow-sm" />
|
||||
<img key={src} src={mediaUrl(src)} alt={city.name} className="h-56 w-full rounded-2xl object-cover shadow-sm" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -573,7 +574,7 @@ function RelatedContent({ items, compact = false }: { items: Array<Record<string
|
||||
const external = href.startsWith("http");
|
||||
const body = (
|
||||
<div className="flex gap-3 rounded-xl bg-gray-50 p-3 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700">
|
||||
{!compact && <img src={String(item.coverImage || "")} alt="" className="h-14 w-20 rounded-lg object-cover" />}
|
||||
{!compact && <img src={mediaUrl(String(item.coverImage || ""))} alt="" className="h-14 w-20 rounded-lg object-cover" />}
|
||||
<div className="min-w-0">
|
||||
<p className="line-clamp-1 text-sm font-semibold text-gray-900 dark:text-gray-100">{String(item.title)}</p>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-gray-500 dark:text-gray-400">{String(item.subtitle || "")}</p>
|
||||
|
||||
@@ -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 && (
|
||||
<img src={form.coverImage} alt="" className="mt-3 h-32 w-full rounded-lg object-cover ring-1 ring-gray-100 dark:ring-gray-800" />
|
||||
<img src={mediaUrl(form.coverImage)} alt="" className="mt-3 h-32 w-full rounded-lg object-cover ring-1 ring-gray-100 dark:ring-gray-800" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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) => (
|
||||
<img
|
||||
key={i}
|
||||
src={src}
|
||||
src={mediaUrl(src)}
|
||||
alt=""
|
||||
className="w-8 h-8 sm:w-9 sm:h-9 rounded-full border-2 border-white dark:border-gray-800 shadow-sm object-cover"
|
||||
/>
|
||||
|
||||
@@ -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" }}
|
||||
>
|
||||
<img
|
||||
src={book.cover}
|
||||
src={mediaUrl(book.cover)}
|
||||
alt={book.title}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
@@ -167,7 +168,7 @@ export default function ResourceNavigation({ data }: ResourceNavigationProps) {
|
||||
style={{ aspectRatio: "1/1" }}
|
||||
>
|
||||
<img
|
||||
src={product.image}
|
||||
src={mediaUrl(product.image)}
|
||||
alt={product.name}
|
||||
className="h-full w-full object-contain transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
poster={poster}
|
||||
src={mediaUrl(src)}
|
||||
poster={mediaUrl(poster)}
|
||||
title={title}
|
||||
className="aspect-video w-full object-contain"
|
||||
onClick={togglePlay}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { marked } from "marked";
|
||||
import { mediaUrl } from "@/app/lib/media";
|
||||
|
||||
export interface EbookChunk {
|
||||
html: string;
|
||||
@@ -10,9 +11,21 @@ export interface EbookData {
|
||||
headers: string[];
|
||||
}
|
||||
|
||||
function rewriteMediaTag(tag: string): string {
|
||||
return tag.replace(/\s(src|poster)=(["'])(.*?)\2/gi, (match, attr: string, quote: string, value: string) => {
|
||||
const rewritten = mediaUrl(value);
|
||||
return rewritten ? ` ${attr}=${quote}${rewritten}${quote}` : match;
|
||||
});
|
||||
}
|
||||
|
||||
function rewriteEmbeddedMedia(html: string): string {
|
||||
return html.replace(/<(img|video|audio|source)\b[^>]*>/gi, rewriteMediaTag);
|
||||
}
|
||||
|
||||
export function parseEbookMarkdown(source: string): EbookData {
|
||||
const html = marked.parse(source, { breaks: true, gfm: true }) as string;
|
||||
const lazyHtml = html.replace(/<img /g, '<img loading="lazy" decoding="async" ');
|
||||
const mediaHtml = rewriteEmbeddedMedia(html);
|
||||
const lazyHtml = mediaHtml.replace(/<img /g, '<img loading="lazy" decoding="async" ');
|
||||
|
||||
let rawChunks = lazyHtml.split(/(?=<h1[\s>])/i).filter((c) => c.trim());
|
||||
if (rawChunks.length <= 1) {
|
||||
|
||||
@@ -6,12 +6,14 @@
|
||||
import * as Minio from "minio";
|
||||
import { getMinioConfig } from "./config";
|
||||
import { getThemeConfig } from "@/config/digital/site.config";
|
||||
import type { MinioConfig } from "./config";
|
||||
|
||||
const ensuredBuckets = new Set<string>();
|
||||
|
||||
/** 获取 MinIO 客户端实例(使用主题配置) */
|
||||
export function getMinioClient(): Minio.Client {
|
||||
export function getMinioClient(config?: MinioConfig): Minio.Client {
|
||||
const theme = getThemeConfig();
|
||||
const cfg = getMinioConfig(theme.services?.minio);
|
||||
|
||||
const cfg = config || getMinioConfig(theme.services?.minio);
|
||||
return new Minio.Client({
|
||||
endPoint: cfg.endPoint,
|
||||
port: cfg.port,
|
||||
@@ -23,6 +25,29 @@ export function getMinioClient(): Minio.Client {
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensurePublicBucket(client: Minio.Client, bucket: string): Promise<void> {
|
||||
if (ensuredBuckets.has(bucket)) return;
|
||||
const exists = await client.bucketExists(bucket);
|
||||
if (!exists) {
|
||||
await client.makeBucket(bucket);
|
||||
}
|
||||
await client.setBucketPolicy(
|
||||
bucket,
|
||||
JSON.stringify({
|
||||
Version: "2012-10-17",
|
||||
Statement: [
|
||||
{
|
||||
Effect: "Allow",
|
||||
Principal: { AWS: ["*"] },
|
||||
Action: ["s3:GetObject"],
|
||||
Resource: [`arn:aws:s3:::${bucket}/*`],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
ensuredBuckets.add(bucket);
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
url: string;
|
||||
objectKey: string;
|
||||
@@ -46,7 +71,8 @@ export async function uploadBuffer(
|
||||
throw new Error("MinIO 存储未启用");
|
||||
}
|
||||
|
||||
const client = getMinioClient();
|
||||
const client = getMinioClient(cfg);
|
||||
await ensurePublicBucket(client, cfg.bucket);
|
||||
await client.putObject(cfg.bucket, objectKey, buffer, buffer.length, {
|
||||
"Content-Type": contentType,
|
||||
});
|
||||
|
||||
32
app/lib/media.ts
Normal file
32
app/lib/media.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
const MEDIA_PROXY_PATH = "/api/media/proxy";
|
||||
|
||||
function configuredMinioPublicUrl(): string {
|
||||
return (
|
||||
process.env.NEXT_PUBLIC_MINIO_PUBLIC_URL ||
|
||||
process.env.MINIO_PUBLIC_URL ||
|
||||
""
|
||||
).replace(/\/$/, "");
|
||||
}
|
||||
|
||||
export function isExternalMediaUrl(value: string): boolean {
|
||||
return /^https?:\/\//i.test(value);
|
||||
}
|
||||
|
||||
export function isMinioUrl(value: string): boolean {
|
||||
const publicUrl = configuredMinioPublicUrl();
|
||||
return Boolean(publicUrl && value.startsWith(`${publicUrl}/`));
|
||||
}
|
||||
|
||||
export function mediaUrl(value?: string | null): string {
|
||||
const url = String(value || "").trim();
|
||||
if (!url) return "";
|
||||
if (/^(data:|blob:|#|mailto:|tel:)/i.test(url)) return url;
|
||||
if (/^file:/i.test(url)) return "";
|
||||
if (!isExternalMediaUrl(url)) return url;
|
||||
if (isMinioUrl(url)) return url;
|
||||
return `${MEDIA_PROXY_PATH}?src=${encodeURIComponent(url)}`;
|
||||
}
|
||||
|
||||
export function mediaUrls(values: string[]): string[] {
|
||||
return values.map(mediaUrl).filter(Boolean);
|
||||
}
|
||||
101
app/lib/meetups.ts
Normal file
101
app/lib/meetups.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
export type MeetupMode = "offline" | "online" | "hybrid";
|
||||
export type MeetupAccessLevel = "public" | "members" | "vip" | "hosts";
|
||||
|
||||
export interface MeetupFeatureOption {
|
||||
key: string;
|
||||
zh: string;
|
||||
en: string;
|
||||
}
|
||||
|
||||
export interface MiroTalkMeetupLike {
|
||||
id: string;
|
||||
city?: string;
|
||||
date?: string;
|
||||
time?: string;
|
||||
meetingUrl?: string;
|
||||
mirotalkRoom?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_MIROTALK_URL = "https://mirotalk.nomadro.cn";
|
||||
export const MIROTALK_PROVIDER_LABEL = "MiroTalk P2P";
|
||||
export const DEFAULT_LOUNGE_URL = "https://lounge.nomadro.cn";
|
||||
export const WEB_CHAT_PROVIDER_LABEL = "The Lounge + Ergo IRC";
|
||||
|
||||
export const MEETUP_FEATURE_OPTIONS: MeetupFeatureOption[] = [
|
||||
{ key: "group_video", zh: "多人视频", en: "Group video" },
|
||||
{ key: "screen_share", zh: "屏幕共享", en: "Screen share" },
|
||||
{ key: "whiteboard", zh: "白板协作", en: "Whiteboard" },
|
||||
{ key: "breakout", zh: "分组讨论", en: "Breakout rooms" },
|
||||
{ key: "recording", zh: "录制回放", en: "Recording" },
|
||||
{ key: "host_controls", zh: "主持人控场", en: "Host controls" },
|
||||
];
|
||||
|
||||
export const DEFAULT_ONLINE_FEATURES = ["group_video", "screen_share", "whiteboard", "host_controls"];
|
||||
|
||||
export function normalizeMiroTalkRoom(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
export function createMiroTalkRoomSlug(meetup: MiroTalkMeetupLike): string {
|
||||
const datePart = String(meetup.date || "").slice(0, 10).replace(/[^0-9]/g, "");
|
||||
const timePart = String(meetup.time || "").slice(0, 5).replace(/[^0-9]/g, "");
|
||||
const cityPart = normalizeMiroTalkRoom(meetup.city || "online");
|
||||
return normalizeMiroTalkRoom(["nomadcna", cityPart, datePart, timePart, meetup.id].filter(Boolean).join("-"));
|
||||
}
|
||||
|
||||
export function getMiroTalkBaseUrl(): string {
|
||||
return (
|
||||
process.env.NEXT_PUBLIC_MIROTALK_URL ||
|
||||
process.env.NEXT_PUBLIC_MIROTALK_P2P_URL ||
|
||||
process.env.NEXT_PUBLIC_MIROTALK_SFU_URL ||
|
||||
DEFAULT_MIROTALK_URL
|
||||
).replace(/\/$/, "");
|
||||
}
|
||||
|
||||
export function getMiroTalkServerLabel(): string {
|
||||
const baseUrl = getMiroTalkBaseUrl();
|
||||
try {
|
||||
return new URL(baseUrl).host;
|
||||
} catch {
|
||||
return baseUrl.replace(/^https?:\/\//i, "");
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMiroTalkJoinUrl(meetup: MiroTalkMeetupLike, displayName = "NomadCNA"): string {
|
||||
if (meetup.meetingUrl && /^https?:\/\//i.test(meetup.meetingUrl)) {
|
||||
return meetup.meetingUrl;
|
||||
}
|
||||
const room = normalizeMiroTalkRoom(meetup.mirotalkRoom || createMiroTalkRoomSlug(meetup));
|
||||
const name = displayName.trim() || "NomadCNA";
|
||||
const params = new URLSearchParams({ room, name });
|
||||
return `${getMiroTalkBaseUrl()}/join?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function getLoungeBaseUrl(): string {
|
||||
return (process.env.NEXT_PUBLIC_LOUNGE_URL || DEFAULT_LOUNGE_URL).replace(/\/$/, "");
|
||||
}
|
||||
|
||||
export function getLoungeServerLabel(): string {
|
||||
const baseUrl = getLoungeBaseUrl();
|
||||
try {
|
||||
return new URL(baseUrl).host;
|
||||
} catch {
|
||||
return baseUrl.replace(/^https?:\/\//i, "");
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMeetupChatUrl(): string {
|
||||
return getLoungeBaseUrl();
|
||||
}
|
||||
|
||||
export function featureLabels(keys: string[], locale: string): string[] {
|
||||
const selected = new Set(keys);
|
||||
return MEETUP_FEATURE_OPTIONS.filter((feature) => selected.has(feature.key)).map((feature) =>
|
||||
locale === "zh" ? feature.zh : feature.en
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user