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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user