From 681879b28ee43fc2dc59fd301fb4286a8f7bdd09 Mon Sep 17 00:00:00 2001 From: eric Date: Fri, 4 Sep 2026 06:23:57 -0500 Subject: [PATCH] Add left/right/up drag swipes on dating match cards. Co-authored-by: Cursor --- frontend/src/app/globals.css | 87 ++++++++++++ frontend/src/components/DatingClient.tsx | 173 ++++++++++++++++++----- frontend/src/lib/i18n/dictionaries.ts | 8 ++ 3 files changed, 236 insertions(+), 32 deletions(-) diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 44b495e..01d9ac4 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -10457,6 +10457,93 @@ img { max-width: 100%; display: block; } text-align: center; } +.dating-deck { + position: relative; + max-width: 420px; + margin: 0 auto; + min-height: 360px; + touch-action: none; + user-select: none; +} + +.dating-card-next { + position: absolute; + inset: 0; + transform: scale(0.96); + opacity: 0.55; + pointer-events: none; + z-index: 0; +} + +.dating-card-top { + position: relative; + z-index: 1; + cursor: grab; + will-change: transform; +} + +.dating-card-top.dragging { + cursor: grabbing; +} + +.dating-card-top.fly-left { + animation: dating-fly-left 0.22s ease-in forwards; +} +.dating-card-top.fly-right { + animation: dating-fly-right 0.22s ease-in forwards; +} +.dating-card-top.fly-up { + animation: dating-fly-up 0.22s ease-in forwards; +} + +@keyframes dating-fly-left { + to { transform: translate(-140%, 20px) rotate(-18deg); opacity: 0; } +} +@keyframes dating-fly-right { + to { transform: translate(140%, 20px) rotate(18deg); opacity: 0; } +} +@keyframes dating-fly-up { + to { transform: translate(0, -140%) rotate(-4deg); opacity: 0; } +} + +.dating-stamp { + position: absolute; + top: 24px; + padding: 6px 12px; + border: 3px solid; + border-radius: 8px; + font-weight: 800; + letter-spacing: 0.08em; + pointer-events: none; + z-index: 2; +} +.dating-stamp.like { + left: 18px; + color: #3dd68c; + border-color: #3dd68c; + transform: rotate(-12deg); +} +.dating-stamp.nope { + right: 18px; + color: #ff6b6b; + border-color: #ff6b6b; + transform: rotate(12deg); +} +.dating-stamp.super { + left: 50%; + top: 18px; + transform: translateX(-50%) rotate(-6deg); + color: #ffe66d; + border-color: #ffe66d; +} + +.dating-swipe-hint { + text-align: center; + color: var(--text-muted); + font-size: 0.85rem; + margin: 0.25rem 0 0.75rem; +} + .dating-card-photo { font-size: 4rem; margin-bottom: 12px; diff --git a/frontend/src/components/DatingClient.tsx b/frontend/src/components/DatingClient.tsx index 59ffd9d..f390016 100644 --- a/frontend/src/components/DatingClient.tsx +++ b/frontend/src/components/DatingClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { api } from "@/lib/api"; @@ -11,6 +11,9 @@ import type { MatchIntent, MatchProfile, MatchQuota } from "@/lib/types"; import RingNext from "@/components/RingNext"; import { useRingSteps } from "@/lib/rings"; +const SWIPE_THRESHOLD = 110; +const SUPER_THRESHOLD = -120; + export default function DatingClient() { const { user, token } = useAuth(); const router = useRouter(); @@ -25,6 +28,11 @@ export default function DatingClient() { const [joined, setJoined] = useState(false); const [error, setError] = useState(""); const [matchModal, setMatchModal] = useState<{ conversationId?: string; name: string } | null>(null); + const [drag, setDrag] = useState({ x: 0, y: 0, active: false, flying: "" }); + const startRef = useRef<{ x: number; y: number } | null>(null); + const dragRef = useRef({ x: 0, y: 0 }); + const swipingRef = useRef(false); + const cardRef = useRef(null); const intents = useMemo( () => @@ -39,6 +47,7 @@ export default function DatingClient() { [t] ); const primary = intents.slice(0, 3); + const quotaBlocked = Boolean(quota && !quota.vip && quota.remaining <= 0); const ensureJoined = useCallback(async () => { if (!token) return; @@ -49,7 +58,7 @@ export default function DatingClient() { bio: t.dating.defaultBio, }); } catch { - // Profile may already exist — continue to candidates. + // Profile may already exist. } }, [token, intent, t]); @@ -69,6 +78,7 @@ export default function DatingClient() { setDeck(Array.isArray(candidates) ? candidates : []); setQuota(q); setJoined(true); + setDrag({ x: 0, y: 0, active: false, flying: "" }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); setError(msg); @@ -109,23 +119,84 @@ export default function DatingClient() { } }; - const swipe = async (action: "like" | "dislike" | "superlike") => { - if (!token || !deck[0]) return; - const current = deck[0]; - try { - const res = await api.swipe(token, current.id, action, intent); - if (res.matched && res.match?.conversationId) { - setMatchModal({ conversationId: res.match.conversationId, name: current.name }); + const swipe = useCallback( + async (action: "like" | "dislike" | "superlike") => { + if (!token || !deck[0] || swipingRef.current || quotaBlocked) return; + swipingRef.current = true; + const current = deck[0]; + const fly = action === "like" ? "right" : action === "dislike" ? "left" : "up"; + setDrag({ x: 0, y: 0, active: false, flying: fly }); + try { + const res = await api.swipe(token, current.id, action, intent); + if (res.matched && res.match?.conversationId) { + setMatchModal({ conversationId: res.match.conversationId, name: current.name }); + } + window.setTimeout(() => { + setDeck((d) => d.slice(1)); + setDrag({ x: 0, y: 0, active: false, flying: "" }); + swipingRef.current = false; + }, 220); + const q = await api.getMatchQuota(token); + setQuota(q); + } catch { + setDrag({ x: 0, y: 0, active: false, flying: "" }); + swipingRef.current = false; + toast(t.dating.swipeFail, "error"); } - setDeck((d) => d.slice(1)); - const q = await api.getMatchQuota(token); - setQuota(q); - } catch { - toast(t.dating.swipeFail, "error"); - } + }, + [token, deck, intent, quotaBlocked, toast, t] + ); + + const onPointerDown = (e: React.PointerEvent) => { + if (quotaBlocked || swipingRef.current) return; + const target = e.target as HTMLElement; + if (target.closest("button,a,input")) return; + startRef.current = { x: e.clientX, y: e.clientY }; + dragRef.current = { x: 0, y: 0 }; + setDrag({ x: 0, y: 0, active: true, flying: "" }); + cardRef.current?.setPointerCapture(e.pointerId); }; + const onPointerMove = (e: React.PointerEvent) => { + if (!startRef.current || !drag.active) return; + const x = e.clientX - startRef.current.x; + const y = e.clientY - startRef.current.y; + dragRef.current = { x, y }; + setDrag({ x, y, active: true, flying: "" }); + }; + + const endDrag = () => { + if (!startRef.current) return; + const { x, y } = dragRef.current; + startRef.current = null; + if (Math.abs(x) > SWIPE_THRESHOLD) { + void swipe(x > 0 ? "like" : "dislike"); + return; + } + if (y < SUPER_THRESHOLD) { + void swipe("superlike"); + return; + } + setDrag({ x: 0, y: 0, active: false, flying: "" }); + }; + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (!deck[0] || matchModal || quotaBlocked) return; + if (e.key === "ArrowLeft") void swipe("dislike"); + if (e.key === "ArrowRight") void swipe("like"); + if (e.key === "ArrowUp") void swipe("superlike"); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [deck, matchModal, quotaBlocked, swipe]); + const top = deck[0]; + const next = deck[1]; + const rotate = drag.x * 0.06; + const likeOpacity = Math.min(1, Math.max(0, drag.x / SWIPE_THRESHOLD)); + const nopeOpacity = Math.min(1, Math.max(0, -drag.x / SWIPE_THRESHOLD)); + const superOpacity = Math.min(1, Math.max(0, -drag.y / Math.abs(SUPER_THRESHOLD))); if (!user) { return ( @@ -219,26 +290,62 @@ export default function DatingClient() { )} {top && ( -
-
{top.photo || "🧑‍💻"}
-

{top.name}

-

- {top.location} · {top.gender} {top.single ? `· ${top.single}` : ""} -

-

{top.bio}

-
- {(top.tags || []).map((tag) => ( - - {tag} + <> +

{t.dating.swipeHint}

+
+ {next && ( +
+
{next.photo || "🧑‍💻"}
+

{next.name}

+
+ )} +
+ + LIKE - ))} + + NOPE + + + SUPER + +
{top.photo || "🧑‍💻"}
+

{top.name}

+

+ {top.location} · {top.gender} {top.single ? `· ${top.single}` : ""} +

+

{top.bio}

+
+ {(top.tags || []).map((tag) => ( + + {tag} + + ))} +
+
-
+
@@ -246,7 +353,8 @@ export default function DatingClient() { type="button" className="dating-btn super" onClick={() => void swipe("superlike")} - disabled={Boolean(quota && !quota.vip && quota.remaining <= 0)} + disabled={quotaBlocked} + aria-label={t.dating.swipeUp} > ⭐ @@ -254,7 +362,8 @@ export default function DatingClient() { type="button" className="dating-btn like" onClick={() => void swipe("like")} - disabled={Boolean(quota && !quota.vip && quota.remaining <= 0)} + disabled={quotaBlocked} + aria-label={t.dating.swipeRight} > ♥ @@ -269,7 +378,7 @@ export default function DatingClient() { )}
-
+ )} {!loading && joined && !top && ( diff --git a/frontend/src/lib/i18n/dictionaries.ts b/frontend/src/lib/i18n/dictionaries.ts index 82ed7ff..7868c18 100644 --- a/frontend/src/lib/i18n/dictionaries.ts +++ b/frontend/src/lib/i18n/dictionaries.ts @@ -448,6 +448,10 @@ export const zh = { openVip: "开通 VIP", defaultCity: "全球", defaultBio: "nomadro 游民", + swipeHint: "← 左滑跳过 · 右滑喜欢 → · 上滑超级喜欢(也可点按钮 / 方向键)", + swipeLeft: "跳过", + swipeRight: "喜欢", + swipeUp: "超级喜欢", }, chat: { tag: "✉️ MESSAGES", @@ -1704,6 +1708,10 @@ export const en: { [K in keyof typeof zh]: typeof zh[K] extends string ? string openVip: "Get VIP", defaultCity: "Global", defaultBio: "nomadro nomad", + swipeHint: "← Swipe left to pass · right to like → · up for superlike (or use buttons / arrows)", + swipeLeft: "Pass", + swipeRight: "Like", + swipeUp: "Super like", }, chat: { tag: "✉️ MESSAGES",