diff --git a/.gitignore b/.gitignore index 2f23f3c..bd44de7 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) # .env* +cursor.env # vercel .vercel diff --git a/app/[locale]/dating/components/LikesPanel.tsx b/app/[locale]/dating/components/LikesPanel.tsx index cd8d758..3203073 100644 --- a/app/[locale]/dating/components/LikesPanel.tsx +++ b/app/[locale]/dating/components/LikesPanel.tsx @@ -1,5 +1,6 @@ "use client"; +import type { ReactNode } from "react"; import { Link } from "@/i18n/navigation"; import { mediaUrl } from "@/app/lib/media"; import type { DatingProfile } from "../types"; @@ -10,6 +11,8 @@ interface LikesPanelProps { formatTime: (value?: string) => string; t: (key: string) => string; onSelectProfile?: (profile: DatingProfile) => void; + onLikeBack?: (profile: DatingProfile) => void; + likingBackId?: string | null; } function ProfileRow({ @@ -17,11 +20,13 @@ function ProfileRow({ timeLabel, badge, onSelect, + action, }: { profile: DatingProfile; timeLabel: string; badge?: string; onSelect?: () => void; + action?: ReactNode; }) { const 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"; @@ -41,9 +46,12 @@ function ProfileRow({ ) : null} - - {timeLabel} - +
+ {action} + + {timeLabel} + +
); if (onSelect) { @@ -56,7 +64,15 @@ function ProfileRow({ return
{content}
; } -export default function LikesPanel({ likes, mutual, formatTime, t, onSelectProfile }: LikesPanelProps) { +export default function LikesPanel({ + likes, + mutual, + formatTime, + t, + onSelectProfile, + onLikeBack, + likingBackId, +}: LikesPanelProps) { return (
@@ -77,6 +93,21 @@ export default function LikesPanel({ likes, mutual, formatTime, t, onSelectProfi profile={profile} timeLabel={formatTime(profile.likedAt)} onSelect={() => onSelectProfile?.(profile)} + action={ + onLikeBack ? ( + + ) : null + } /> )) ) : ( diff --git a/app/[locale]/dating/components/SwipeCard.tsx b/app/[locale]/dating/components/SwipeCard.tsx index 66c7fea..486330b 100644 --- a/app/[locale]/dating/components/SwipeCard.tsx +++ b/app/[locale]/dating/components/SwipeCard.tsx @@ -12,6 +12,8 @@ interface SwipeCardProps { isAnimatingOut: "left" | "right" | null; onLike: () => void; onDislike: () => void; + onSuperLike?: () => void; + showSuperLike?: boolean; onBeginDrag: (clientX: number) => void; onMoveDrag: (clientX: number) => void; onEndDrag: () => void; @@ -26,6 +28,8 @@ export default function SwipeCard({ isAnimatingOut, onLike, onDislike, + onSuperLike, + showSuperLike = false, onBeginDrag, onMoveDrag, onEndDrag, @@ -76,17 +80,20 @@ export default function SwipeCard({ e.stopPropagation()} > {t("report")}
- {profile.name} + e.stopPropagation()}> + {profile.name} +
@@ -114,11 +121,17 @@ export default function SwipeCard({

{profile.bio}

-
+
e.stopPropagation()} + >
{t("dislike")}
+ {showSuperLike && onSuperLike ? ( +
+ + {t("superlike")} +
+ ) : null}
diff --git a/app/[locale]/dating/utils.ts b/app/[locale]/dating/utils.ts index 3a6e83f..ac4d438 100644 --- a/app/[locale]/dating/utils.ts +++ b/app/[locale]/dating/utils.ts @@ -45,10 +45,20 @@ export function filterFallbackProfiles( ) { return false; } - if (advanced?.gender && advanced.gender !== "all" && profile.gender !== advanced.gender) { + if ( + advanced?.gender && + advanced.gender !== "all" && + advanced.gender !== "不限" && + profile.gender !== advanced.gender + ) { return false; } - if (advanced?.single && advanced.single !== "all" && profile.single !== advanced.single) { + if ( + advanced?.single && + advanced.single !== "all" && + advanced.single !== "不限" && + profile.single !== advanced.single + ) { return false; } return true; @@ -123,7 +133,7 @@ export async function fetchMutualMatches(): Promise { export async function saveSwipe( profileId: string, - action: "like" | "dislike", + action: "like" | "dislike" | "superlike", intent: MatchIntent ): Promise { return apiFetch("/api/matches/swipes", { diff --git a/app/[locale]/discuss/[id]/page.tsx b/app/[locale]/discuss/[id]/page.tsx new file mode 100644 index 0000000..48c5e6f --- /dev/null +++ b/app/[locale]/discuss/[id]/page.tsx @@ -0,0 +1,268 @@ +"use client"; + +import { FormEvent, useEffect, useState } from "react"; +import { Link, useLocale } from "@/i18n/navigation"; +import { useParams } from "next/navigation"; +import Footer from "@/app/components/Footer"; +import { fetchAuthMe } from "@/app/lib/api-client"; +import { + fetchDiscussion, + fetchDiscussionReplies, + postDiscussionReply, + toggleDiscussionLike, + toggleDiscussionPin, + type DiscussionReply, +} from "@/app/lib/social"; + +export default function DiscussThreadPage() { + const params = useParams(); + const discussionId = String(params?.id || ""); + const locale = useLocale(); + const [topic, setTopic] = useState | null>(null); + const [replies, setReplies] = useState([]); + const [draft, setDraft] = useState(""); + const [loading, setLoading] = useState(true); + const [posting, setPosting] = useState(false); + const [isLoggedIn, setIsLoggedIn] = useState(false); + const [currentUserId, setCurrentUserId] = useState(""); + const [liking, setLiking] = useState(false); + const [pinning, setPinning] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + fetchAuthMe().then((data) => { + const userId = String(data?.user?.id || ""); + setIsLoggedIn(Boolean(userId)); + setCurrentUserId(userId); + }); + }, []); + + useEffect(() => { + if (!discussionId) return; + let cancelled = false; + Promise.all([fetchDiscussion(discussionId), fetchDiscussionReplies(discussionId)]) + .then(([topicData, replyItems]) => { + if (cancelled) return; + setTopic(topicData.item); + setReplies(replyItems); + }) + .catch(() => { + if (!cancelled) setError(locale === "zh" ? "加载失败" : "Failed to load"); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [discussionId, locale]); + + const handleLike = async () => { + if (!isLoggedIn || !discussionId) return; + setLiking(true); + setError(""); + try { + const result = await toggleDiscussionLike(discussionId); + setTopic((current) => + current + ? { + ...current, + likes: result.likes, + likedByMe: result.liked, + } + : current + ); + } catch (err) { + setError(err instanceof Error ? err.message : locale === "zh" ? "点赞失败" : "Like failed"); + } finally { + setLiking(false); + } + }; + + const handlePin = async () => { + if (!isLoggedIn || !discussionId) return; + setPinning(true); + setError(""); + try { + const result = await toggleDiscussionPin(discussionId); + setTopic((current) => + current + ? { + ...current, + pinned: result.pinned, + } + : current + ); + } catch (err) { + setError(err instanceof Error ? err.message : locale === "zh" ? "置顶失败" : "Pin failed"); + } finally { + setPinning(false); + } + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + const body = draft.trim(); + if (!body || !discussionId) return; + if (!isLoggedIn) { + setError(locale === "zh" ? "请先登录后再回复" : "Please log in to reply"); + return; + } + setPosting(true); + setError(""); + try { + const reply = await postDiscussionReply(discussionId, body); + setReplies((current) => [...current, reply]); + setDraft(""); + setTopic((current) => + current ? { ...current, replies: Number(current.replies || 0) + 1 } : current + ); + } catch (err) { + setError(err instanceof Error ? err.message : locale === "zh" ? "回复失败" : "Reply failed"); + } finally { + setPosting(false); + } + }; + + if (loading) { + return ( +
+
+
+
+
+ ); + } + + if (!topic) { + return ( +
+
+

{error || (locale === "zh" ? "话题不存在" : "Topic not found")}

+ + {locale === "zh" ? "返回讨论区" : "Back to discussions"} + +
+
+ ); + } + + const tags = Array.isArray(topic.tags) ? topic.tags.map(String) : []; + const likedByMe = Boolean(topic.likedByMe); + const likes = Number(topic.likes || 0); + const pinned = Boolean(topic.pinned); + const isAuthor = Boolean(currentUserId && String(topic.authorUserId || "") === currentUserId); + + return ( +
+
+ + ← {locale === "zh" ? "返回讨论区" : "Back"} + + +
+
+ {tags.map((tag) => ( + + {tag} + + ))} +
+
+

{String(topic.title || "")}

+ {pinned ? ( + + 📌 {locale === "zh" ? "置顶" : "Pinned"} + + ) : null} +
+

+ {String(topic.author || "社区成员")} + {topic.city ? ` · ${String(topic.city)}` : ""} + {` · 👁 ${Number(topic.views || 0)} · 💬 ${Number(topic.replies || 0)}`} +

+
+ + {isAuthor ? ( + + ) : null} +
+ {topic.excerpt ? ( +

+ {String(topic.excerpt)} +

+ ) : null} +
+ +
+

+ {locale === "zh" ? "回复" : "Replies"} ({replies.length}) +

+ {replies.length ? ( + replies.map((reply) => ( +
+
+ + {reply.author || (locale === "zh" ? "成员" : "Member")} + + {reply.created ? new Date(reply.created).toLocaleString() : ""} +
+

{reply.body}

+
+ )) + ) : ( +

+ {locale === "zh" ? "还没有回复,来抢沙发吧" : "No replies yet"} +

+ )} +
+ +
+