feat: 实现 NoWhatever 别说随便餐厅决策 Web App
- Framer Motion 卡片滑动 UI,带物理阻尼动画 - 多人房间系统,4位房间号 + SWR 实时轮询 - 高德地图 POI v5 API 搜索附近餐厅 - Web Share API 一键邀请,剪贴板降级方案 - SQLite/Prisma 持久化存储 - 移动端优先响应式设计 (Tailwind CSS)
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import SwipeableCard from "./SwipeableCard";
|
||||
import ActionButtons from "./ActionButtons";
|
||||
import MatchResult from "./MatchResult";
|
||||
import { Restaurant, SwipeDirection } from "@/types";
|
||||
|
||||
interface SwipeDeckProps {
|
||||
restaurants: Restaurant[];
|
||||
roomId: string;
|
||||
userId: string;
|
||||
matchedRestaurantId: string | null;
|
||||
}
|
||||
|
||||
export default function SwipeDeck({
|
||||
restaurants,
|
||||
roomId,
|
||||
userId,
|
||||
matchedRestaurantId,
|
||||
}: SwipeDeckProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [showMatch, setShowMatch] = useState(false);
|
||||
const [localMatchId, setLocalMatchId] = useState<string | null>(null);
|
||||
const swipeFnRef = useRef<((direction: SwipeDirection) => void) | null>(null);
|
||||
const swipingRef = useRef(false);
|
||||
|
||||
const resolvedMatchId = matchedRestaurantId ?? localMatchId;
|
||||
|
||||
useEffect(() => {
|
||||
if (matchedRestaurantId != null && !showMatch) {
|
||||
setShowMatch(true);
|
||||
}
|
||||
}, [matchedRestaurantId, showMatch]);
|
||||
|
||||
const registerSwipe = useCallback(
|
||||
(fn: (direction: SwipeDirection) => void) => {
|
||||
swipeFnRef.current = fn;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const sendSwipe = async (restaurantId: string, action: "like" | "nope") => {
|
||||
try {
|
||||
const res = await fetch(`/api/room/${roomId}/swipe`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId, restaurantId, action }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.match != null) {
|
||||
setLocalMatchId(data.match);
|
||||
}
|
||||
} catch {
|
||||
// Polling will catch match state
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwipe = useCallback(
|
||||
(direction: SwipeDirection) => {
|
||||
const current = restaurants[currentIndex];
|
||||
if (!current) return;
|
||||
|
||||
swipingRef.current = false;
|
||||
|
||||
const action = direction === "right" ? "like" : "nope";
|
||||
sendSwipe(current.id, action);
|
||||
|
||||
const nextIndex = currentIndex + 1;
|
||||
setCurrentIndex(nextIndex);
|
||||
swipeFnRef.current = null;
|
||||
|
||||
if (nextIndex >= restaurants.length && !resolvedMatchId) {
|
||||
setTimeout(() => {
|
||||
if (!showMatch) setShowMatch(true);
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[currentIndex, restaurants, roomId, userId, resolvedMatchId, showMatch],
|
||||
);
|
||||
|
||||
const handleButtonAction = useCallback(
|
||||
(direction: SwipeDirection) => {
|
||||
if (swipeFnRef.current && !swipingRef.current) {
|
||||
swipingRef.current = true;
|
||||
swipeFnRef.current(direction);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setCurrentIndex(0);
|
||||
setShowMatch(false);
|
||||
setLocalMatchId(null);
|
||||
}, []);
|
||||
|
||||
const isDone = currentIndex >= restaurants.length || resolvedMatchId != null;
|
||||
|
||||
const matchRestaurant = resolvedMatchId
|
||||
? restaurants.find((r) => r.id === resolvedMatchId) ?? restaurants[0]
|
||||
: restaurants[0];
|
||||
|
||||
const showWaiting =
|
||||
currentIndex >= restaurants.length && !resolvedMatchId && !showMatch;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex flex-1 items-center justify-center px-4">
|
||||
<div className="relative h-[70vh] w-full max-w-sm">
|
||||
{!resolvedMatchId && (
|
||||
<AnimatePresence>
|
||||
{restaurants.map((restaurant, index) => {
|
||||
if (index < currentIndex || index > currentIndex + 1)
|
||||
return null;
|
||||
const isTop = index === currentIndex;
|
||||
return (
|
||||
<SwipeableCard
|
||||
key={restaurant.id}
|
||||
restaurant={restaurant}
|
||||
isTop={isTop}
|
||||
onSwipe={handleSwipe}
|
||||
registerSwipe={isTop ? registerSwipe : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
|
||||
{showWaiting && (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-zinc-300 border-t-emerald-500" />
|
||||
<p className="text-sm text-zinc-400">等待其他人完成选择...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ActionButtons onAction={handleButtonAction} disabled={isDone} />
|
||||
|
||||
{showMatch && matchRestaurant && (
|
||||
<MatchResult restaurant={matchRestaurant} onReset={handleReset} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user