feat: 实现 NoWhatever 别说随便餐厅决策 Web App

- Framer Motion 卡片滑动 UI,带物理阻尼动画
- 多人房间系统,4位房间号 + SWR 实时轮询
- 高德地图 POI v5 API 搜索附近餐厅
- Web Share API 一键邀请,剪贴板降级方案
- SQLite/Prisma 持久化存储
- 移动端优先响应式设计 (Tailwind CSS)
This commit is contained in:
2026-02-24 16:49:43 +08:00
parent f5d921d585
commit d87d30ccc0
37 changed files with 8680 additions and 84 deletions
+114
View File
@@ -0,0 +1,114 @@
"use client";
import { useRef } from "react";
import {
motion,
useMotionValue,
useTransform,
animate,
PanInfo,
MotionValue,
} from "framer-motion";
import RestaurantCard from "./RestaurantCard";
import { Restaurant, SwipeDirection } from "@/types";
const SWIPE_THRESHOLD = 120;
const EXIT_X = 600;
const ROTATION_RANGE = 18;
interface SwipeableCardProps {
restaurant: Restaurant;
isTop: boolean;
onSwipe: (direction: SwipeDirection) => void;
registerSwipe?: (fn: (direction: SwipeDirection) => void) => void;
}
function SwipeOverlay({ x }: { x: MotionValue<number> }) {
const likeOpacity = useTransform(x, [0, SWIPE_THRESHOLD], [0, 1]);
const nopeOpacity = useTransform(x, [-SWIPE_THRESHOLD, 0], [1, 0]);
return (
<>
<motion.div
className="pointer-events-none absolute inset-0 z-10 flex items-start justify-start rounded-2xl border-4 border-emerald-400 p-6"
style={{ opacity: likeOpacity }}
>
<span className="rounded-lg border-3 border-emerald-400 px-3 py-1 text-2xl font-extrabold tracking-wide text-emerald-400">
LIKE
</span>
</motion.div>
<motion.div
className="pointer-events-none absolute inset-0 z-10 flex items-start justify-end rounded-2xl border-4 border-rose-400 p-6"
style={{ opacity: nopeOpacity }}
>
<span className="rounded-lg border-3 border-rose-400 px-3 py-1 text-2xl font-extrabold tracking-wide text-rose-400">
NOPE
</span>
</motion.div>
</>
);
}
export default function SwipeableCard({
restaurant,
isTop,
onSwipe,
registerSwipe,
}: SwipeableCardProps) {
const x = useMotionValue(0);
const rotate = useTransform(x, [-300, 300], [-ROTATION_RANGE, ROTATION_RANGE]);
const opacity = useTransform(x, [-300, -100, 0, 100, 300], [0.5, 1, 1, 1, 0.5]);
const isSwiping = useRef(false);
const flyOut = (direction: SwipeDirection) => {
if (isSwiping.current) return;
isSwiping.current = true;
const exitX = direction === "right" ? EXIT_X : -EXIT_X;
animate(x, exitX, {
type: "spring",
stiffness: 600,
damping: 40,
onComplete: () => onSwipe(direction),
});
};
if (registerSwipe) {
registerSwipe(flyOut);
}
const handleDragEnd = (_: unknown, info: PanInfo) => {
const offsetX = info.offset.x;
if (offsetX > SWIPE_THRESHOLD) {
flyOut("right");
} else if (offsetX < -SWIPE_THRESHOLD) {
flyOut("left");
} else {
animate(x, 0, { type: "spring", stiffness: 500, damping: 30 });
}
};
return (
<motion.div
className="absolute inset-0"
style={{
x,
rotate,
opacity,
zIndex: isTop ? 10 : 0,
cursor: isTop ? "grab" : "default",
}}
drag={isTop ? "x" : false}
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.9}
onDragEnd={handleDragEnd}
whileDrag={{ cursor: "grabbing" }}
initial={isTop ? { scale: 1 } : { scale: 0.95, y: 16 }}
animate={isTop ? { scale: 1, y: 0 } : { scale: 0.95, y: 16 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
>
<SwipeOverlay x={x} />
<RestaurantCard restaurant={restaurant} />
</motion.div>
);
}