494 lines
20 KiB
TypeScript
494 lines
20 KiB
TypeScript
"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>
|
||
);
|
||
}
|