's'
This commit is contained in:
76
app/[locale]/dating/components/AdvancedFilters.tsx
Normal file
76
app/[locale]/dating/components/AdvancedFilters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
app/[locale]/dating/components/CityFilter.tsx
Normal file
32
app/[locale]/dating/components/CityFilter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
app/[locale]/dating/components/IntentTabs.tsx
Normal file
33
app/[locale]/dating/components/IntentTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
app/[locale]/dating/components/LikesPanel.tsx
Normal file
122
app/[locale]/dating/components/LikesPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
app/[locale]/dating/components/MatchModal.tsx
Normal file
54
app/[locale]/dating/components/MatchModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
app/[locale]/dating/components/SwipeCard.tsx
Normal file
143
app/[locale]/dating/components/SwipeCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
app/[locale]/dating/components/SwipeQuota.tsx
Normal file
52
app/[locale]/dating/components/SwipeQuota.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
app/[locale]/dating/components/SwipeStack.tsx
Normal file
70
app/[locale]/dating/components/SwipeStack.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
app/[locale]/dating/constants.ts
Normal file
128
app/[locale]/dating/constants.ts
Normal 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",
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
45
app/[locale]/dating/types.ts
Normal file
45
app/[locale]/dating/types.ts
Normal 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;
|
||||
}
|
||||
131
app/[locale]/dating/utils.ts
Normal file
131
app/[locale]/dating/utils.ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]">
|
||||
📝 自我介绍
|
||||
|
||||
91
app/[locale]/meetups/[id]/live/page.tsx
Normal file
91
app/[locale]/meetups/[id]/live/page.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
223
app/[locale]/videos/page.tsx
Normal file
223
app/[locale]/videos/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
203
app/components/meetups/EventLiveRoom.tsx
Normal file
203
app/components/meetups/EventLiveRoom.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" ? "日间模式" : "夜间模式"}
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user