This commit is contained in:
eric
2026-06-07 23:57:52 -05:00
parent 6d479c9f94
commit 47b1ae8514
56 changed files with 4163 additions and 874 deletions

View File

@@ -0,0 +1,76 @@
"use client";
import { Link } from "@/i18n/navigation";
export interface AdvancedFilterState {
gender: string;
single: string;
}
interface AdvancedFiltersProps {
value: AdvancedFilterState;
onChange: (next: AdvancedFilterState) => void;
isVip: boolean;
t: (key: string) => string;
}
const GENDER_OPTIONS = ["all", "男", "女"];
const SINGLE_OPTIONS = ["all", "单身", "恋爱中", "已婚", "不限"];
export default function AdvancedFilters({ value, onChange, isVip, t }: AdvancedFiltersProps) {
return (
<div className="relative mx-auto mb-4 max-w-md px-4">
<div
className={`rounded-2xl border p-4 ${
isVip
? "border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"
: "border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-900/60"
}`}
>
<div className="mb-3 flex items-center justify-between">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">{t("advancedFilters")}</p>
{!isVip ? (
<Link href="/pricing" className="text-xs font-semibold text-[#ff4d4f] hover:underline">
{t("upgradeVip")}
</Link>
) : null}
</div>
<div className={`grid grid-cols-2 gap-3 ${!isVip ? "pointer-events-none opacity-50" : ""}`}>
<label className="text-xs text-gray-500 dark:text-gray-400">
{t("filterGender")}
<select
value={value.gender}
onChange={(e) => onChange({ ...value, gender: e.target.value })}
disabled={!isVip}
className="mt-1 w-full rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
>
{GENDER_OPTIONS.map((option) => (
<option key={option} value={option}>
{option === "all" ? t("all") : option}
</option>
))}
</select>
</label>
<label className="text-xs text-gray-500 dark:text-gray-400">
{t("filterSingle")}
<select
value={value.single}
onChange={(e) => onChange({ ...value, single: e.target.value })}
disabled={!isVip}
className="mt-1 w-full rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
>
{SINGLE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option === "all" ? t("all") : option}
</option>
))}
</select>
</label>
</div>
{!isVip ? (
<p className="mt-3 text-xs text-gray-500 dark:text-gray-400">{t("vipFilterHint")}</p>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,32 @@
"use client";
interface CityFilterProps {
value: string;
onChange: (city: string) => void;
cities: Array<{ slug: string; name: string; nameEn?: string }>;
locale: string;
t: (key: string) => string;
}
export default function CityFilter({ value, onChange, cities, locale, t }: CityFilterProps) {
return (
<div className="mx-auto flex max-w-md items-center justify-center gap-2 px-4 pb-4">
<label htmlFor="dating-city-filter" className="text-xs font-medium text-gray-500 dark:text-gray-400">
{t("cityFilter")}
</label>
<select
id="dating-city-filter"
value={value}
onChange={(e) => onChange(e.target.value)}
className="min-w-[140px] rounded-full border border-gray-200 bg-white px-3 py-2 text-xs font-medium text-gray-700 shadow-sm outline-none focus:border-[#ff4d4f] dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200"
>
<option value="all">{t("allCities")}</option>
{cities.map((city) => (
<option key={city.slug} value={city.slug}>
{locale === "zh" ? city.name : city.nameEn || city.name}
</option>
))}
</select>
</div>
);
}

View File

@@ -0,0 +1,33 @@
"use client";
import { TAB_ORDER } from "../constants";
import type { MatchIntent } from "../types";
interface IntentTabsProps {
activeTab: MatchIntent;
onChange: (tab: MatchIntent) => void;
t: (key: string) => string;
}
export default function IntentTabs({ activeTab, onChange, t }: IntentTabsProps) {
return (
<div className="flex justify-center pt-6 pb-3 px-4">
<div className="flex max-w-3xl flex-wrap justify-center gap-2">
{TAB_ORDER.map((tab) => (
<button
key={tab}
type="button"
onClick={() => onChange(tab)}
className={`rounded-full px-4 py-2 text-xs font-medium transition-all duration-200 sm:text-sm ${
activeTab === tab
? "bg-[#ff4d4f] text-white shadow-sm"
: "border border-gray-100 bg-white text-gray-600 hover:text-gray-900 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
}`}
>
{t(tab)}
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,122 @@
"use client";
import { Link } from "@/i18n/navigation";
import { mediaUrl } from "@/app/lib/media";
import type { DatingProfile } from "../types";
interface LikesPanelProps {
likes: DatingProfile[];
mutual: DatingProfile[];
formatTime: (value?: string) => string;
t: (key: string) => string;
onSelectProfile?: (profile: DatingProfile) => void;
}
function ProfileRow({
profile,
timeLabel,
badge,
onSelect,
}: {
profile: DatingProfile;
timeLabel: string;
badge?: string;
onSelect?: () => void;
}) {
return (
<button
type="button"
onClick={onSelect}
className="flex w-full items-center gap-3 rounded-xl border border-gray-100 bg-white p-3 text-left shadow-sm transition-shadow hover:shadow-md dark:border-gray-800 dark:bg-gray-900"
>
<img
src={mediaUrl(profile.photo)}
alt={profile.name}
className="h-12 w-12 flex-shrink-0 rounded-full object-cover shadow-sm"
/>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-gray-900 dark:text-gray-100">{profile.name}</p>
<p className="truncate text-xs text-gray-500 dark:text-gray-400">📍 {profile.location}</p>
{badge ? (
<span className="mt-1 inline-flex rounded-full bg-pink-50 px-2 py-0.5 text-[10px] font-semibold text-pink-600 dark:bg-pink-950/40 dark:text-pink-200">
{badge}
</span>
) : null}
</div>
<span className="flex-shrink-0 rounded-full bg-gray-50 px-2 py-1 text-[10px] text-gray-400 dark:bg-gray-800 dark:text-gray-500">
{timeLabel}
</span>
</button>
);
}
export default function LikesPanel({ likes, mutual, formatTime, t, onSelectProfile }: LikesPanelProps) {
return (
<div className="space-y-6">
<section>
<div className="mb-3 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">{t("title")} 💕</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">{t("likedYou")}</p>
</div>
{likes.length > 0 ? (
<span className="rounded-full bg-[#ff4d4f] px-2 py-0.5 text-xs font-bold text-white">{likes.length}</span>
) : null}
</div>
<div className="space-y-3">
{likes.length ? (
likes.map((profile) => (
<ProfileRow
key={profile.id}
profile={profile}
timeLabel={formatTime(profile.likedAt)}
onSelect={() => onSelectProfile?.(profile)}
/>
))
) : (
<div className="rounded-xl border border-dashed border-gray-200 p-4 text-sm text-gray-500 dark:border-gray-700 dark:text-gray-400">
{t("noLikesYet")}
</div>
)}
</div>
</section>
<section>
<div className="mb-3 flex items-center justify-between">
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100">{t("mutualTitle")}</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">{t("mutualSubtitle")}</p>
</div>
{mutual.length > 0 ? (
<span className="rounded-full bg-emerald-500 px-2 py-0.5 text-xs font-bold text-white">{mutual.length}</span>
) : null}
</div>
<div className="space-y-3">
{mutual.length ? (
mutual.map((profile) => (
<ProfileRow
key={profile.id}
profile={profile}
timeLabel={formatTime(profile.matchedAt)}
badge={t("mutualBadge")}
onSelect={() => onSelectProfile?.(profile)}
/>
))
) : (
<div className="rounded-xl border border-dashed border-gray-200 p-4 text-sm text-gray-500 dark:border-gray-700 dark:text-gray-400">
{t("noMutualYet")}
</div>
)}
</div>
{mutual.length > 0 ? (
<Link
href="/messages"
className="mt-3 inline-flex w-full items-center justify-center rounded-full bg-[#ff4d4f] px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-600"
>
{t("goMessages")}
</Link>
) : null}
</section>
</div>
);
}

View File

@@ -0,0 +1,54 @@
"use client";
import { Link } from "@/i18n/navigation";
import { mediaUrl } from "@/app/lib/media";
import type { DatingProfile } from "../types";
interface MatchModalProps {
profile: DatingProfile | null;
onClose: () => void;
t: (key: string) => string;
}
export default function MatchModal({ profile, onClose, t }: MatchModalProps) {
if (!profile) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="w-full max-w-sm overflow-hidden rounded-3xl bg-white shadow-2xl dark:bg-gray-900">
<div className="bg-gradient-to-br from-[#ff4d4f] via-pink-500 to-rose-400 px-6 py-8 text-center text-white">
<p className="text-sm font-semibold uppercase tracking-widest opacity-90">{t("matchCelebration")}</p>
<h2 className="mt-2 text-3xl font-black">{t("itsAMatch")}</h2>
<p className="mt-2 text-sm text-white/90">{t("matchHint")}</p>
</div>
<div className="-mt-10 flex justify-center">
<img
src={mediaUrl(profile.photo)}
alt={profile.name}
className="h-24 w-24 rounded-full border-4 border-white object-cover shadow-lg dark:border-gray-900"
/>
</div>
<div className="px-6 pb-6 pt-4 text-center">
<h3 className="text-xl font-bold text-gray-900 dark:text-gray-100">{profile.name}</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">📍 {profile.location}</p>
<div className="mt-6 flex flex-col gap-3">
<Link
href="/messages"
onClick={onClose}
className="inline-flex items-center justify-center rounded-full bg-[#ff4d4f] px-4 py-3 text-sm font-semibold text-white transition hover:bg-red-600"
>
{t("sendMessage")}
</Link>
<button
type="button"
onClick={onClose}
className="rounded-full border border-gray-200 px-4 py-3 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800"
>
{t("keepSwiping")}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,143 @@
"use client";
import { Link } from "@/i18n/navigation";
import { mediaUrl } from "@/app/lib/media";
import type { DatingProfile } from "../types";
interface SwipeCardProps {
profile: DatingProfile;
badgeText: string;
dragX: number;
isDragging: boolean;
isAnimatingOut: "left" | "right" | null;
onLike: () => void;
onDislike: () => void;
onBeginDrag: (clientX: number) => void;
onMoveDrag: (clientX: number) => void;
onEndDrag: () => void;
t: (key: string) => string;
}
export default function SwipeCard({
profile,
badgeText,
dragX,
isDragging,
isAnimatingOut,
onLike,
onDislike,
onBeginDrag,
onMoveDrag,
onEndDrag,
t,
}: SwipeCardProps) {
return (
<div
className={`relative touch-pan-y select-none overflow-hidden rounded-2xl border border-white/20 bg-gradient-to-br ${profile.gradient} p-8 pb-6 shadow-xl ${
isDragging ? "cursor-grabbing" : "cursor-grab"
} transition-transform`}
style={{
transform: `translateX(${isAnimatingOut === "right" ? 420 : isAnimatingOut === "left" ? -420 : dragX}px) rotate(${dragX / 18}deg)`,
opacity: isAnimatingOut ? 0.25 : 1,
transitionDuration: isDragging ? "0ms" : "180ms",
}}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "ArrowRight") onLike();
if (e.key === "ArrowLeft") onDislike();
}}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
onBeginDrag(e.clientX);
}}
onPointerMove={(e) => onMoveDrag(e.clientX)}
onPointerUp={onEndDrag}
onPointerCancel={onEndDrag}
>
<div
className={`pointer-events-none absolute left-5 top-5 rounded-full border-2 border-white/80 px-4 py-2 text-sm font-bold text-white transition-opacity ${
dragX > 45 ? "opacity-100" : "opacity-0"
}`}
>
{t("likeOverlay")}
</div>
<div
className={`pointer-events-none absolute right-5 top-5 rounded-full border-2 border-white/80 px-4 py-2 text-sm font-bold text-white transition-opacity ${
dragX < -45 ? "opacity-100" : "opacity-0"
}`}
>
{t("passOverlay")}
</div>
<div className="absolute right-4 top-4 flex flex-col items-end gap-1">
<span className="rounded-full bg-white/90 px-3 py-1.5 text-xs font-medium text-gray-800 backdrop-blur-sm">
{badgeText}
</span>
<Link
href={`/feedback?type=report&target=${encodeURIComponent(profile.id)}`}
className="text-sm font-medium text-[#ff4d4f] hover:text-red-600"
>
{t("report")}
</Link>
</div>
<div className="mb-6 mt-12 flex justify-center">
<img
src={mediaUrl(profile.photo)}
alt={profile.name}
className="h-28 w-28 rounded-full border-4 border-white/50 object-cover shadow-lg"
/>
</div>
<div className="mb-4 text-center">
<h3 className="text-xl font-bold text-white drop-shadow-sm">
{profile.name}{profile.age}
</h3>
<p className="mt-1 text-sm text-white/90">📍 {profile.location}</p>
{(profile.gender || profile.single) && (
<p className="mt-1 text-xs text-white/80">
{[profile.gender, profile.single].filter(Boolean).join(" · ")}
</p>
)}
</div>
<div className="mb-4">
<p className="mb-2 text-xs font-medium text-white/90">{t("matchTags")}:</p>
<div className="flex flex-wrap justify-center gap-2">
{profile.tags.map((tag) => (
<span key={tag} className="rounded-full bg-white/80 px-3 py-1 text-xs font-medium text-gray-800">
{tag}
</span>
))}
</div>
</div>
<p className="mb-8 px-2 text-center text-sm leading-relaxed text-white/95">{profile.bio}</p>
<div className="flex items-center justify-center gap-8">
<div className="flex flex-col items-center gap-2">
<button
type="button"
onClick={onDislike}
className="flex h-16 w-16 items-center justify-center rounded-full bg-white text-[#ff4d4f] shadow-lg transition-all hover:scale-105 hover:bg-red-50 active:scale-95"
aria-label={t("dislike")}
>
<span className="text-2xl"></span>
</button>
<span className="text-xs font-medium text-white/80">{t("dislike")}</span>
</div>
<div className="flex flex-col items-center gap-2">
<button
type="button"
onClick={onLike}
className="flex h-16 w-16 items-center justify-center rounded-full bg-[#22c55e] text-white shadow-lg transition-all hover:scale-105 hover:bg-[#16a34a] active:scale-95"
aria-label={t("like")}
>
<span className="text-2xl">💚</span>
</button>
<span className="text-xs font-medium text-white/80">{t("like")}</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import { Link } from "@/i18n/navigation";
interface SwipeQuotaProps {
vip: boolean;
used: number;
limit: number;
remaining: number;
t: (key: string) => string;
}
export default function SwipeQuota({ vip, used, limit, remaining, t }: SwipeQuotaProps) {
if (vip) {
return (
<div className="mx-auto mb-3 max-w-md px-4">
<div className="rounded-full bg-emerald-50 px-4 py-2 text-center text-xs font-semibold text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-200">
{t("vipUnlimited")}
</div>
</div>
);
}
const pct = Math.min(100, Math.round((used / Math.max(limit, 1)) * 100));
return (
<div className="mx-auto mb-3 max-w-md px-4">
<div className="rounded-2xl border border-gray-200 bg-white px-4 py-3 dark:border-gray-800 dark:bg-gray-900">
<div className="mb-2 flex items-center justify-between text-xs">
<span className="font-medium text-gray-600 dark:text-gray-300">{t("dailyQuota")}</span>
<span className="text-gray-500 dark:text-gray-400">
{remaining} / {limit}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div
className={`h-full rounded-full transition-all ${remaining <= 3 ? "bg-[#ff4d4f]" : "bg-[#ff4d4f]/80"}`}
style={{ width: `${pct}%` }}
/>
</div>
{remaining <= 3 ? (
<p className="mt-2 text-xs text-[#ff4d4f]">
{t("quotaLow")}{" "}
<Link href="/pricing" className="font-semibold underline">
{t("upgradeVip")}
</Link>
</p>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,70 @@
"use client";
import { mediaUrl } from "@/app/lib/media";
import type { DatingProfile } from "../types";
import SwipeCard from "./SwipeCard";
interface SwipeStackProps {
current: DatingProfile;
next?: DatingProfile;
badgeText: string;
dragX: number;
isDragging: boolean;
isAnimatingOut: "left" | "right" | null;
onLike: () => void;
onDislike: () => void;
onBeginDrag: (clientX: number) => void;
onMoveDrag: (clientX: number) => void;
onEndDrag: () => void;
t: (key: string) => string;
}
export default function SwipeStack({
current,
next,
badgeText,
dragX,
isDragging,
isAnimatingOut,
onLike,
onDislike,
onBeginDrag,
onMoveDrag,
onEndDrag,
t,
}: SwipeStackProps) {
return (
<div className="relative">
{next ? (
<div
aria-hidden
className={`pointer-events-none absolute inset-x-0 top-3 mx-auto w-[92%] scale-[0.96] rounded-2xl bg-gradient-to-br ${next.gradient} opacity-70 shadow-lg`}
>
<div className="flex justify-center py-10">
<img
src={mediaUrl(next.photo)}
alt=""
className="h-20 w-20 rounded-full border-4 border-white/40 object-cover"
/>
</div>
<div className="pb-6 text-center text-sm font-semibold text-white/90">{next.name}</div>
</div>
) : null}
<div className="relative z-10">
<SwipeCard
profile={current}
badgeText={badgeText}
dragX={dragX}
isDragging={isDragging}
isAnimatingOut={isAnimatingOut}
onLike={onLike}
onDislike={onDislike}
onBeginDrag={onBeginDrag}
onMoveDrag={onMoveDrag}
onEndDrag={onEndDrag}
t={t}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,128 @@
import { avatarForIndex } from "@/app/data/avatars";
import type { DatingProfile, MatchIntent } from "./types";
export const TAB_ORDER: MatchIntent[] = [
"friends",
"dating",
"partner",
"roommate",
"cofounder",
"explore",
];
export const PROFILE_GRADIENTS = [
"from-rose-400 via-pink-400 to-fuchsia-400",
"from-amber-400 via-orange-400 to-red-400",
"from-emerald-400 via-teal-400 to-cyan-400",
"from-violet-400 via-purple-400 to-indigo-400",
"from-blue-400 via-indigo-400 to-violet-500",
"from-sky-400 via-cyan-400 to-teal-400",
];
export const FALLBACK_PROFILES: DatingProfile[] = [
{
id: "fb-1",
name: "林晓雨",
age: 28,
location: "大理",
citySlug: "dali",
gender: "女",
single: "单身",
lookingFor: ["friends", "dating", "explore"],
tags: ["本科学历", "远程工作者", "数字游民", "咖啡爱好者"],
bio: "数字游民三年,目前在云南大理远程做产品设计。喜欢爬山、摄影,周末常去洱海边发呆。",
gradient: PROFILE_GRADIENTS[0],
initial: "林",
avatarColor: "bg-rose-500",
photo: avatarForIndex(0),
},
{
id: "fb-2",
name: "陈浩然",
age: 32,
location: "成都",
citySlug: "chengdu",
gender: "男",
single: "单身",
lookingFor: ["friends", "partner", "cofounder"],
tags: ["硕士学历", "独立开发者", "自由职业者", "素食主义"],
bio: "在成都住了两年,做独立开发。喜欢冲浪和冥想,寻找志同道合的旅伴一起探索世界。",
gradient: PROFILE_GRADIENTS[1],
initial: "陈",
avatarColor: "bg-amber-500",
photo: avatarForIndex(4),
},
{
id: "fb-3",
name: "王思琪",
age: 26,
location: "深圳",
citySlug: "shenzhen",
gender: "女",
single: "单身",
lookingFor: ["dating", "roommate", "explore"],
tags: ["本科学历", "内容创作者", "数字游民", "环保主义者"],
bio: "自由撰稿人,在深圳写写画画。热爱瑜伽和冥想,相信慢生活才是真正的奢侈。",
gradient: PROFILE_GRADIENTS[2],
initial: "王",
avatarColor: "bg-emerald-500",
photo: avatarForIndex(2),
},
{
id: "fb-4",
name: "张明远",
age: 30,
location: "杭州",
citySlug: "hangzhou",
gender: "男",
single: "单身",
lookingFor: ["cofounder", "friends"],
tags: ["MBA学历", "创业中", "自由职业者", "徒步爱好者"],
bio: "正在杭州创业,做远程团队协作工具。周末喜欢去西湖徒步,偶尔品鉴龙井茶。",
gradient: PROFILE_GRADIENTS[3],
initial: "张",
avatarColor: "bg-violet-500",
photo: avatarForIndex(28),
},
{
id: "fb-5",
name: "李雅婷",
age: 27,
location: "厦门",
citySlug: "xiamen",
gender: "女",
single: "单身",
lookingFor: ["explore", "friends"],
tags: ["本科学历", "UI设计师", "数字游民", "摄影爱好者"],
bio: "在厦门远程做UI设计业余时间探索闽南美食。喜欢用相机记录生活寻找一起探店的朋友。",
gradient: PROFILE_GRADIENTS[4],
initial: "李",
avatarColor: "bg-rose-500",
photo: avatarForIndex(12),
},
{
id: "fb-6",
name: "刘子轩",
age: 33,
location: "昆明",
citySlug: "kunming",
gender: "男",
single: "单身",
lookingFor: ["roommate", "friends"],
tags: ["博士学历", "数据科学家", "远程工作者", "户外爱好者"],
bio: "在昆明远程工作,热爱户外运动。正在探索云南各地,希望认识更多志同道合的朋友。",
gradient: PROFILE_GRADIENTS[5],
initial: "刘",
avatarColor: "bg-blue-500",
photo: avatarForIndex(10),
},
];
export const BADGE_KEYS: Record<MatchIntent, string> = {
friends: "badgeFriends",
dating: "badgeDating",
partner: "badgePartner",
roommate: "badgeRoommate",
cofounder: "badgeCofounder",
explore: "badgeExplore",
};

View File

@@ -1,161 +1,238 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useTranslation } from "@/i18n/navigation";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocale, useTranslation } from "@/i18n/navigation";
import { useSearchParams } from "next/navigation";
import Footer from "@/app/components/Footer";
import { apiFetch } from "@/app/lib/api-client";
import { mediaUrl } from "@/app/lib/media";
import { avatarForIndex, avatarForName } from "@/app/data/avatars";
import { cities } from "@/app/data/cities";
import { fetchAuthMe } from "@/app/lib/api-client";
import IntentTabs from "./components/IntentTabs";
import CityFilter from "./components/CityFilter";
import LikesPanel from "./components/LikesPanel";
import SwipeStack from "./components/SwipeStack";
import MatchModal from "./components/MatchModal";
import AdvancedFilters from "./components/AdvancedFilters";
import SwipeQuota from "./components/SwipeQuota";
import { BADGE_KEYS } from "./constants";
import type { DatingProfile, MatchIntent } from "./types";
import type { MatchQuota } from "./utils";
import {
fetchCandidates,
fetchLikes,
fetchMutualMatches,
fetchQuota,
filterFallbackProfiles,
formatTimeAgo,
saveSwipe,
undoSwipe,
} from "./utils";
type TabType = "friends" | "dating" | "partner" | "roommate" | "cofounder" | "explore";
interface Profile {
id: string;
name: string;
age: number;
location: string;
tags: string[];
bio: string;
gradient: string;
initial: string;
avatarColor: string;
photo: string;
function DatingSkeleton() {
return (
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
<div className="mx-auto max-w-md animate-pulse px-4 py-16">
<div className="h-[520px] rounded-2xl bg-gray-200 dark:bg-gray-800" />
</div>
</div>
);
}
interface MatchCard {
id: string;
name: string;
location: string;
timeAgo: string;
initial: string;
color: string;
photo: string;
}
interface ProfileRecord {
id: string;
name?: string;
age?: number;
location?: string;
tags?: string[];
bio?: string;
photo?: string;
}
const FALLBACK_PROFILES: Profile[] = [
{ id: "1", name: "林晓雨", age: 28, location: "大理", tags: ["本科学历", "远程工作者", "数字游民", "咖啡爱好者"], bio: "数字游民三年,目前在云南大理远程做产品设计。喜欢爬山、摄影,周末常去洱海边发呆。", gradient: "from-rose-400 via-pink-400 to-fuchsia-400", initial: "林", avatarColor: "bg-rose-500", photo: avatarForIndex(0) },
{ id: "2", name: "陈浩然", age: 32, location: "成都", tags: ["硕士学历", "独立开发者", "自由职业者", "素食主义"], bio: "在成都住了两年,做独立开发。喜欢冲浪和冥想,寻找志同道合的旅伴一起探索世界。", gradient: "from-amber-400 via-orange-400 to-red-400", initial: "陈", avatarColor: "bg-amber-500", photo: avatarForIndex(4) },
{ id: "3", name: "王思琪", age: 26, location: "深圳", tags: ["本科学历", "内容创作者", "数字游民", "环保主义者"], bio: "自由撰稿人,在深圳写写画画。热爱瑜伽和冥想,相信慢生活才是真正的奢侈。", gradient: "from-emerald-400 via-teal-400 to-cyan-400", initial: "王", avatarColor: "bg-emerald-500", photo: avatarForIndex(2) },
{ id: "4", name: "张明远", age: 30, location: "杭州", tags: ["MBA学历", "创业中", "自由职业者", "徒步爱好者"], bio: "正在杭州创业,做远程团队协作工具。周末喜欢去西湖徒步,偶尔品鉴龙井茶。", gradient: "from-violet-400 via-purple-400 to-indigo-400", initial: "张", avatarColor: "bg-violet-500", photo: avatarForIndex(28) },
{ id: "5", name: "李雅婷", age: 27, location: "厦门", tags: ["本科学历", "UI设计师", "数字游民", "摄影爱好者"], bio: "在厦门远程做UI设计业余时间探索闽南美食。喜欢用相机记录生活寻找一起探店的朋友。", gradient: "from-rose-500 via-red-400 to-orange-500", initial: "李", avatarColor: "bg-rose-500", photo: avatarForIndex(12) },
{ id: "6", name: "刘子轩", age: 33, location: "昆明", tags: ["博士学历", "数据科学家", "远程工作者", "户外爱好者"], bio: "在昆明远程工作,热爱户外运动。正在探索云南各地,希望认识更多志同道合的朋友。", gradient: "from-blue-400 via-indigo-400 to-violet-500", initial: "刘", avatarColor: "bg-blue-500", photo: avatarForIndex(10) },
];
const FALLBACK_MATCHES: MatchCard[] = [
{ id: "m1", name: "周雨桐", location: "杭州", timeAgo: "2天前", initial: "周", color: "bg-pink-400", photo: avatarForIndex(6) },
{ id: "m2", name: "吴俊杰", location: "成都", timeAgo: "1周前", initial: "吴", color: "bg-amber-400", photo: avatarForIndex(30) },
{ id: "m3", name: "郑诗涵", location: "上海", timeAgo: "3周前", initial: "郑", color: "bg-emerald-400", photo: avatarForIndex(15) },
{ id: "m4", name: "孙宇航", location: "深圳", timeAgo: "1月前", initial: "孙", color: "bg-violet-400", photo: avatarForIndex(18) },
{ id: "m5", name: "赵梦琪", location: "厦门", timeAgo: "1年前", initial: "赵", color: "bg-rose-400", photo: avatarForIndex(24) },
];
const TAB_ORDER: TabType[] = ["friends", "dating", "partner", "roommate", "cofounder", "explore"];
const PROFILE_GRADIENTS = [
"from-rose-400 via-pink-400 to-fuchsia-400",
"from-amber-400 via-orange-400 to-red-400",
"from-emerald-400 via-teal-400 to-cyan-400",
"from-violet-400 via-purple-400 to-indigo-400",
"from-blue-400 via-indigo-400 to-violet-500",
];
function mapProfile(record: ProfileRecord, index: number): Profile {
const name = record.name || "数字游民";
const initial = name.slice(0, 1);
return {
id: record.id,
name,
age: Number(record.age || 28 + index),
location: record.location || "远程",
tags: Array.isArray(record.tags) ? record.tags : ["数字游民", "远程工作者"],
bio: record.bio || "正在完善个人资料,期待结识同城和同路的远程工作伙伴。",
gradient: PROFILE_GRADIENTS[index % PROFILE_GRADIENTS.length],
initial,
avatarColor: "bg-[#ff4d4f]",
photo: record.photo || avatarForName(name),
};
}
function toMatch(profile: Profile, index: number): MatchCard {
return {
id: `m-${profile.id}`,
name: profile.name,
location: profile.location,
timeAgo: index < 2 ? "刚刚活跃" : "本周活跃",
initial: profile.initial,
color: profile.avatarColor,
photo: profile.photo,
};
}
export default function DatingPage() {
function DatingPageInner() {
const { t } = useTranslation("match");
const [activeTab, setActiveTab] = useState<TabType>("friends");
const locale = useLocale();
const searchParams = useSearchParams();
const initialIntent = (searchParams.get("intent") as MatchIntent) || "friends";
const initialCity = searchParams.get("city") || "all";
const [activeTab, setActiveTab] = useState<MatchIntent>(
["friends", "dating", "partner", "roommate", "cofounder", "explore"].includes(initialIntent)
? initialIntent
: "friends"
);
const [cityFilter, setCityFilter] = useState(initialCity);
const [profiles, setProfiles] = useState<DatingProfile[]>([]);
const [likes, setLikes] = useState<DatingProfile[]>([]);
const [mutual, setMutual] = useState<DatingProfile[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [profiles, setProfiles] = useState<Profile[]>(FALLBACK_PROFILES);
const [loading, setLoading] = useState(true);
const [usingFallback, setUsingFallback] = useState(false);
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [isVip, setIsVip] = useState(false);
const [quota, setQuota] = useState<MatchQuota>({ vip: false, limit: 25, used: 0, remaining: 25 });
const [advancedFilters, setAdvancedFilters] = useState({ gender: "all", single: "all" });
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
const [showQuotaPrompt, setShowQuotaPrompt] = useState(false);
const [canUndo, setCanUndo] = useState(false);
const [matchCelebration, setMatchCelebration] = useState<DatingProfile | null>(null);
const [mobilePanelOpen, setMobilePanelOpen] = useState(false);
const [dragX, setDragX] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [isAnimatingOut, setIsAnimatingOut] = useState<"left" | "right" | null>(null);
const startXRef = useRef(0);
const swipeLockRef = useRef(false);
const cityOptions = useMemo(
() =>
cities.map((city) => ({
slug: city.slug || city.name,
name: city.name,
nameEn: city.nameEn,
})),
[]
);
const refreshSidePanels = useCallback(async () => {
if (!isLoggedIn) {
setLikes([]);
setMutual([]);
return;
}
try {
const [likesData, mutualData] = await Promise.all([fetchLikes(), fetchMutualMatches()]);
setLikes(likesData);
setMutual(mutualData);
} catch {
setLikes([]);
setMutual([]);
}
}, [isLoggedIn]);
const refreshQuota = useCallback(async () => {
if (!isLoggedIn) return;
try {
const data = await fetchQuota();
setQuota(data);
setIsVip(data.vip);
} catch {
// ignore
}
}, [isLoggedIn]);
const loadCandidates = useCallback(
async (intent: MatchIntent, city: string, advanced = advancedFilters) => {
setLoading(true);
setCurrentIndex(0);
setCanUndo(false);
try {
const items = await fetchCandidates(intent, city, advanced);
if (items.length) {
setProfiles(items);
setUsingFallback(false);
} else {
const fallback = filterFallbackProfiles(intent, city, advanced);
setProfiles(fallback);
setUsingFallback(true);
}
} catch {
const fallback = filterFallbackProfiles(intent, city, advanced);
setProfiles(fallback);
setUsingFallback(true);
} finally {
setLoading(false);
}
},
[advancedFilters]
);
useEffect(() => {
apiFetch<{ items: ProfileRecord[] }>("/api/profiles")
.then((data) => {
if (data.items?.length) {
setProfiles(data.items.map(mapProfile));
setCurrentIndex(0);
}
})
.catch(() => setProfiles(FALLBACK_PROFILES));
fetchAuthMe().then((data) => setIsLoggedIn(Boolean(data?.user?.id)));
}, []);
const matches = useMemo(
() => (profiles.length ? profiles.slice(0, 5).map(toMatch) : FALLBACK_MATCHES),
[profiles]
);
const currentProfile = profiles[currentIndex] || profiles[0] || FALLBACK_PROFILES[0];
useEffect(() => {
if (isLoggedIn) refreshQuota();
}, [isLoggedIn, refreshQuota]);
useEffect(() => {
loadCandidates(activeTab, cityFilter, advancedFilters);
}, [activeTab, cityFilter, advancedFilters, loadCandidates]);
useEffect(() => {
refreshSidePanels();
}, [refreshSidePanels]);
useEffect(() => {
const params = new URLSearchParams();
params.set("intent", activeTab);
if (cityFilter && cityFilter !== "all") params.set("city", cityFilter);
const next = `/dating?${params.toString()}`;
window.history.replaceState(null, "", next);
}, [activeTab, cityFilter]);
const currentProfile = profiles[currentIndex];
const nextProfile = profiles[currentIndex + 1];
const hasMoreProfiles = currentIndex < profiles.length - 1;
const badgeText = t(BADGE_KEYS[activeTab]);
const formatTime = (value?: string) => formatTimeAgo(value, locale);
const saveSwipe = (action: "like" | "dislike") => {
if (!currentProfile) return;
apiFetch("/api/matches/swipes", {
method: "POST",
body: JSON.stringify({ profileId: currentProfile.id, action }),
}).catch(() => {});
};
const nextProfile = () => {
const advanceProfile = () => {
window.setTimeout(() => {
setCurrentIndex((i) => (hasMoreProfiles ? i + 1 : 0));
setCurrentIndex((index) => (hasMoreProfiles ? index + 1 : index));
setDragX(0);
setIsAnimatingOut(null);
}, 160);
swipeLockRef.current = false;
}, 180);
};
const handleLike = () => {
saveSwipe("like");
setIsAnimatingOut("right");
setDragX(260);
nextProfile();
const performSwipe = async (action: "like" | "dislike") => {
if (!currentProfile || swipeLockRef.current) return;
if (!isLoggedIn) {
setShowLoginPrompt(true);
setDragX(0);
return;
}
if (!quota.vip && quota.remaining <= 0) {
setShowQuotaPrompt(true);
setDragX(0);
return;
}
swipeLockRef.current = true;
setIsAnimatingOut(action === "like" ? "right" : "left");
setDragX(action === "like" ? 260 : -260);
try {
if (!usingFallback) {
const result = await saveSwipe(currentProfile.id, action, activeTab);
setCanUndo(true);
if (result.matched) {
setMatchCelebration(currentProfile);
refreshSidePanels();
} else if (action === "like") {
refreshSidePanels();
}
refreshQuota();
} else {
setCanUndo(true);
}
} catch (err) {
const message = err instanceof Error ? err.message : "";
if (message.includes("滑动次数") || message.includes("升级会员")) {
setShowQuotaPrompt(true);
} else {
setShowLoginPrompt(true);
}
swipeLockRef.current = false;
setIsAnimatingOut(null);
setDragX(0);
return;
} finally {
advanceProfile();
}
};
const handleDislike = () => {
saveSwipe("dislike");
setIsAnimatingOut("left");
setDragX(-260);
nextProfile();
const handleUndo = async () => {
if (!canUndo || currentIndex <= 0) return;
try {
if (!usingFallback) await undoSwipe();
setCurrentIndex((index) => Math.max(0, index - 1));
setCanUndo(false);
refreshQuota();
} catch {
// ignore
}
};
const handleLike = () => performSwipe("like");
const handleDislike = () => performSwipe("dislike");
const beginDrag = (clientX: number) => {
startXRef.current = clientX;
setIsDragging(true);
@@ -180,130 +257,226 @@ export default function DatingPage() {
setDragX(0);
};
const badgeMap: Record<TabType, string> = {
friends: t("badgeFriends"),
dating: t("badgeDating"),
partner: t("badgePartner"),
roommate: t("badgeRoommate"),
cofounder: t("badgeCofounder"),
explore: t("badgeExplore"),
const jumpToProfile = (profile: DatingProfile) => {
const index = profiles.findIndex((item) => item.id === profile.id);
if (index >= 0) setCurrentIndex(index);
setMobilePanelOpen(false);
};
const badgeText = badgeMap[activeTab];
return (
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
<div className="flex justify-center pt-6 pb-4 px-4">
<div className="flex flex-wrap justify-center gap-2 max-w-2xl">
{TAB_ORDER.map((tab) => (
<div className="border-b border-gray-100 bg-white/80 backdrop-blur dark:border-gray-800 dark:bg-gray-950/80">
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-4">
<div>
<h1 className="text-lg font-bold text-gray-900 dark:text-gray-100">{t("heroTitle")}</h1>
<p className="text-xs text-gray-500 dark:text-gray-400">{t("heroSubtitle")}</p>
</div>
<div className="flex items-center gap-2">
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 rounded-full text-xs sm:text-sm font-medium transition-all duration-200 ${
activeTab === tab
? "bg-[#ff4d4f] text-white shadow-sm"
: "bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 border border-gray-100 dark:border-gray-800"
}`}
type="button"
onClick={() => setMobilePanelOpen(true)}
className="rounded-full border border-gray-200 px-3 py-1.5 text-xs font-semibold text-gray-700 md:hidden dark:border-gray-700 dark:text-gray-200"
>
{t(tab)}
{t("mobilePanel")} {likes.length + mutual.length > 0 ? `(${likes.length + mutual.length})` : ""}
</button>
))}
<Link
href="/join"
className="rounded-full bg-[#ff4d4f] px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-red-600"
>
{t("editProfile")}
</Link>
</div>
</div>
</div>
<div className="flex flex-col md:flex-row gap-6 max-w-7xl mx-auto px-4 pb-12">
<aside className="hidden md:block w-72 flex-shrink-0">
<IntentTabs activeTab={activeTab} onChange={setActiveTab} t={t} />
<CityFilter value={cityFilter} onChange={setCityFilter} cities={cityOptions} locale={locale} t={t} />
<AdvancedFilters
value={advancedFilters}
onChange={setAdvancedFilters}
isVip={isVip}
t={t}
/>
{isLoggedIn ? <SwipeQuota vip={quota.vip} used={quota.used} limit={quota.limit} remaining={quota.remaining} t={t} /> : null}
{!isLoggedIn ? (
<div className="mx-auto mb-4 max-w-md px-4">
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-100">
{t("loginHint")}{" "}
<Link href="/join" className="font-semibold underline">
{t("loginAction")}
</Link>
</div>
</div>
) : null}
{usingFallback ? (
<p className="mx-auto mb-3 max-w-md px-4 text-center text-xs text-gray-400">{t("fallbackHint")}</p>
) : null}
<div className="mx-auto flex max-w-7xl flex-col gap-6 px-4 pb-12 md:flex-row">
<aside className="hidden w-72 flex-shrink-0 md:block">
<div className="sticky top-24">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">{t("title")} 💕</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">{t("likedYou")}</p>
<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={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>
</div>
<span className="text-xs text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-gray-800 px-2 py-1 rounded-full flex-shrink-0">{match.timeAgo}</span>
</div>
))}
</div>
<LikesPanel
likes={likes}
mutual={mutual}
formatTime={formatTime}
t={t}
onSelectProfile={jumpToProfile}
/>
</div>
</aside>
<main className="flex-1 min-w-0 flex flex-col items-center">
<div className="w-full max-w-md mx-auto">
<div
className={`relative rounded-2xl overflow-hidden bg-gradient-to-br ${currentProfile.gradient} p-8 pb-6 shadow-xl border border-white/20 touch-pan-y select-none ${isDragging ? "cursor-grabbing" : "cursor-grab"} transition-transform`}
style={{
transform: `translateX(${isAnimatingOut === "right" ? 420 : isAnimatingOut === "left" ? -420 : dragX}px) rotate(${dragX / 18}deg)`,
opacity: isAnimatingOut ? 0.25 : 1,
transitionDuration: isDragging ? "0ms" : "180ms",
}}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "ArrowRight") handleLike();
if (e.key === "ArrowLeft") handleDislike();
}}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
beginDrag(e.clientX);
}}
onPointerMove={(e) => moveDrag(e.clientX)}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
<div className={`pointer-events-none absolute left-5 top-5 rounded-full border-2 border-white/80 px-4 py-2 text-sm font-bold text-white transition-opacity ${dragX > 45 ? "opacity-100" : "opacity-0"}`}>
</div>
<div className={`pointer-events-none absolute right-5 top-5 rounded-full border-2 border-white/80 px-4 py-2 text-sm font-bold text-white transition-opacity ${dragX < -45 ? "opacity-100" : "opacity-0"}`}>
</div>
<div className="absolute top-4 right-4 flex flex-col items-end gap-1">
<span className="px-3 py-1.5 rounded-full bg-white/90 text-gray-800 text-xs font-medium backdrop-blur-sm">{badgeText}</span>
<Link
href={`/feedback?type=report&target=${encodeURIComponent(currentProfile.id)}`}
className="text-[#ff4d4f] hover:text-red-600 text-sm font-medium"
>
{t("report")}
</Link>
</div>
<div className="flex justify-center mt-12 mb-6">
<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>
<p className="text-white/90 text-sm mt-1">📍 {currentProfile.location}</p>
</div>
<div className="mb-4">
<p className="text-white/90 text-xs font-medium mb-2">{t("matchTags")}:</p>
<div className="flex flex-wrap gap-2 justify-center">
{currentProfile.tags.map((tag) => (
<span key={tag} className="px-3 py-1 rounded-full bg-white/80 text-gray-800 text-xs font-medium">{tag}</span>
))}
</div>
</div>
<p className="text-white/95 text-sm leading-relaxed text-center mb-8 px-2">{currentProfile.bio}</p>
<div className="flex items-center justify-center gap-8">
<div className="flex flex-col items-center gap-2">
<button onClick={handleDislike} className="w-16 h-16 rounded-full bg-white shadow-lg flex items-center justify-center text-[#ff4d4f] hover:bg-red-50 hover:scale-105 active:scale-95 transition-all" aria-label={t("dislike")}>
<span className="text-2xl"></span>
<main className="flex min-w-0 flex-1 flex-col items-center">
<div className="w-full max-w-md">
{loading ? (
<div className="h-[520px] animate-pulse rounded-2xl bg-gray-200 dark:bg-gray-800" />
) : currentProfile ? (
<>
<SwipeStack
current={currentProfile}
next={nextProfile}
badgeText={badgeText}
dragX={dragX}
isDragging={isDragging}
isAnimatingOut={isAnimatingOut}
onLike={handleLike}
onDislike={handleDislike}
onBeginDrag={beginDrag}
onMoveDrag={moveDrag}
onEndDrag={endDrag}
t={t}
/>
<div className="mt-4 flex items-center justify-center gap-3">
<button
type="button"
onClick={handleUndo}
disabled={!canUndo || currentIndex <= 0}
className="rounded-full border border-gray-200 px-4 py-2 text-xs font-semibold text-gray-600 transition enabled:hover:bg-gray-50 disabled:opacity-40 dark:border-gray-700 dark:text-gray-300"
>
{t("undo")}
</button>
<span className="text-xs text-white/80 font-medium">{t("dislike")}</span>
<p className="text-sm text-gray-500 dark:text-gray-400">
{currentIndex + 1} / {profiles.length}
</p>
</div>
<div className="flex flex-col items-center gap-2">
<button onClick={handleLike} className="w-16 h-16 rounded-full bg-[#22c55e] shadow-lg flex items-center justify-center text-white hover:bg-[#16a34a] hover:scale-105 active:scale-95 transition-all" aria-label={t("like")}>
<span className="text-2xl">💚</span>
<p className="mt-1 text-center text-xs text-gray-400 dark:text-gray-500">{t("keyboardHint")}</p>
</>
) : (
<div className="rounded-2xl border border-dashed border-gray-200 bg-white p-10 text-center dark:border-gray-700 dark:bg-gray-900">
<div className="text-4xl">🌏</div>
<h3 className="mt-4 text-lg font-semibold text-gray-900 dark:text-gray-100">{t("emptyTitle")}</h3>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">{t("emptySubtitle")}</p>
<div className="mt-6 flex flex-wrap justify-center gap-2">
<button
type="button"
onClick={() => {
setCityFilter("all");
setActiveTab("friends");
}}
className="rounded-full border border-gray-200 px-4 py-2 text-sm font-medium dark:border-gray-700"
>
{t("resetFilters")}
</button>
<span className="text-xs text-white/80 font-medium">{t("like")}</span>
<Link
href="/join"
className="rounded-full bg-[#ff4d4f] px-4 py-2 text-sm font-semibold text-white"
>
{t("editProfile")}
</Link>
</div>
</div>
</div>
<p className="text-center text-sm text-gray-500 dark:text-gray-400 mt-4">{currentIndex + 1} / {profiles.length}</p>
)}
</div>
</main>
<div className="hidden lg:block w-72 flex-shrink-0" />
<div className="hidden w-72 flex-shrink-0 lg:block" />
</div>
{mobilePanelOpen ? (
<div className="fixed inset-0 z-40 md:hidden">
<button
type="button"
aria-label={t("closePanel")}
className="absolute inset-0 bg-black/40"
onClick={() => setMobilePanelOpen(false)}
/>
<div className="absolute bottom-0 max-h-[80vh] w-full overflow-y-auto rounded-t-3xl bg-[#fafafa] p-4 dark:bg-gray-950">
<div className="mb-4 flex items-center justify-between">
<h2 className="font-semibold text-gray-900 dark:text-gray-100">{t("mobilePanel")}</h2>
<button type="button" onClick={() => setMobilePanelOpen(false)} className="text-sm text-gray-500">
{t("closePanel")}
</button>
</div>
<LikesPanel
likes={likes}
mutual={mutual}
formatTime={formatTime}
t={t}
onSelectProfile={jumpToProfile}
/>
</div>
</div>
) : null}
{showQuotaPrompt ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-sm rounded-2xl bg-white p-6 dark:bg-gray-900">
<h3 className="text-lg font-bold text-gray-900 dark:text-gray-100">{t("quotaExceededTitle")}</h3>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">{t("quotaExceededBody")}</p>
<div className="mt-5 flex gap-3">
<Link
href="/pricing"
className="flex-1 rounded-full bg-[#ff4d4f] px-4 py-2 text-center text-sm font-semibold text-white"
>
{t("upgradeVip")}
</Link>
<button
type="button"
onClick={() => setShowQuotaPrompt(false)}
className="flex-1 rounded-full border border-gray-200 px-4 py-2 text-sm font-semibold dark:border-gray-700"
>
{t("closePanel")}
</button>
</div>
</div>
</div>
) : null}
{showLoginPrompt ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-sm rounded-2xl bg-white p-6 dark:bg-gray-900">
<h3 className="text-lg font-bold text-gray-900 dark:text-gray-100">{t("loginRequiredTitle")}</h3>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">{t("loginRequiredBody")}</p>
<div className="mt-5 flex gap-3">
<Link
href="/join"
className="flex-1 rounded-full bg-[#ff4d4f] px-4 py-2 text-center text-sm font-semibold text-white"
>
{t("loginAction")}
</Link>
<button
type="button"
onClick={() => setShowLoginPrompt(false)}
className="flex-1 rounded-full border border-gray-200 px-4 py-2 text-sm font-semibold dark:border-gray-700"
>
{t("closePanel")}
</button>
</div>
</div>
</div>
) : null}
<MatchModal profile={matchCelebration} onClose={() => setMatchCelebration(null)} t={t} />
<Footer />
</div>
);
}
export default function DatingPage() {
return (
<Suspense fallback={<DatingSkeleton />}>
<DatingPageInner />
</Suspense>
);
}

View File

@@ -0,0 +1,45 @@
export type MatchIntent = "friends" | "dating" | "partner" | "roommate" | "cofounder" | "explore";
export interface ProfileRecord {
id: string;
name?: string;
age?: number;
location?: string;
citySlug?: string;
gender?: string;
single?: string;
tags?: string[];
bio?: string;
photo?: string;
lookingFor?: MatchIntent[];
likedAt?: string;
matchedAt?: string;
intent?: MatchIntent;
connectionId?: string;
}
export interface DatingProfile {
id: string;
name: string;
age: number;
location: string;
citySlug: string;
gender: string;
single: string;
tags: string[];
bio: string;
lookingFor: MatchIntent[];
gradient: string;
initial: string;
avatarColor: string;
photo: string;
likedAt?: string;
matchedAt?: string;
intent?: MatchIntent;
}
export interface SwipeResult {
ok: boolean;
matched: boolean;
match?: Record<string, unknown> | null;
}

View File

@@ -0,0 +1,131 @@
import { apiFetch } from "@/app/lib/api-client";
import { avatarForName } from "@/app/data/avatars";
import { FALLBACK_PROFILES, PROFILE_GRADIENTS } from "./constants";
import type { DatingProfile, MatchIntent, ProfileRecord, SwipeResult } from "./types";
export function mapProfile(record: ProfileRecord, index: number): DatingProfile {
const name = record.name || "数字游民";
const initial = name.slice(0, 1);
return {
id: record.id,
name,
age: Number(record.age || 26 + (index % 8)),
location: record.location || "远程",
citySlug: record.citySlug || "",
gender: record.gender || "",
single: record.single || "",
tags: Array.isArray(record.tags) ? record.tags : ["数字游民", "远程工作者"],
bio: record.bio || "正在完善个人资料,期待结识同城和同路的远程工作伙伴。",
lookingFor: Array.isArray(record.lookingFor) ? record.lookingFor : ["friends", "explore"],
gradient: PROFILE_GRADIENTS[index % PROFILE_GRADIENTS.length],
initial,
avatarColor: "bg-[#ff4d4f]",
photo: record.photo || avatarForName(name),
likedAt: record.likedAt,
matchedAt: record.matchedAt,
intent: record.intent,
};
}
export function filterFallbackProfiles(
intent: MatchIntent,
city: string,
advanced?: { gender: string; single: string }
): DatingProfile[] {
const cityQuery = city.trim().toLowerCase();
return FALLBACK_PROFILES.filter((profile) => {
if (!profile.lookingFor.includes(intent)) return false;
if (!cityQuery || cityQuery === "all") {
// continue
} else if (
profile.citySlug.toLowerCase() !== cityQuery &&
!profile.location.toLowerCase().includes(cityQuery)
) {
return false;
}
if (advanced?.gender && advanced.gender !== "all" && profile.gender !== advanced.gender) {
return false;
}
if (advanced?.single && advanced.single !== "all" && profile.single !== advanced.single) {
return false;
}
return true;
});
}
export function formatTimeAgo(value?: string, locale = "zh"): string {
if (!value) return locale === "zh" ? "刚刚活跃" : "Active now";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return locale === "zh" ? "刚刚活跃" : "Active now";
const diff = Date.now() - date.getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return locale === "zh" ? "刚刚" : "Just now";
if (minutes < 60) return locale === "zh" ? `${minutes} 分钟前` : `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return locale === "zh" ? `${hours} 小时前` : `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 7) return locale === "zh" ? `${days} 天前` : `${days}d ago`;
const weeks = Math.floor(days / 7);
if (weeks < 5) return locale === "zh" ? `${weeks} 周前` : `${weeks}w ago`;
const months = Math.floor(days / 30);
return locale === "zh" ? `${Math.max(months, 1)} 月前` : `${Math.max(months, 1)}mo ago`;
}
export interface MatchQuota {
vip: boolean;
limit: number;
used: number;
remaining: number;
}
export async function fetchCandidates(
intent: MatchIntent,
city: string,
advanced?: { gender: string; single: string }
): Promise<DatingProfile[]> {
const params = new URLSearchParams({ intent });
if (city && city !== "all") params.set("city", city);
if (advanced?.gender && advanced.gender !== "all") params.set("gender", advanced.gender);
if (advanced?.single && advanced.single !== "all") params.set("single", advanced.single);
params.set("exclude_swiped", "true");
const data = await apiFetch<{ items: ProfileRecord[] }>(`/api/matches/candidates?${params}`);
return (data.items || []).map((item, index) => mapProfile(item, index));
}
export async function fetchQuota(): Promise<MatchQuota> {
const data = await apiFetch<MatchQuota & { ok?: boolean }>("/api/matches/quota");
return {
vip: Boolean(data.vip),
limit: Number(data.limit || 25),
used: Number(data.used || 0),
remaining: Number(data.remaining || 0),
};
}
export async function undoSwipe(): Promise<{ profileId?: string }> {
return apiFetch<{ ok: boolean; profileId?: string }>("/api/matches/swipes/undo", {
method: "POST",
body: JSON.stringify({}),
});
}
export async function fetchLikes(): Promise<DatingProfile[]> {
const data = await apiFetch<{ items: ProfileRecord[] }>("/api/matches/likes");
return (data.items || []).map((item, index) => mapProfile(item, index));
}
export async function fetchMutualMatches(): Promise<DatingProfile[]> {
const data = await apiFetch<{ items: ProfileRecord[] }>("/api/matches/mutual");
return (data.items || []).map((item, index) => mapProfile(item, index));
}
export async function saveSwipe(
profileId: string,
action: "like" | "dislike",
intent: MatchIntent
): Promise<SwipeResult> {
return apiFetch<SwipeResult>("/api/matches/swipes", {
method: "POST",
body: JSON.stringify({ profileId, action, intent }),
});
}

View File

@@ -1,20 +1,40 @@
"use client";
import { useState } from "react";
import { Suspense, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useTranslation } from "@/i18n/navigation";
import Footer from "@/app/components/Footer";
import { apiFetch } from "@/app/lib/api-client";
export default function FeedbackPage() {
type FeedbackType = "suggestion" | "bug" | "report";
function FeedbackForm() {
const { t } = useTranslation("feedback");
const searchParams = useSearchParams();
const initialType = searchParams.get("type") === "report" ? "report" : "suggestion";
const reportTarget = searchParams.get("target") || "";
const [submitted, setSubmitted] = useState(false);
const [form, setForm] = useState({
type: "suggestion" as "suggestion" | "bug",
type: initialType as FeedbackType,
email: "",
title: "",
content: "",
targetId: reportTarget,
});
useEffect(() => {
if (reportTarget) {
setForm((prev) => ({
...prev,
type: "report",
targetId: reportTarget,
title: prev.title || t("reportTitle").replace("{target}", reportTarget),
content: prev.content || t("reportContent").replace("{target}", reportTarget),
}));
}
}, [reportTarget, t]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await apiFetch("/api/feedback", {
@@ -26,32 +46,34 @@ export default function FeedbackPage() {
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-[640px] px-4 py-8 sm:px-6 sm:py-12">
<header className="mb-8">
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<span className="w-1 h-8 bg-[#ff4d4f] rounded-full" />
{t("title")}
<h1 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]" />
{form.type === "report" ? t("reportPageTitle") : t("title")}
</h1>
<p className="text-gray-500 dark:text-gray-400 mt-1 text-sm sm:text-base">
{t("subtitle")}
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400 sm:text-base">
{form.type === "report" ? t("reportPageSubtitle") : t("subtitle")}
</p>
</header>
{submitted ? (
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-800 p-8 text-center">
<span className="text-5xl mb-4 block">🙏</span>
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-2">
{t("successTitle")}
</h2>
<p className="text-gray-500 dark:text-gray-400 mb-6">
{t("successDesc")}
</p>
<div className="rounded-2xl border border-gray-100 bg-white p-8 text-center shadow-sm dark:border-gray-800 dark:bg-gray-900">
<span className="mb-4 block text-5xl">🙏</span>
<h2 className="mb-2 text-xl font-bold text-gray-900 dark:text-gray-100">{t("successTitle")}</h2>
<p className="mb-6 text-gray-500 dark:text-gray-400">{t("successDesc")}</p>
<button
onClick={() => {
setSubmitted(false);
setForm({ type: "suggestion", email: "", title: "", content: "" });
setForm({
type: "suggestion",
email: "",
title: "",
content: "",
targetId: "",
});
}}
className="px-6 py-2.5 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="rounded-xl border border-gray-200 px-6 py-2.5 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("submitAnother")}
</button>
@@ -59,93 +81,76 @@ export default function FeedbackPage() {
) : (
<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"
>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t("type")} *
</label>
<div className="flex gap-3">
<label className="flex-1 flex items-center justify-center gap-2 p-3 rounded-xl border cursor-pointer transition-colors has-[:checked]:border-[#ff4d4f] has-[:checked]:bg-[#ff4d4f]/5">
<input
type="radio"
name="type"
value="suggestion"
checked={form.type === "suggestion"}
onChange={(e) =>
setForm((f) => ({ ...f, type: e.target.value as "suggestion" }))
}
className="sr-only"
/>
<span>💡</span>
<span>{t("suggestion")}</span>
</label>
<label className="flex-1 flex items-center justify-center gap-2 p-3 rounded-xl border cursor-pointer transition-colors has-[:checked]:border-[#ff4d4f] has-[:checked]:bg-[#ff4d4f]/5">
<input
type="radio"
name="type"
value="bug"
checked={form.type === "bug"}
onChange={(e) =>
setForm((f) => ({ ...f, type: e.target.value as "bug" }))
}
className="sr-only"
/>
<span>🐛</span>
<span>{t("bug")}</span>
</label>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{t("type")} *</label>
<div className="flex flex-wrap gap-3">
{(["suggestion", "bug", "report"] as FeedbackType[]).map((type) => (
<label
key={type}
className="flex flex-1 min-w-[120px] cursor-pointer items-center justify-center gap-2 rounded-xl border p-3 transition-colors has-[:checked]:border-[#ff4d4f] has-[:checked]:bg-[#ff4d4f]/5"
>
<input
type="radio"
name="type"
value={type}
checked={form.type === type}
onChange={() => setForm((f) => ({ ...f, type }))}
className="sr-only"
/>
<span>{type === "suggestion" ? "💡" : type === "bug" ? "🐛" : "🚩"}</span>
<span>{t(type)}</span>
</label>
))}
</div>
</div>
{form.type === "report" && form.targetId ? (
<div className="rounded-xl bg-rose-50 px-4 py-3 text-sm text-rose-800 dark:bg-rose-950/30 dark:text-rose-200">
{t("reportTargetLabel")}: <code className="font-mono">{form.targetId}</code>
</div>
) : null}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t("email")} *
</label>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{t("email")} *</label>
<input
type="email"
required
value={form.email}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
placeholder={t("emailPlaceholder")}
className="w-full px-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 focus:border-[#ff4d4f]"
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-3 text-gray-900 placeholder-gray-400 focus:border-[#ff4d4f] focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 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("titleLabel")} *
</label>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{t("titleLabel")} *</label>
<input
type="text"
required
value={form.title}
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
placeholder={t("titlePlaceholder")}
className="w-full px-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 focus:border-[#ff4d4f]"
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-3 text-gray-900 placeholder-gray-400 focus:border-[#ff4d4f] focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 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("content")} *
</label>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{t("content")} *</label>
<textarea
required
rows={5}
value={form.content}
onChange={(e) => setForm((f) => ({ ...f, content: e.target.value }))}
placeholder={t("contentPlaceholder")}
className="w-full px-4 py-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 focus:border-[#ff4d4f] resize-none"
className="w-full resize-none rounded-xl border border-gray-200 bg-white px-4 py-3 text-gray-900 placeholder-gray-400 focus:border-[#ff4d4f] focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<p className="text-xs text-gray-400 dark:text-gray-500">
FastAPI + PocketBase feedback
</p>
<button
type="submit"
className="w-full py-3.5 rounded-xl bg-[#ff4d4f] text-white font-semibold hover:bg-[#ff7a45] transition-colors"
className="w-full rounded-xl bg-[#ff4d4f] py-3.5 font-semibold text-white transition-colors hover:bg-[#ff7a45]"
>
{t("submit")}
</button>
@@ -156,3 +161,11 @@ export default function FeedbackPage() {
</div>
);
}
export default function FeedbackPage() {
return (
<Suspense fallback={<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950" />}>
<FeedbackForm />
</Suspense>
);
}

View File

@@ -10,9 +10,18 @@ import {
import { usePayStatusPoll } from "@/app/lib/payment/usePayStatusPoll";
import AuthModal from "@/app/components/AuthModal";
import { apiFetch, fetchAuthMe } from "@/app/lib/api-client";
import { cities } from "@/app/data/cities";
const genderOptions = ["男", "女"];
const singleOptions = ["单身", "恋爱中", "已婚"];
const lookingForOptions = [
{ key: "friends", label: "🤝 交友" },
{ key: "dating", label: "🌹 约会" },
{ key: "partner", label: "👥 搭档" },
{ key: "roommate", label: "🏠 室友" },
{ key: "cofounder", label: "🚀 合伙人" },
{ key: "explore", label: "🍜 探店" },
];
const educationOptions = ["高中及以下", "大专", "本科", "硕士", "博士"];
const graduationYears = Array.from({ length: 41 }, (_, i) => String(2026 - i));
@@ -26,6 +35,8 @@ interface FormData {
education: string;
graduationYear: string;
phone: string;
location: string;
citySlug: string;
}
interface MediaInfo {
@@ -45,7 +56,10 @@ export default function JoinPage() {
education: "",
graduationYear: "",
phone: "",
location: "",
citySlug: "",
});
const [lookingFor, setLookingFor] = useState<string[]>(["friends", "explore"]);
const [media, setMedia] = useState<MediaInfo | null>(null);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
@@ -100,7 +114,11 @@ export default function JoinPage() {
try {
const data = await apiFetch<{ ok?: boolean; user_id?: string; error?: string }>("/api/join", {
method: "POST",
body: JSON.stringify({ ...form, media: media?.url }),
body: JSON.stringify({
...form,
media: media?.url,
lookingFor,
}),
});
if (!data?.ok) {
throw new Error(data?.error || "提交失败,请稍后重试");
@@ -303,6 +321,60 @@ export default function JoinPage() {
</Field>
</div>
<Field label="📍 当前城市">
<select
value={form.citySlug}
onChange={(e) => {
const city = cities.find((item) => (item.slug || item.name) === e.target.value);
setForm((prev) => ({
...prev,
citySlug: e.target.value,
location: city?.name || "",
}));
}}
required
className={`form-input appearance-none ${form.citySlug ? "text-slate-800 dark:text-slate-100" : "text-slate-400 dark:text-slate-500"}`}
>
<option value="" disabled></option>
{cities.map((city) => (
<option key={city.name} value={city.slug || city.name}>
{city.name}
</option>
))}
</select>
<ChevronDown />
</Field>
<div className="py-4 sm:py-5">
<label className="mb-3 block text-base font-bold text-slate-700 dark:text-slate-300 sm:text-[17px]">
🎯
<span className="ml-2 text-xs font-normal text-slate-400 dark:text-slate-500"></span>
</label>
<div className="flex flex-wrap gap-2">
{lookingForOptions.map((option) => {
const active = lookingFor.includes(option.key);
return (
<button
key={option.key}
type="button"
onClick={() =>
setLookingFor((prev) =>
active ? prev.filter((item) => item !== option.key) : [...prev, option.key]
)
}
className={`rounded-full px-3 py-2 text-sm font-medium transition ${
active
? "bg-[#ff4d4f] text-white"
: "border border-slate-200 bg-white text-slate-600 dark:border-gray-700 dark:bg-gray-900 dark:text-slate-300"
}`}
>
{option.label}
</button>
);
})}
</div>
</div>
<div className="border-b border-slate-100 dark:border-gray-700 py-4 sm:border-b-0 sm:py-5">
<label className="mb-2 block text-base font-bold text-slate-700 dark:text-slate-300 sm:text-[17px]">
📝

View File

@@ -0,0 +1,91 @@
"use client";
import { useEffect, useState } from "react";
import { useLocale } from "@/i18n/navigation";
import { useParams } from "next/navigation";
import EventLiveRoom from "@/app/components/meetups/EventLiveRoom";
import { apiFetch } from "@/app/lib/api-client";
import type { MeetupSession } from "@/app/lib/meetups";
interface MeetupRecord {
id: string;
city: string;
emoji: string;
venue: string;
date: string;
time: string;
organizer: string;
gradientFrom: string;
gradientTo: string;
mode: string;
}
const weekdays = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
function formatMeetupDate(dateStr: string, time: string, locale: 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)}`;
if (locale !== "zh") {
return d.toLocaleString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" });
}
const weekday = weekdays[d.getDay()];
return `${weekday}, ${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}日, ${time.slice(0, 5)}`;
}
export default function MeetupLivePage() {
const locale = useLocale();
const params = useParams<{ id: string }>();
const meetupId = String(params?.id || "");
const [meetup, setMeetup] = useState<MeetupRecord | null>(null);
const [session, setSession] = useState<MeetupSession | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
if (!meetupId) return;
let cancelled = false;
setLoading(true);
apiFetch<{ meetup: MeetupRecord; session: MeetupSession }>(`/api/meetups/${meetupId}/session`)
.then((data) => {
if (cancelled) return;
setMeetup(data.meetup);
setSession(data.session);
setError("");
})
.catch((err: Error) => {
if (!cancelled) setError(err.message || "加载失败");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [meetupId]);
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#0b1220] text-white/70">
{locale === "zh" ? "正在连接活动间…" : "Connecting to live room…"}
</div>
);
}
if (error || !meetup || !session) {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-[#0b1220] px-4 text-center text-white">
<p className="text-lg font-semibold">{error || (locale === "zh" ? "活动不存在" : "Event not found")}</p>
</div>
);
}
return (
<EventLiveRoom
locale={locale}
meetup={meetup}
session={session}
formattedDate={formatMeetupDate(meetup.date, meetup.time, locale)}
/>
);
}

View File

@@ -5,12 +5,16 @@ import { Link, useLocale, useTranslation } from "@/i18n/navigation";
import { apiFetch } from "@/app/lib/api-client";
import {
buildMeetupChatUrl,
buildMeetupLivePath,
buildMiroTalkJoinUrl,
canAccessMeetup,
DEFAULT_ONLINE_FEATURES,
featureLabels,
getLoungeServerLabel,
getMiroTalkServerLabel,
isLiveMeetup,
MIROTALK_PROVIDER_LABEL,
viewerDisplayName,
type MeetupAccessLevel,
type MeetupMode,
WEB_CHAT_PROVIDER_LABEL,
@@ -41,6 +45,7 @@ interface Meetup {
accessLevel: MeetupAccessLevel;
meetingProvider: string;
mirotalkRoom: string;
loungeChannel: string;
meetingUrl: string;
replayUrl: string;
features: string[];
@@ -66,6 +71,31 @@ interface ViewerState {
type ModeFilter = "all" | MeetupMode;
const allMeetups: Meetup[] = [
{
id: "live-june-8",
city: "线上",
emoji: "🎙️",
date: "2026-06-08",
time: "19:00",
venue: "NomadCNA 线上圆桌",
address: "视频会议 + Web 群聊 · 社区账户直通",
description: "今晚 19:00 线上圆桌:远程工作节奏、旅居城市选择与社区协作。登录后昵称自动带入视频房,群聊进入活动频道。",
rsvpCount: 42,
gradientFrom: "#ff4d4f",
gradientTo: "#7c3aed",
attendees: [{ name: "林晓", photo: avatarForIndex(0) }, { name: "张明", photo: avatarForIndex(4) }, { name: "陈静", photo: avatarForIndex(2) }],
isUpcoming: true,
mode: "online",
accessLevel: "public",
meetingProvider: "mirotalk",
mirotalkRoom: "nomadcna-20260608-online-roundtable",
loungeChannel: "#nomadcna-20260608",
meetingUrl: "",
replayUrl: "",
features: DEFAULT_ONLINE_FEATURES,
maxAttendees: 120,
organizer: "NomadCNA",
},
{
id: "online-1",
city: "线上",
@@ -84,6 +114,7 @@ const allMeetups: Meetup[] = [
accessLevel: "members",
meetingProvider: "mirotalk",
mirotalkRoom: "nomadcna-remote-work-open-office",
loungeChannel: "#online-events",
meetingUrl: "",
replayUrl: "",
features: DEFAULT_ONLINE_FEATURES,
@@ -326,6 +357,7 @@ const copy = {
max: "上限",
details: "查看详情",
joinRoom: "进入视频房",
enterLiveRoom: "进入活动间",
joinChat: "进入群聊",
embedRoom: "在当前页打开",
closeEmbed: "收起房间",
@@ -355,7 +387,8 @@ const copy = {
emptyUpcoming: "暂无即将举办的活动",
emptyPrevious: "暂无往期活动记录",
authPublicHint: "公开活动可直接打开,登录后会使用你的社区身份进入房间。",
chatHint: "群聊默认进入 #nomadcna 与 #online-events。",
chatHint: "活动间内嵌 MiroTalk 视频 + The Lounge 群聊,登录后昵称自动带入视频房。",
chatChannel: "活动频道",
},
en: {
center: "Events Center",
@@ -372,6 +405,7 @@ const copy = {
max: "Limit",
details: "Details",
joinRoom: "Join video room",
enterLiveRoom: "Enter live room",
joinChat: "Join group chat",
embedRoom: "Open inline",
closeEmbed: "Hide room",
@@ -401,7 +435,8 @@ const copy = {
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.",
chatHint: "The live room embeds MiroTalk video and The Lounge chat. Your community display name is passed into the video room.",
chatChannel: "Event channel",
},
};
@@ -451,6 +486,7 @@ function mapMeetup(item: Partial<Meetup> & { id: string; status?: string }): Mee
accessLevel: toAccessLevel(item.accessLevel),
meetingProvider: item.meetingProvider || (mode === "offline" ? "" : "mirotalk"),
mirotalkRoom: item.mirotalkRoom || "",
loungeChannel: item.loungeChannel || "",
meetingUrl: item.meetingUrl || "",
replayUrl: item.replayUrl || "",
features: Array.isArray(item.features) ? item.features.map(String) : mode === "offline" ? [] : DEFAULT_ONLINE_FEATURES,
@@ -474,25 +510,6 @@ function accessLabel(accessLevel: MeetupAccessLevel, locale: string) {
}[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;
@@ -585,10 +602,11 @@ function MeetupDetailModal({ meetup, locale, viewer, onClose }: { meetup: Meetup
const [embedOpen, setEmbedOpen] = useState(false);
const canJoin = canAccessMeetup(meetup, viewer);
const joinUrl = buildMiroTalkJoinUrl(meetup, viewerDisplayName(viewer));
const chatUrl = buildMeetupChatUrl();
const chatUrl = buildMeetupChatUrl(meetup);
const livePath = buildMeetupLivePath(meetup.id);
const mirotalkServer = getMiroTalkServerLabel();
const loungeServer = getLoungeServerLabel();
const isVideoEvent = meetup.mode !== "offline";
const isVideoEvent = isLiveMeetup(meetup);
return (
<div className="fixed inset-0 z-[9999] overflow-y-auto">
@@ -645,14 +663,21 @@ function MeetupDetailModal({ meetup, locale, viewer, onClose }: { meetup: Meetup
<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>
<p className="mt-1 break-all text-xs text-gray-500 dark:text-gray-400">{c.chatChannel}: {meetup.loungeChannel || `#${meetup.mirotalkRoom || meetup.id}`}</p>
</div>
{canJoin ? (
<div className="flex flex-wrap gap-2">
<Link
href={livePath}
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.enterLiveRoom}
</Link>
<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"
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.joinRoom}
</a>

View File

@@ -56,7 +56,7 @@ const FALLBACK_HOME_CONTENT: ContentItem[] = [
subtitle: "个人访谈 / 现实纪录片",
subtitleEn: "Interview documentary",
coverImage: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=1200",
targetUrl: "/videos/lin-dali-documentary",
targetUrl: "/videos",
ctaLabel: "观看访谈",
ctaLabelEn: "Watch",
},
@@ -86,7 +86,55 @@ const FALLBACK_HOME_CONTENT: ContentItem[] = [
},
];
const HIDDEN_HOME_RIGHT_CARD_TYPES = new Set<ContentItem["type"]>(["app", "guide"]);
const COVER_FALLBACK: Record<"ebook" | "video", string> = {
ebook: "https://images.unsplash.com/photo-1519389950473-47ba0277781c?w=1200",
video: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=1200",
};
function normalizeHomeContentItem(item: Partial<ContentItem> & { slug: string }): ContentItem | null {
const type = String(item.type || "").toLowerCase();
if (type !== "ebook" && type !== "video" && type !== "app" && type !== "guide") return null;
const fallback = FALLBACK_HOME_CONTENT.find((entry) => entry.slug === item.slug);
const contentType = type as ContentItem["type"];
const defaultTarget =
contentType === "ebook"
? `/ebooks/${item.slug}`
: contentType === "video"
? "/videos"
: contentType === "app"
? "/app"
: "/digital";
return {
slug: item.slug,
type: contentType,
title: item.title || fallback?.title || item.slug,
titleEn: item.titleEn || fallback?.titleEn,
subtitle: item.subtitle || fallback?.subtitle || "",
subtitleEn: item.subtitleEn || fallback?.subtitleEn,
description: item.description || fallback?.description,
coverImage:
item.coverImage ||
fallback?.coverImage ||
(contentType === "ebook" || contentType === "video" ? COVER_FALLBACK[contentType] : "") ||
"",
targetUrl:
contentType === "video"
? "/videos"
: item.targetUrl || fallback?.targetUrl || defaultTarget,
ctaLabel: item.ctaLabel || fallback?.ctaLabel || "查看",
ctaLabelEn: item.ctaLabelEn || fallback?.ctaLabelEn,
durationMinutes: item.durationMinutes ?? fallback?.durationMinutes,
};
}
function pickHomepageFeatureContent(items: ContentItem[]): ContentItem[] {
const normalized = items
.map((item) => normalizeHomeContentItem(item))
.filter((item): item is ContentItem => Boolean(item));
const ebook = normalized.find((item) => item.type === "ebook");
const video = normalized.find((item) => item.type === "video");
return [ebook, video].filter((item): item is ContentItem => Boolean(item));
}
interface ProfileItem {
id: string;
@@ -146,7 +194,12 @@ export default function Home() {
})
.catch(() => setLiveCities(cities));
apiFetch<{ ok: boolean; items: ContentItem[] }>("/api/content/home")
.then((data) => setHomeContent(data.items?.length ? data.items : FALLBACK_HOME_CONTENT))
.then((data) => {
const items = (data.items || [])
.map((item) => normalizeHomeContentItem(item))
.filter((item): item is ContentItem => Boolean(item));
setHomeContent(items.length ? items : FALLBACK_HOME_CONTENT);
})
.catch(() => setHomeContent(FALLBACK_HOME_CONTENT));
apiFetch<{ stats: { cities: number; nomads: number; meetups: number; minCost: number; profiles: number; discussions: number } }>("/api/stats/overview")
.then((data) => {
@@ -217,7 +270,7 @@ export default function Home() {
}, [activeFilter, sortBy, liveCities]);
const homepageFeatureContent = useMemo(
() => homeContent.filter((item) => !HIDDEN_HOME_RIGHT_CARD_TYPES.has(item.type)).slice(0, 4),
() => pickHomepageFeatureContent(homeContent),
[homeContent]
);
@@ -264,58 +317,70 @@ export default function Home() {
const title = locale === "zh" ? item.title : item.titleEn || item.title;
const subtitle = locale === "zh" ? item.subtitle : item.subtitleEn || item.subtitle;
const cta = locale === "zh" ? item.ctaLabel : item.ctaLabelEn || item.ctaLabel;
const typeMeta: Record<string, { header: string; icon: string; href: string }> = {
ebook: { header: "电子书", icon: "📘", href: `/ebooks/${item.slug}` },
video: { header: "访谈视频", icon: "▶️", href: `/videos/${item.slug}` },
app: { header: "App", icon: "📱", href: "/app" },
guide: { header: "数字游民指南", icon: "🧭", href: "/digital" },
const typeMeta: Record<ContentItem["type"], { headerKey: "rightCardEbook" | "rightCardVideo" | null; icon: string }> = {
ebook: { headerKey: "rightCardEbook", icon: "📘" },
video: { headerKey: "rightCardVideo", icon: "▶️" },
app: { headerKey: null, icon: "📱" },
guide: { headerKey: null, icon: "🧭" },
};
const meta = typeMeta[item.type] || typeMeta.guide;
const isExternal = meta.href.startsWith("http");
const meta = typeMeta[item.type];
const href = item.targetUrl || "/";
const coverSrc = mediaUrl(
item.coverImage || COVER_FALLBACK[item.type as "ebook" | "video"] || ""
);
const isExternal = href.startsWith("http");
const rowStyle = { "--right-row": index + 1 } as CSSProperties;
const content = (
<>
<div className="right-item-header">{meta.header}</div>
<div className="relative h-[calc(100%-38px)] sm:h-[calc(100%-42px)] overflow-hidden">
<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>
<div className="right-item-header">
{meta.headerKey ? t(meta.headerKey) : title}
</div>
<div className="right-item-body">
{coverSrc ? (
<img
src={coverSrc}
alt={title}
className="absolute inset-0 h-full w-full object-cover"
loading="lazy"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-gray-200 to-gray-300 text-4xl dark:from-gray-700 dark:to-gray-800">
{meta.icon}
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-black/10" />
<div className="absolute inset-x-0 bottom-0 z-10 p-3 sm:p-4 text-white">
<div className="mb-1 text-lg sm:text-xl">{meta.icon}</div>
<h3 className="line-clamp-2 text-xs sm:text-sm font-bold leading-snug">{title}</h3>
<p className="mt-1 line-clamp-2 text-[10px] sm:text-xs text-white/80">{subtitle}</p>
<span className="mt-2 inline-flex rounded-full bg-white/90 px-2.5 py-1 text-[10px] sm:text-xs font-semibold text-gray-900">
<p className="mt-1 line-clamp-2 text-[10px] sm:text-xs text-white/85">{subtitle}</p>
<span className="mt-2 inline-flex max-w-full truncate rounded-full bg-white/92 px-2.5 py-1 text-[10px] sm:text-xs font-semibold text-gray-900">
{cta}
</span>
</div>
</div>
</>
);
const rowStyle = { "--right-row": index + 1 } as CSSProperties;
return isExternal ? (
<a
key={item.slug}
href={meta.href}
href={href}
target="_blank"
rel="noopener noreferrer"
className="right-item block"
className="right-item"
style={rowStyle}
>
{content}
</a>
) : (
<Link
key={item.slug}
href={meta.href}
className="right-item block"
style={rowStyle}
>
<Link key={item.slug} href={href} className="right-item" style={rowStyle}>
{content}
</Link>
);
})}
<Link href="/join" className="right-item block" style={{ "--right-row": homepageFeatureContent.length + 1 } as React.CSSProperties}>
<Link href="/join" className="right-item" style={{ "--right-row": homepageFeatureContent.length + 1 } as React.CSSProperties}>
<div className="right-item-header">{t("joinCommunity")}</div>
<div className="flex flex-col items-center justify-center text-center p-2 sm:p-5 h-[calc(100%-38px)] sm:h-[calc(100%-42px)]">
<div className="right-item-body flex flex-col items-center justify-center text-center p-2 sm:p-5">
<span className="text-2xl sm:text-5xl mb-1 sm:mb-3">💬</span>
<p className="text-[10px] sm:text-sm text-gray-600 dark:text-gray-400 mb-0.5">
{t("joinCommunityDesc")}
@@ -329,9 +394,9 @@ export default function Home() {
</div>
</Link>
<Link href="/dating" className="right-item block" style={{ "--right-row": homepageFeatureContent.length + 2 } as React.CSSProperties}>
<Link href="/dating" className="right-item" style={{ "--right-row": homepageFeatureContent.length + 2 } as React.CSSProperties}>
<div className="right-item-header">{t("newMembers")}</div>
<div className="p-2 sm:p-5 flex flex-wrap gap-1 sm:gap-2 content-start">
<div className="right-item-body p-2 sm:p-5 flex flex-wrap gap-1 sm:gap-2 content-start">
{memberPhotos.map((m, i) => (
<img
key={`${m.name}-${i}`}
@@ -347,9 +412,9 @@ export default function Home() {
</div>
</Link>
<Link href="/meetups" className="right-item block" style={{ "--right-row": homepageFeatureContent.length + 3 } as React.CSSProperties}>
<Link href="/meetups" className="right-item" style={{ "--right-row": homepageFeatureContent.length + 3 } as React.CSSProperties}>
<div className="right-item-header">{t("latestEvents")}</div>
<div className="p-1.5 sm:p-4 flex flex-col gap-0.5 sm:gap-2.5 text-[10px] sm:text-sm text-gray-700 dark:text-gray-300 h-[calc(100%-38px)] sm:h-[calc(100%-42px)]">
<div className="right-item-body p-1.5 sm:p-4 flex flex-col gap-0.5 sm:gap-2.5 text-[10px] sm:text-sm text-gray-700 dark:text-gray-300">
{homeMeetups.slice(0, 3).map((meetup) => (
<div
key={`${meetup.city}-${meetup.date}`}

View File

@@ -0,0 +1,223 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Link, useLocale, useTranslation } from "@/i18n/navigation";
import Footer from "@/app/components/Footer";
import ContentSubmissionModal from "@/app/components/ContentSubmissionModal";
import { apiFetch } from "@/app/lib/api-client";
import { mediaUrl } from "@/app/lib/media";
interface VideoItem {
slug: string;
title: string;
titleEn?: string;
subtitle: string;
subtitleEn?: string;
description?: string;
descriptionEn?: string;
coverImage: string;
durationMinutes?: number;
citySlugs?: string[];
relatedProfiles?: string[];
}
const FALLBACK_VIDEOS: VideoItem[] = [
{
slug: "lin-dali-documentary",
title: "林晓雨:在大理远程工作的现实一天",
titleEn: "A remote work day in Dali",
subtitle: "个人访谈 / 现实纪录片 · 大理",
subtitleEn: "Interview documentary · Dali",
coverImage: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=1200",
durationMinutes: 12,
citySlugs: ["dali"],
relatedProfiles: ["林晓雨"],
},
{
slug: "chen-chengdu-dev",
title: "陈浩然:在成都做独立开发的真实节奏",
titleEn: "Indie dev life in Chengdu",
subtitle: "个人访谈 · 成都",
subtitleEn: "Interview · Chengdu",
coverImage: "https://images.unsplash.com/photo-1547981609-4b6bfe67ca0b?w=1200",
durationMinutes: 14,
citySlugs: ["chengdu"],
relatedProfiles: ["陈浩然"],
},
{
slug: "wang-shenzhen-creator",
title: "王思琪:深圳远程撰稿人的一天",
titleEn: "Remote writer day in Shenzhen",
subtitle: "个人访谈 · 深圳",
subtitleEn: "Interview · Shenzhen",
coverImage: "https://images.unsplash.com/photo-1518837695005-2083093ee35b?w=1200",
durationMinutes: 11,
citySlugs: ["shenzhen"],
relatedProfiles: ["王思琪"],
},
{
slug: "zhang-hangzhou-startup",
title: "张明远:在杭州远程创业的一周",
titleEn: "Remote startup week in Hangzhou",
subtitle: "个人访谈 · 杭州",
subtitleEn: "Interview · Hangzhou",
coverImage: "https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=1200",
durationMinutes: 16,
citySlugs: ["hangzhou"],
relatedProfiles: ["张明远"],
},
{
slug: "li-xiamen-design",
title: "李雅婷:在厦门做远程 UI 设计",
titleEn: "Remote UI design from Xiamen",
subtitle: "个人访谈 · 厦门",
subtitleEn: "Interview · Xiamen",
coverImage: "https://images.unsplash.com/photo-1590559899731-a382839e5549?w=1200",
durationMinutes: 10,
citySlugs: ["xiamen"],
relatedProfiles: ["李雅婷"],
},
];
export default function VideosPage() {
const locale = useLocale();
const { t } = useTranslation("videos");
const [videos, setVideos] = useState<VideoItem[]>(FALLBACK_VIDEOS);
const [loading, setLoading] = useState(true);
const [submissionOpen, setSubmissionOpen] = useState(false);
useEffect(() => {
apiFetch<{ items: VideoItem[] }>("/api/videos")
.then((data) => {
if (data.items?.length) setVideos(data.items);
})
.catch(() => setVideos(FALLBACK_VIDEOS))
.finally(() => setLoading(false));
}, []);
const featured = videos[0];
const rest = useMemo(() => videos.slice(1), [videos]);
const label = (item: VideoItem) => (locale === "zh" ? item.title : item.titleEn || item.title);
const sub = (item: VideoItem) => (locale === "zh" ? item.subtitle : item.subtitleEn || item.subtitle);
return (
<div className="min-h-screen bg-[#0f172a] text-white">
<main className="mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-12">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<Link href="/" className="text-sm text-white/60 hover:text-white">
{t("backHome")}
</Link>
<h1 className="mt-4 flex items-center gap-2 text-3xl font-bold sm:text-4xl">
<span className="h-10 w-1 rounded-full bg-[#ff4d4f]" />
{t("title")}
</h1>
<p className="mt-2 max-w-2xl text-white/60">{t("subtitle")}</p>
</div>
<button
type="button"
onClick={() => setSubmissionOpen(true)}
className="rounded-full border border-[#ff7a45] px-4 py-2 text-sm font-semibold text-[#ffb088] hover:bg-[#ff7a45]/10"
>
{t("submit")}
</button>
</div>
{loading ? (
<div className="mt-10 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="aspect-[4/3] animate-pulse rounded-2xl bg-white/10" />
))}
</div>
) : (
<>
{featured ? (
<Link
href={`/videos/${featured.slug}`}
className="group mt-10 block overflow-hidden rounded-2xl bg-white/5 ring-1 ring-white/10 transition hover:bg-white/10"
>
<div className="grid md:grid-cols-2">
<div className="relative aspect-video md:aspect-auto md:min-h-[280px]">
<img
src={mediaUrl(featured.coverImage)}
alt={label(featured)}
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.02]"
/>
<span className="absolute left-4 top-4 rounded-full bg-[#ff4d4f] px-3 py-1 text-xs font-semibold">
{t("featured")}
</span>
</div>
<div className="flex flex-col justify-center p-6 sm:p-8">
<p className="text-sm font-semibold text-[#ff7a45]">{t("badge")}</p>
<h2 className="mt-2 text-2xl font-bold leading-snug">{label(featured)}</h2>
<p className="mt-2 text-white/60">{sub(featured)}</p>
<div className="mt-4 flex flex-wrap gap-2 text-xs text-white/50">
{featured.durationMinutes ? (
<span className="rounded-full bg-white/10 px-2.5 py-1">
{featured.durationMinutes} {t("minutes")}
</span>
) : null}
{(featured.relatedProfiles || []).map((name) => (
<span key={name} className="rounded-full bg-white/10 px-2.5 py-1">
@{name}
</span>
))}
</div>
<span className="mt-6 inline-flex w-fit rounded-full bg-white px-4 py-2 text-sm font-semibold text-gray-900">
{t("watch")}
</span>
</div>
</div>
</Link>
) : null}
<div className="mt-10 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{rest.map((item) => (
<Link
key={item.slug}
href={`/videos/${item.slug}`}
className="group overflow-hidden rounded-2xl bg-white/5 ring-1 ring-white/10 transition hover:-translate-y-0.5 hover:bg-white/10 hover:shadow-xl"
>
<div className="relative aspect-video">
<img
src={mediaUrl(item.coverImage)}
alt={label(item)}
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent" />
{item.durationMinutes ? (
<span className="absolute bottom-3 right-3 rounded-md bg-black/70 px-2 py-1 text-xs">
{item.durationMinutes} {t("minutes")}
</span>
) : null}
<span className="absolute left-3 top-3 rounded-full bg-black/50 px-2 py-1 text-[10px] backdrop-blur">
{t("badge")}
</span>
</div>
<div className="p-4">
<h3 className="line-clamp-2 font-bold leading-snug">{label(item)}</h3>
<p className="mt-1 line-clamp-2 text-sm text-white/55">{sub(item)}</p>
{(item.relatedProfiles || []).length > 0 ? (
<p className="mt-3 text-xs text-[#ffb088]">
{(item.relatedProfiles || []).join(" · ")}
</p>
) : null}
</div>
</Link>
))}
</div>
</>
)}
</main>
<ContentSubmissionModal
open={submissionOpen}
locale={locale}
submissionType="video"
onClose={() => setSubmissionOpen(false)}
/>
<Footer />
</div>
);
}

View File

@@ -58,29 +58,22 @@ async function objectExists(client: ReturnType<typeof getMinioClient>, bucket: s
}
}
function passthroughResponse(buffer: Buffer, contentType: string): NextResponse {
return new NextResponse(buffer, {
status: 200,
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=86400",
},
});
}
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" });
@@ -97,22 +90,36 @@ export async function GET(request: NextRequest) {
}
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",
});
const cfg = getMinioConfig();
if (!cfg.enabled) {
return passthroughResponse(buffer, contentType);
}
return NextResponse.redirect(publicObjectUrl(cfg.publicUrl, objectKey), 307);
try {
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 ext = extensionFromSource(source, contentType);
const objectKey = `${prefix}/${hash}.${ext}`;
if (await objectExists(client, cfg.bucket, objectKey)) {
return NextResponse.redirect(publicObjectUrl(cfg.publicUrl, objectKey), 307);
}
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);
} catch {
return passthroughResponse(buffer, contentType);
}
}

View File

@@ -601,6 +601,12 @@ const SOCIAL_MODE_META: Record<SocialMode, { title: string; subtitle: string; ba
},
};
const DATING_INTENT_BY_MODE: Record<SocialMode, string> = {
coffeechat: "friends",
localGuide: "partner",
cityWalk: "explore",
};
function SocialMembersPanel({
city,
mode,
@@ -611,6 +617,7 @@ function SocialMembersPanel({
members: CitySocialMember[];
}) {
const meta = SOCIAL_MODE_META[mode];
const datingHref = `/dating?intent=${DATING_INTENT_BY_MODE[mode]}&city=${encodeURIComponent(city.slug || city.name)}`;
return (
<div className="space-y-5">
<section className={`rounded-2xl border border-gray-200 p-4 dark:border-gray-700 ${meta.tone}`}>
@@ -621,7 +628,7 @@ function SocialMembersPanel({
<p className="mt-2 max-w-3xl text-sm leading-6 opacity-90">{meta.subtitle}</p>
</div>
<Link
href="/dating"
href={datingHref}
className="rounded-full bg-gray-900 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-gray-700 dark:bg-white dark:text-gray-900 dark:hover:bg-gray-200"
>
{meta.cta}

View File

@@ -63,6 +63,9 @@ export default function NavbarComponent() {
const isActive = useCallback((href: string) => {
if (href.startsWith("/#")) return false;
if (href === "/") return pathname === "/";
if (href === "/digital") {
return /^\/(zh|en)\/digital(?:\/|$)/.test(pathname);
}
return pathname.startsWith(href);
}, [pathname]);

View File

@@ -0,0 +1,203 @@
"use client";
import { useMemo, useState } from "react";
import { Link } from "@/i18n/navigation";
import {
MIROTALK_PROVIDER_LABEL,
WEB_CHAT_PROVIDER_LABEL,
type MeetupSession,
} from "@/app/lib/meetups";
interface EventLiveRoomProps {
locale: string;
meetup: {
id: string;
city: string;
emoji: string;
venue: string;
date: string;
time: string;
organizer: string;
gradientFrom: string;
gradientTo: string;
};
session: MeetupSession;
formattedDate: string;
}
const copy = {
zh: {
back: "返回活动列表",
video: "视频会议",
chat: "Web 群聊",
split: "分屏",
videoOnly: "只看视频",
chatOnly: "只看群聊",
identity: "社区身份",
guest: "访客",
vip: "VIP",
member: "成员",
channel: "活动频道",
openChat: "新窗口打开群聊",
openVideo: "新窗口打开视频",
locked: "当前权限不足,无法进入活动间",
login: "登录社区账户",
joinVip: "开通 VIP",
providers: "服务由 VPS 自建开源项目提供",
},
en: {
back: "Back to events",
video: "Video",
chat: "Web chat",
split: "Split",
videoOnly: "Video only",
chatOnly: "Chat only",
identity: "Community identity",
guest: "Guest",
vip: "VIP",
member: "Member",
channel: "Event channel",
openChat: "Open chat in new tab",
openVideo: "Open video in new tab",
locked: "You do not have access to this live room yet.",
login: "Sign in",
joinVip: "Get VIP",
providers: "Powered by self-hosted open-source services on VPS",
},
};
type PanelMode = "split" | "video" | "chat";
export default function EventLiveRoom({ locale, meetup, session, formattedDate }: EventLiveRoomProps) {
const c = locale === "zh" ? copy.zh : copy.en;
const [panel, setPanel] = useState<PanelMode>("split");
const badge = useMemo(() => {
if (session.user?.vip) return c.vip;
if (session.user) return c.member;
return c.guest;
}, [c.guest, c.member, c.vip, session.user]);
if (!session.canJoin) {
return (
<div className="mx-auto flex min-h-[60vh] max-w-2xl flex-col items-center justify-center px-4 text-center">
<p className="text-lg font-semibold text-gray-900 dark:text-gray-100">{c.locked}</p>
<div className="mt-6 flex flex-wrap justify-center gap-3">
<Link
href="/login"
className="rounded-xl bg-[#ff4d4f] px-5 py-2.5 text-sm font-semibold text-white hover:bg-[#ff7a45]"
>
{c.login}
</Link>
{session.lockReason === "vip_required" ? (
<Link
href="/join"
className="rounded-xl border border-violet-300 px-5 py-2.5 text-sm font-semibold text-violet-700 dark:border-violet-700 dark:text-violet-300"
>
{c.joinVip}
</Link>
) : null}
<Link href="/meetups" className="rounded-xl border border-gray-200 px-5 py-2.5 text-sm font-semibold text-gray-600 dark:border-gray-700 dark:text-gray-300">
{c.back}
</Link>
</div>
</div>
);
}
const showVideo = panel === "split" || panel === "video";
const showChat = panel === "split" || panel === "chat";
return (
<div className="flex min-h-screen flex-col bg-[#0b1220] text-white">
<header
className="border-b border-white/10 px-4 py-4 sm:px-6"
style={{ background: `linear-gradient(135deg, ${meetup.gradientFrom}, ${meetup.gradientTo})` }}
>
<div className="mx-auto flex max-w-[1600px] flex-wrap items-center justify-between gap-4">
<div className="min-w-0">
<Link href="/meetups" className="text-xs font-medium text-white/75 hover:text-white">
{c.back}
</Link>
<h1 className="mt-2 truncate text-xl font-bold sm:text-2xl">
{meetup.emoji} {meetup.city} · {meetup.venue}
</h1>
<p className="mt-1 text-sm text-white/85">{formattedDate}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full bg-black/20 px-3 py-1 text-xs font-semibold backdrop-blur">
{c.identity}: {session.displayName}
</span>
<span className="rounded-full bg-black/20 px-3 py-1 text-xs font-semibold backdrop-blur">{badge}</span>
<span className="rounded-full bg-black/20 px-3 py-1 text-xs font-semibold backdrop-blur">
{c.channel} {session.loungeChannel}
</span>
</div>
</div>
</header>
<div className="border-b border-white/10 bg-[#111827] px-4 py-3 sm:px-6">
<div className="mx-auto flex max-w-[1600px] flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2 text-xs text-white/70">
<span className="rounded-full bg-white/10 px-2.5 py-1">{MIROTALK_PROVIDER_LABEL}</span>
<span className="rounded-full bg-white/10 px-2.5 py-1">{WEB_CHAT_PROVIDER_LABEL}</span>
<span className="rounded-full bg-white/10 px-2.5 py-1">{c.providers}</span>
</div>
<div className="flex flex-wrap gap-2">
{(["split", "video", "chat"] as PanelMode[]).map((mode) => (
<button
key={mode}
type="button"
onClick={() => setPanel(mode)}
className={`rounded-lg px-3 py-1.5 text-xs font-semibold transition ${
panel === mode ? "bg-[#ff4d4f] text-white" : "bg-white/10 text-white/80 hover:bg-white/15"
}`}
>
{mode === "split" ? c.split : mode === "video" ? c.videoOnly : c.chatOnly}
</button>
))}
<a
href={session.videoUrl}
target="_blank"
rel="noreferrer"
className="rounded-lg border border-white/15 px-3 py-1.5 text-xs font-semibold text-white/85 hover:bg-white/10"
>
{c.openVideo}
</a>
<a
href={session.chatUrl}
target="_blank"
rel="noreferrer"
className="rounded-lg border border-white/15 px-3 py-1.5 text-xs font-semibold text-white/85 hover:bg-white/10"
>
{c.openChat}
</a>
</div>
</div>
</div>
<main className="mx-auto grid w-full max-w-[1600px] flex-1 gap-0 px-0 py-0 lg:grid-cols-[minmax(0,1.35fr)_minmax(320px,0.65fr)]">
{showVideo ? (
<section className={`relative min-h-[320px] bg-black ${panel === "split" ? "border-r border-white/10" : ""}`}>
<iframe
title={`${meetup.city} video`}
src={session.videoUrl}
className="h-[50vh] w-full lg:h-[calc(100vh-180px)]"
allow="camera; microphone; display-capture; fullscreen; clipboard-read; clipboard-write"
referrerPolicy="no-referrer-when-downgrade"
/>
</section>
) : null}
{showChat ? (
<section className="relative min-h-[320px] bg-[#0f172a]">
<iframe
title={`${meetup.city} chat`}
src={session.chatUrl}
className="h-[50vh] w-full lg:h-[calc(100vh-180px)]"
referrerPolicy="no-referrer-when-downgrade"
/>
</section>
) : null}
</main>
</div>
);
}

View File

@@ -1,8 +1,47 @@
export const AVATAR_BASE_URL = "https://minio.nomadro.cn/hackrobot/nomadcna/avatars";
/** 头像裁切参数:正方形、聚焦面部 */
const AVATAR_CROP = "w=480&h=480&fit=crop&crop=faces&auto=format&q=85";
export const AVATAR_URLS = Array.from(
{ length: 32 },
(_, index) => `${AVATAR_BASE_URL}/avatar-${String(index + 1).padStart(2, "0")}.jpg`
/**
* 32 张精选东亚青年人像(约 20 岁档),男女交替。
* 来源 Unsplash 免费图库,经 asian portrait 检索筛选。
*/
const AVATAR_PHOTO_IDS = [
"photo-1534528741775-53994a69daeb", // F
"photo-1542909168-82c3e7fdca5c", // M
"photo-1594744803329-7837a673c588", // F
"photo-1681097561932-36d0df02b379", // M
"photo-1616268164880-673b3ba611bb", // F
"photo-1720501828093-c792c10e3f0b", // M
"photo-1534751516642-a1af1ef26a56", // F
"photo-1542327897-d73f4005b533", // M
"photo-1542996966-2e31c00bae31", // F
"photo-1540569014015-19a7be504e3a", // M
"photo-1696956994811-95c0a29c917c", // F
"photo-1642060603505-e716140d45d2", // M
"photo-1619235327941-c0e47a3ac74a", // F
"photo-1552358155-515e264cb8b8", // M
"photo-1541823709867-1b206113eafd", // F
"photo-1507591064344-4c6ce005b128", // M
"photo-1580489944761-15a19d654956", // F
"photo-1546567850-8a49d669d37a", // M
"photo-1610892417845-07f399a903f4", // F
"photo-1616326431985-b9f89ebc6ab7", // M
"photo-1524502397800-2ee9a7dfd228", // F
"photo-1543610892-0b1f7e6d8ac1", // M
"photo-1573496359142-b8d87734a5a2", // F
"photo-1537107041341-713aaa2a234c", // M
"photo-1601455763557-db1aca8d9a2d", // F
"photo-1503443207922-dff7d543fd0e", // M
"photo-1521577352947-9bb5871b2487", // F
"photo-1534471770828-9bde524ee634", // M
"photo-1544005313-94ddf0286df2", // F
"photo-1563521779750-d0bb67aa1d23", // M
"photo-1554151228-14d9def656e4", // F
"photo-1543132220-3ec99c6094dc", // M
] as const;
export const AVATAR_URLS = AVATAR_PHOTO_IDS.map(
(id) => `https://images.unsplash.com/${id}?${AVATAR_CROP}`
);
export function avatarForIndex(index: number): string {

View File

@@ -1,14 +1,18 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import NextLink from "next/link";
import { Link, useLocale, usePathname, useRouter, useTranslation } from "@/app/digital/i18n/navigation";
import { getStoredUserEmail } from "@/app/digital/lib/pocketbase";
import { useCrossSiteUrls } from "@/app/digital/lib/useCrossSiteUrls";
import AuthModal from "./AuthModal";
import DigitalThemeIcon from "./DigitalThemeIcon";
import ThemeToggle from "./ThemeToggle";
import UserAvatar from "./UserAvatar";
type NavItem =
| { labelKey: "roadmap" | "tools" | "resources"; href: string; hash: string }
| { labelKey: "community"; href: string; external: true };
export default function Header() {
const { t } = useTranslation("common");
const { t: tNav } = useTranslation("nav");
@@ -16,18 +20,16 @@ export default function Header() {
const pathname = usePathname();
const router = useRouter();
const [mobileOpen, setMobileOpen] = useState(false);
const [scrolled, setScrolled] = useState(() =>
typeof window !== "undefined" ? window.scrollY > 8 : false
);
const [authOpen, setAuthOpen] = useState(false);
const [userEmail, setUserEmail] = useState<string | null>(null);
const [vip, setVip] = useState(false);
const { vipUrl } = useCrossSiteUrls();
const navLinks = [
{ labelKey: "roadmap" as const, href: "/#roadmap" },
{ labelKey: "tools" as const, href: "/#tools" },
{ labelKey: "community" as const, href: vipUrl, external: true },
{ labelKey: "resources" as const, href: "/#resources" },
const navLinks: NavItem[] = [
{ labelKey: "roadmap", href: "/#roadmap", hash: "roadmap" },
{ labelKey: "tools", href: "/#tools", hash: "tools" },
{ labelKey: "resources", href: "/#resources", hash: "resources" },
{ labelKey: "community", href: vipUrl, external: true },
];
const fetchAuthAndVip = useCallback(async () => {
@@ -38,8 +40,10 @@ export default function Header() {
const { pbSaveAuth } = await import("@/app/digital/lib/pocketbase");
pbSaveAuth(meData.token, { id: meData.user.id, email: meData.user.email });
setUserEmail(meData.user.email);
// 用邮箱查 VIP比 cookie userId 更可靠
const vipRes = await fetch(`/api/digital/vip/check?email=${encodeURIComponent(meData.user.email)}`, { credentials: "include" });
const vipRes = await fetch(
`/api/digital/vip/check?email=${encodeURIComponent(meData.user.email)}`,
{ credentials: "include" }
);
const vipData = await vipRes.json();
setVip(vipData?.vip === true);
} else {
@@ -55,12 +59,6 @@ export default function Header() {
fetchAuthAndVip();
}, [fetchAuthAndVip]);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8);
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => {
const onVipUpdated = () => fetchAuthAndVip();
const onAuthSuccess = () => fetchAuthAndVip();
@@ -75,157 +73,178 @@ export default function Header() {
};
}, [fetchAuthAndVip]);
const isGuideHome = pathname === "/";
const navClass = (active: boolean) =>
`flex items-center gap-1.5 px-3 py-2 rounded-full text-sm font-medium transition-colors whitespace-nowrap shrink-0 ${
active
? "text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-800"
: "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800"
}`;
return (
<header
className={`dn-header fixed top-0 left-0 right-0 z-50 w-full max-w-[100vw] transition-all duration-300 ${
scrolled
? "dn-header-scrolled bg-white/80 backdrop-blur-lg shadow-sm dark:bg-slate-900/80 dark:shadow-slate-950/50"
: "bg-transparent"
}`}
>
<div className="dn-container mx-auto flex h-16 w-full min-w-0 max-w-7xl items-center justify-between gap-2 pl-[max(1rem,env(safe-area-inset-left))] pr-[max(1rem,env(safe-area-inset-right))] sm:pl-[max(1.5rem,env(safe-area-inset-left))] sm:pr-[max(1.5rem,env(safe-area-inset-right))] lg:pl-[max(2rem,env(safe-area-inset-left))] lg:pr-[max(2rem,env(safe-area-inset-right))]">
<Link href="/" className="dn-brand flex min-w-0 shrink items-center gap-2 text-lg font-bold text-slate-900 dark:text-slate-100">
<DigitalThemeIcon name="globe" emoji="🌍" className="h-7 w-7 text-2xl" />
<span className="hidden sm:inline">{t("siteName")}</span>
<span className="sm:hidden">{t("siteNameShort")}</span>
<header className="dn-header sticky top-0 z-50 bg-white/80 dark:bg-gray-900/80 backdrop-blur-lg border-b border-gray-100 dark:border-gray-800">
<div className="max-w-[1600px] mx-auto px-4 sm:px-6 h-16 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
<NextLink
href={`/${locale}`}
className="flex shrink-0 items-center gap-2 text-gray-900 transition-colors hover:text-[#ff4d4f] dark:text-gray-100"
title={tNav("mainSite")}
>
<span className="text-2xl">🌏</span>
<span className="hidden text-lg font-bold tracking-tight sm:inline">
{t("parentSiteName")}
</span>
</NextLink>
<span className="hidden text-gray-300 dark:text-gray-600 sm:inline" aria-hidden>
/
</span>
<Link
href="/"
className={`truncate text-sm font-semibold sm:text-base ${
isGuideHome
? "text-[#ff4d4f]"
: "text-gray-600 hover:text-[#ff4d4f] dark:text-gray-400"
}`}
>
{t("siteName")}
</Link>
<nav className="dn-nav hidden items-center gap-1 md:flex">
{navLinks.map((link) => (
"external" in link && link.external ? (
<a
key={link.href}
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="dn-nav-link rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
>
{tNav(link.labelKey)}
</a>
) : (
<Link
key={link.href}
href={link.href}
className="dn-nav-link rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
>
{tNav(link.labelKey)}
</Link>
)
))}
</nav>
<div className="flex shrink-0 items-center gap-1 sm:gap-3">
{userEmail ? (
<UserAvatar email={userEmail} vip={vip} onLogout={() => { setUserEmail(null); setVip(false); router.replace("/"); }} />
) : (
<button
onClick={() => setAuthOpen(true)}
className="dn-button-secondary hidden rounded-full border border-slate-200 px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 sm:inline-flex"
>
{t("loginRegister")}
</button>
)}
<button
type="button"
onClick={() => router.replace(pathname, { locale: locale === "zh" ? "en" : "zh" })}
className="dn-header-control inline-flex shrink-0 items-center justify-center rounded-lg p-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
aria-label={locale === "zh" ? "Switch to English" : "切换到中文"}
title={locale === "zh" ? "EN" : "中文"}
>
{locale === "zh" ? "EN" : "中文"}
</button>
<ThemeToggle />
<button
onClick={() => setMobileOpen(!mobileOpen)}
className="dn-header-control inline-flex shrink-0 items-center justify-center rounded-lg p-2 text-slate-600 transition-colors hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 md:hidden"
aria-label={t("toggleMenu")}
>
{mobileOpen ? (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
)}
</button>
<Link
href="/#roadmap"
className="dn-button-primary hidden rounded-full bg-sky-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-sky-600 dark:bg-sky-600 dark:hover:bg-sky-500 sm:inline-flex"
>
{t("explore")}
</Link>
</div>
</div>
<nav className="hidden md:flex items-center gap-1 overflow-x-auto max-w-[calc(100vw-420px)] hide-scrollbar">
{navLinks.map((link) =>
"external" in link && link.external ? (
<a
key={link.href}
href={link.href}
target="_blank"
rel="noopener noreferrer"
className={navClass(false)}
>
{tNav(link.labelKey)}
</a>
) : (
<Link key={link.href} href={link.href} className={navClass(false)}>
{tNav(link.labelKey)}
</Link>
)
)}
</nav>
<div className="flex shrink-0 items-center gap-1 sm:gap-2">
{userEmail ? (
<UserAvatar
email={userEmail}
vip={vip}
onLogout={() => {
setUserEmail(null);
setVip(false);
router.replace("/");
}}
/>
) : (
<button
type="button"
onClick={() => setAuthOpen(true)}
className="hidden sm:inline-flex shrink-0 items-center justify-center rounded-full border border-gray-200 dark:border-gray-700 px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
{t("loginRegister")}
</button>
)}
<button
type="button"
onClick={() => router.replace(pathname, { locale: locale === "zh" ? "en" : "zh" })}
className="inline-flex shrink-0 items-center justify-center rounded-lg p-2 text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label={locale === "zh" ? "Switch to English" : "切换到中文"}
title={locale === "zh" ? "EN" : "中文"}
>
{locale === "zh" ? "EN" : "中文"}
</button>
<ThemeToggle />
<Link
href="/#roadmap"
className="dn-button-primary hidden text-xs sm:text-sm text-white px-3 sm:px-4 py-2 rounded-full font-medium whitespace-nowrap sm:inline-flex"
>
{t("explore")}
</Link>
<button
type="button"
className="md:hidden p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
onClick={() => setMobileOpen(!mobileOpen)}
aria-label={t("toggleMenu")}
>
{mobileOpen ? (
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
)}
</button>
</div>
</div>
{mobileOpen && (
<div className="dn-mobile-menu overflow-x-hidden border-t border-slate-100 bg-white/95 backdrop-blur-lg dark:border-slate-800 dark:bg-slate-900/95 md:hidden">
<nav className="mx-auto w-full max-w-7xl space-y-1 px-4 py-3 pr-[max(1rem,env(safe-area-inset-right))]">
{navLinks.map((link) => (
"external" in link && link.external ? (
<a
key={link.href}
href={link.href}
target="_blank"
rel="noopener noreferrer"
onClick={() => setMobileOpen(false)}
className="dn-nav-link block rounded-lg px-3 py-2.5 text-base font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
>
{tNav(link.labelKey)}
</a>
) : (
<Link
key={link.href}
href={link.href}
onClick={() => setMobileOpen(false)}
className="dn-nav-link block rounded-lg px-3 py-2.5 text-base font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
>
{tNav(link.labelKey)}
</Link>
)
))}
<div className="md:hidden border-t border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900 px-4 py-3 space-y-1">
<NextLink
href={`/${locale}`}
onClick={() => setMobileOpen(false)}
className="flex items-center gap-2 px-4 py-3 rounded-xl text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
>
<span className="text-lg">🌏</span>
<span>{tNav("mainSite")}</span>
</NextLink>
{navLinks.map((link) =>
"external" in link && link.external ? (
<a
key={link.href}
href={link.href}
target="_blank"
rel="noopener noreferrer"
onClick={() => setMobileOpen(false)}
className="flex items-center gap-2 px-4 py-3 rounded-xl text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
>
{tNav(link.labelKey)}
</a>
) : (
<Link
key={link.href}
href={link.href}
onClick={() => setMobileOpen(false)}
className="flex items-center gap-2 px-4 py-3 rounded-xl text-sm font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
>
{tNav(link.labelKey)}
</Link>
)
)}
{userEmail ? (
<div className="pt-2 border-t border-gray-100 dark:border-gray-800 flex items-center justify-between px-4 py-2.5 rounded-lg bg-gray-50 dark:bg-gray-800">
<UserAvatar
email={userEmail}
vip={vip}
onLogout={() => {
setUserEmail(null);
setVip(false);
setMobileOpen(false);
router.replace("/");
}}
/>
<span className="truncate text-sm text-gray-600 dark:text-gray-400">{userEmail}</span>
</div>
) : (
<button
type="button"
onClick={() => {
setMobileOpen(false);
router.replace(pathname, { locale: locale === "zh" ? "en" : "zh" });
setAuthOpen(true);
}}
className="block w-full rounded-lg px-3 py-2.5 text-left text-base font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
className="block w-full text-left px-4 py-3 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
>
{locale === "zh" ? "EN" : "中文"}
{t("loginRegister")}
</button>
{userEmail ? (
<div className="dn-mobile-account mt-2 flex items-center gap-3 rounded-lg border border-slate-100 bg-slate-50 px-4 py-2.5 dark:border-slate-700 dark:bg-slate-800">
<UserAvatar email={userEmail} vip={vip} onLogout={() => { setUserEmail(null); setVip(false); setMobileOpen(false); router.replace("/"); }} />
<Link
href="/profile"
onClick={() => setMobileOpen(false)}
className="min-w-0 flex-1"
>
<span className="block truncate text-sm font-semibold text-slate-700 dark:text-slate-200">{userEmail}</span>
<span className="mt-0.5 block text-xs text-slate-500 dark:text-slate-400">{t("profile")}</span>
</Link>
</div>
) : (
<button
onClick={() => {
setMobileOpen(false);
setAuthOpen(true);
}}
className="dn-button-secondary mt-2 block w-full rounded-full border border-slate-200 px-4 py-2.5 text-center text-base font-medium text-slate-600 dark:border-slate-700 dark:text-slate-400"
>
{t("loginRegister")}
</button>
)}
<Link
href="/#roadmap"
onClick={() => setMobileOpen(false)}
className="dn-button-primary mt-2 block rounded-full bg-sky-500 px-4 py-2.5 text-center text-base font-medium text-white transition-colors hover:bg-sky-600 dark:bg-sky-600 dark:hover:bg-sky-500"
>
{t("explore")}
</Link>
</nav>
)}
</div>
)}

View File

@@ -6,13 +6,13 @@ import DigitalThemeIcon from "./DigitalThemeIcon";
export default function Hero() {
const { t } = useTranslation("hero");
return (
<section className="dn-hero relative overflow-hidden pt-24 pb-16 sm:pt-32 sm:pb-20">
<div className="dn-hero-bg pointer-events-none absolute inset-0 bg-gradient-to-b from-sky-50/80 via-white to-white dark:from-slate-900 dark:via-slate-900 dark:to-slate-950" />
<div className="dn-hero-glow pointer-events-none absolute top-0 left-1/2 h-[600px] w-[800px] -translate-x-1/2 rounded-full bg-gradient-to-br from-sky-100/60 via-cyan-50/40 to-amber-50/30 blur-3xl dark:from-sky-900/20 dark:via-slate-800/30 dark:to-amber-900/10" />
<section className="dn-hero relative overflow-hidden pt-10 pb-16 sm:pt-14 sm:pb-20">
<div className="dn-hero-bg pointer-events-none absolute inset-0" />
<div className="dn-hero-glow pointer-events-none absolute top-0 left-1/2 h-[600px] w-[800px] -translate-x-1/2 rounded-full blur-3xl" />
<div className="dn-container relative mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="text-center">
<div className="dn-badge mb-6 inline-flex items-center gap-2 rounded-full border border-sky-200 bg-sky-50 px-4 py-1.5 text-sm font-medium text-sky-700 dark:border-sky-800 dark:bg-sky-900/50 dark:text-sky-300">
<div className="dn-badge mb-6 inline-flex items-center gap-2 rounded-full px-4 py-1.5 text-sm font-medium">
<DigitalThemeIcon name="briefcase" emoji="🎒" className="h-4 w-4" />
<span>{t("badge")}</span>
</div>
@@ -34,7 +34,7 @@ export default function Hero() {
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row sm:gap-4">
<a
href="#roadmap"
className="dn-button-primary inline-flex w-full items-center justify-center gap-2 rounded-full bg-sky-500 px-8 py-3.5 text-base font-semibold text-white shadow-lg shadow-sky-500/25 transition-all hover:bg-sky-600 hover:shadow-xl hover:shadow-sky-500/30 sm:w-auto"
className="dn-button-primary inline-flex w-full items-center justify-center gap-2 rounded-full px-8 py-3.5 text-base font-semibold text-white sm:w-auto"
>
{t("ctaStart")}
<span></span>

View File

@@ -9,7 +9,7 @@ export default function ThemeToggle() {
<button
type="button"
onClick={toggleTheme}
className="dn-header-control rounded-lg p-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
className="rounded-lg p-2 text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
aria-label={theme === "dark" ? "切换到日间模式" : "切换到夜间模式"}
title={theme === "dark" ? "日间模式" : "夜间模式"}
>

View File

@@ -148,7 +148,10 @@ html {
/* ==================== Right Item ==================== */
.right-item {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
border-radius: 15px;
position: relative;
overflow: hidden;
@@ -158,6 +161,13 @@ html {
background: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04);
}
.right-item-body {
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.dark .right-item {
background: #1f2937;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(255, 255, 255, 0.05);
@@ -170,6 +180,7 @@ html {
.right-item-header {
width: 100%;
height: 38px;
flex: 0 0 38px;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.5px;
@@ -183,6 +194,7 @@ html {
@media (min-width: 640px) {
.right-item-header {
height: 42px;
flex-basis: 42px;
font-size: 15px;
}
}

View File

@@ -1,5 +1,15 @@
const MEDIA_PROXY_PATH = "/api/media/proxy";
/** 浏览器可直接加载的公开图床,无需经 MinIO 代理缓存 */
const DIRECT_MEDIA_HOSTS = [
"images.unsplash.com",
"unsplash.com",
"i.pravatar.cc",
"api.dicebear.com",
"logo.clearbit.com",
"avatars.githubusercontent.com",
];
function configuredMinioPublicUrl(): string {
return (
process.env.NEXT_PUBLIC_MINIO_PUBLIC_URL ||
@@ -8,6 +18,13 @@ function configuredMinioPublicUrl(): string {
).replace(/\/$/, "");
}
function isDirectMediaHost(hostname: string): boolean {
const host = hostname.toLowerCase();
return DIRECT_MEDIA_HOSTS.some(
(allowed) => host === allowed || host.endsWith(`.${allowed}`)
);
}
export function isExternalMediaUrl(value: string): boolean {
return /^https?:\/\//i.test(value);
}
@@ -22,8 +39,23 @@ export function mediaUrl(value?: string | null): string {
if (!url) return "";
if (/^(data:|blob:|#|mailto:|tel:)/i.test(url)) return url;
if (/^file:/i.test(url)) return "";
if (url.startsWith("/") && !url.startsWith("//")) {
const publicUrl = configuredMinioPublicUrl();
return publicUrl ? `${publicUrl}${url}` : url;
}
if (!isExternalMediaUrl(url)) return url;
if (isMinioUrl(url)) return url;
try {
const parsed = new URL(url);
if (isDirectMediaHost(parsed.hostname)) return url;
} catch {
return "";
}
return `${MEDIA_PROXY_PATH}?src=${encodeURIComponent(url)}`;
}

View File

@@ -7,13 +7,42 @@ export interface MeetupFeatureOption {
en: string;
}
export interface MiroTalkMeetupLike {
export interface MeetupLike {
id: string;
city?: string;
date?: string;
time?: string;
meetingUrl?: string;
mirotalkRoom?: string;
loungeChannel?: string;
mode?: MeetupMode | string;
accessLevel?: MeetupAccessLevel | string;
}
export interface MeetupViewer {
loaded?: boolean;
user?: {
id?: string;
email?: string;
name?: string;
vip?: boolean;
role?: string;
memberRole?: string;
siteRole?: string;
} | null;
vip?: boolean;
}
export interface MeetupSession {
canJoin: boolean;
lockReason?: string;
displayName: string;
videoUrl: string;
chatUrl: string;
loungeChannel: string;
mirotalkRoom: string;
meetingProvider: string;
user?: MeetupViewer["user"];
}
export const DEFAULT_MIROTALK_URL = "https://mirotalk.nomadro.cn";
@@ -41,13 +70,27 @@ export function normalizeMiroTalkRoom(value: string): string {
.slice(0, 80);
}
export function createMiroTalkRoomSlug(meetup: MiroTalkMeetupLike): string {
export function normalizeLoungeChannel(value: string): string {
const channel = value.trim();
if (!channel) return "";
return channel.startsWith("#") ? channel : `#${channel}`;
}
export function createMiroTalkRoomSlug(meetup: MeetupLike): 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 createLoungeChannel(meetup: MeetupLike): string {
const explicit = normalizeLoungeChannel(meetup.loungeChannel || "");
if (explicit) return explicit;
const room = normalizeMiroTalkRoom(meetup.mirotalkRoom || "");
if (room) return `#${room}`;
return `#${createMiroTalkRoomSlug(meetup)}`;
}
export function getMiroTalkBaseUrl(): string {
return (
process.env.NEXT_PUBLIC_MIROTALK_URL ||
@@ -66,7 +109,7 @@ export function getMiroTalkServerLabel(): string {
}
}
export function buildMiroTalkJoinUrl(meetup: MiroTalkMeetupLike, displayName = "NomadCNA"): string {
export function buildMiroTalkJoinUrl(meetup: MeetupLike, displayName = "NomadCNA"): string {
if (meetup.meetingUrl && /^https?:\/\//i.test(meetup.meetingUrl)) {
return meetup.meetingUrl;
}
@@ -89,8 +132,35 @@ export function getLoungeServerLabel(): string {
}
}
export function buildMeetupChatUrl(): string {
return getLoungeBaseUrl();
export function buildMeetupChatUrl(meetup: MeetupLike): string {
const channel = createLoungeChannel(meetup);
return `${getLoungeBaseUrl()}/#/chan-${channel}`;
}
export function buildMeetupLivePath(meetupId: string): string {
return `/meetups/${meetupId}/live`;
}
export function viewerDisplayName(viewer: MeetupViewer): 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";
}
export function isHostViewer(viewer: MeetupViewer): boolean {
const role = String(
viewer.user?.role || viewer.user?.memberRole || viewer.user?.siteRole || ""
).toLowerCase();
return ["admin", "owner", "host", "organizer", "moderator"].includes(role);
}
export function canAccessMeetup(meetup: Pick<MeetupLike, "accessLevel">, viewer: MeetupViewer): boolean {
const access = String(meetup.accessLevel || "public").toLowerCase();
if (access === "public") return true;
if (access === "members") return Boolean(viewer.user);
if (access === "vip") return Boolean(viewer.vip || viewer.user?.vip);
return isHostViewer(viewer);
}
export function featureLabels(keys: string[], locale: string): string[] {
@@ -99,3 +169,7 @@ export function featureLabels(keys: string[], locale: string): string[] {
locale === "zh" ? feature.zh : feature.en
);
}
export function isLiveMeetup(meetup: Pick<MeetupLike, "mode">): boolean {
return meetup.mode === "online" || meetup.mode === "hybrid";
}