Files
gitlab-instance-0a899031_cn…/app/[locale]/dating/page.tsx
root b65908e328 s
2026-06-08 10:02:16 +08:00

310 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useTranslation } from "@/i18n/navigation";
import Footer from "@/app/components/Footer";
import { apiFetch } from "@/app/lib/api-client";
import { mediaUrl } from "@/app/lib/media";
import { avatarForIndex, avatarForName } from "@/app/data/avatars";
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;
}
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() {
const { t } = useTranslation("match");
const [activeTab, setActiveTab] = useState<TabType>("friends");
const [currentIndex, setCurrentIndex] = useState(0);
const [profiles, setProfiles] = useState<Profile[]>(FALLBACK_PROFILES);
const [dragX, setDragX] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [isAnimatingOut, setIsAnimatingOut] = useState<"left" | "right" | null>(null);
const startXRef = useRef(0);
useEffect(() => {
apiFetch<{ items: ProfileRecord[] }>("/api/profiles")
.then((data) => {
if (data.items?.length) {
setProfiles(data.items.map(mapProfile));
setCurrentIndex(0);
}
})
.catch(() => setProfiles(FALLBACK_PROFILES));
}, []);
const matches = useMemo(
() => (profiles.length ? profiles.slice(0, 5).map(toMatch) : FALLBACK_MATCHES),
[profiles]
);
const currentProfile = profiles[currentIndex] || profiles[0] || FALLBACK_PROFILES[0];
const hasMoreProfiles = currentIndex < profiles.length - 1;
const saveSwipe = (action: "like" | "dislike") => {
if (!currentProfile) return;
apiFetch("/api/matches/swipes", {
method: "POST",
body: JSON.stringify({ profileId: currentProfile.id, action }),
}).catch(() => {});
};
const nextProfile = () => {
window.setTimeout(() => {
setCurrentIndex((i) => (hasMoreProfiles ? i + 1 : 0));
setDragX(0);
setIsAnimatingOut(null);
}, 160);
};
const handleLike = () => {
saveSwipe("like");
setIsAnimatingOut("right");
setDragX(260);
nextProfile();
};
const handleDislike = () => {
saveSwipe("dislike");
setIsAnimatingOut("left");
setDragX(-260);
nextProfile();
};
const beginDrag = (clientX: number) => {
startXRef.current = clientX;
setIsDragging(true);
};
const moveDrag = (clientX: number) => {
if (!isDragging) return;
setDragX(Math.max(-180, Math.min(180, clientX - startXRef.current)));
};
const endDrag = () => {
if (!isDragging) return;
setIsDragging(false);
if (dragX > 90) {
handleLike();
return;
}
if (dragX < -90) {
handleDislike();
return;
}
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 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) => (
<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"
}`}
>
{t(tab)}
</button>
))}
</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">
<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>
</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>
</button>
<span className="text-xs text-white/80 font-medium">{t("dislike")}</span>
</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>
</button>
<span className="text-xs text-white/80 font-medium">{t("like")}</span>
</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>
<Footer />
</div>
);
}