Files
gitlab-instance-0a899031_cn…/app/[locale]/meetups/host/page.tsx
2026-06-07 23:18:53 +08:00

494 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { 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<HostMeetupForm>({
mode: "offline",
city: "",
date: "",
time: "19:00",
venue: "",
address: "",
description: "",
maxAttendees: "20",
organizer: "",
accessLevel: "members",
meetingProvider: "mirotalk",
mirotalkRoom: "",
meetingUrl: "",
features: DEFAULT_ONLINE_FEATURES,
});
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();
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: finalCity,
emoji: city?.icon || (isOnlineOnly ? "💻" : "📍"),
date: form.date,
time: form.time,
venue: finalVenue,
address: finalAddress,
description: form.description,
rsvpCount: 1,
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);
} finally {
setSubmitting(false);
}
};
if (submitted) {
return (
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
<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="mb-8 text-gray-500 dark:text-gray-400">
{t("successDesc")}
</p>
<div className="flex flex-col justify-center gap-3 sm:flex-row">
<Link
href="/meetups/host"
onClick={() => setSubmitted(false)}
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="cursor-pointer rounded-xl bg-[#ff4d4f] px-6 py-3 font-medium text-white transition-colors hover:bg-[#ff7a45]"
>
{t("viewMeetups")}
</Link>
</div>
</div>
</main>
</div>
);
}
return (
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
<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="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="flex items-center gap-2 text-2xl font-bold text-gray-900 dark:text-gray-100 sm:text-3xl">
<span className="h-8 w-1 rounded-full bg-[#ff4d4f]" />
{t("title")}
</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400 sm:text-base">
{t("subtitle")}
</p>
</header>
<form
onSubmit={handleSubmit}
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="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{t("city")} {isOnlineOnly ? "" : "*"}
</label>
<select
required={!isOnlineOnly}
value={form.city}
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="">{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 gap-4 sm:grid-cols-2">
<div>
<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) => 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="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) => 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="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{t("venue")} {isOnlineOnly ? "" : "*"}
</label>
<input
type="text"
required={!isOnlineOnly}
placeholder={isOnlineOnly ? "MiroTalk 多人视频房" : t("venuePlaceholder")}
value={form.venue}
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="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{t("address")} {isOnlineOnly ? "" : "*"}
</label>
<input
type="text"
required={!isOnlineOnly}
placeholder={isOnlineOnly ? "线上活动" : t("addressPlaceholder")}
value={form.address}
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="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) => 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 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">
{c.pendingHint}
</p>
<button
type="submit"
disabled={submitting}
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 ? "提交中..." : c.submitOnline}
</button>
</form>
</main>
</div>
);
}