Add swipping mechanic

This commit is contained in:
tonkaew131 2025-07-27 18:07:27 +07:00
parent bde97a3c42
commit e80f7e6a58
3 changed files with 303 additions and 0 deletions

View File

@ -0,0 +1,254 @@
"use client";
import { Button } from "@/components/ui/button";
import { useState, useRef, useEffect } from "react";
interface PollsProps {
choices: {
id: string;
name: string;
picture: string;
distance?: number;
}[];
}
interface SwipeResult {
choiceId: string;
name: string;
action: "like" | "pass";
timestamp: Date;
}
export default function Polls(props: PollsProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
const [results, setResults] = useState<SwipeResult[]>([]);
const cardRef = useRef<HTMLDivElement>(null);
const currentChoice = props.choices[currentIndex];
// Global mouse event listeners
useEffect(() => {
const handleGlobalMouseMove = (e: MouseEvent) => {
if (isDragging) {
handleMove(e.clientX, e.clientY);
}
};
const handleGlobalMouseUp = () => {
if (isDragging) {
handleEnd();
}
};
if (isDragging) {
document.addEventListener("mousemove", handleGlobalMouseMove);
document.addEventListener("mouseup", handleGlobalMouseUp);
}
return () => {
document.removeEventListener("mousemove", handleGlobalMouseMove);
document.removeEventListener("mouseup", handleGlobalMouseUp);
};
}, [isDragging, dragOffset.x]);
// Handle swipe completion
const handleSwipe = (direction: "like" | "pass") => {
if (!currentChoice || isAnimating) return;
const result: SwipeResult = {
choiceId: currentChoice.id,
name: currentChoice.name,
action: direction,
timestamp: new Date(),
};
setResults((prev) => [...prev, result]);
setIsAnimating(true);
// Animate out
setDragOffset({
x: direction === "like" ? 300 : -300,
y: 0,
});
setTimeout(() => {
if (currentIndex >= props.choices.length - 1) {
// All choices completed
const finalResults = [...results, result];
console.log("🎉 Polling Complete! Final Results:", finalResults);
console.log("📊 Summary:", {
totalChoices: finalResults.length,
likes: finalResults.filter((r) => r.action === "like").length,
passes: finalResults.filter((r) => r.action === "pass").length,
choices: finalResults.map((r) => ({
name: r.name,
action: r.action,
})),
});
// Move beyond the last index to trigger completion state
setCurrentIndex(props.choices.length);
} else {
setCurrentIndex((prev) => prev + 1);
}
setDragOffset({ x: 0, y: 0 });
setIsAnimating(false);
}, 300);
};
// Mouse/Touch event handlers
const handleStart = () => {
if (isAnimating) return;
setIsDragging(true);
};
const handleMove = (clientX: number, clientY: number) => {
if (!isDragging || isAnimating) return;
const rect = cardRef.current?.getBoundingClientRect();
if (!rect) return;
const x = clientX - rect.left - rect.width / 2;
const y = clientY - rect.top - rect.height / 2;
setDragOffset({ x: x * 0.5, y: y * 0.1 });
};
const handleEnd = () => {
if (!isDragging || isAnimating) return;
setIsDragging(false);
const threshold = 80;
if (Math.abs(dragOffset.x) > threshold) {
handleSwipe(dragOffset.x > 0 ? "like" : "pass");
} else {
// Snap back
setDragOffset({ x: 0, y: 0 });
}
};
// Mouse events
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
handleStart();
};
// Touch events
const handleTouchStart = (e: React.TouchEvent) => {
e.preventDefault();
handleStart();
};
const handleTouchMove = (e: React.TouchEvent) => {
e.preventDefault();
const touch = e.touches[0];
handleMove(touch.clientX, touch.clientY);
};
const handleTouchEnd = (e: React.TouchEvent) => {
e.preventDefault();
handleEnd();
};
// Button handlers
const handleLike = () => handleSwipe("like");
const handlePass = () => handleSwipe("pass");
if (!currentChoice) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center text-white">
<h2 className="text-2xl font-bold mb-4">! 🎉</h2>
<p className="text-lg"> !</p>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col relative">
<div
ref={cardRef}
className="h-full absolute inset-0 cursor-grab active:cursor-grabbing"
style={{
transform: `translate(${dragOffset.x}px, ${dragOffset.y}px) rotate(${
dragOffset.x * 0.1
}deg)`,
transition: isDragging ? "none" : "transform 0.3s ease-out",
opacity: Math.abs(dragOffset.x) > 150 ? 0.5 : 1,
}}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<picture>
<img
className="h-full w-full object-cover"
src={currentChoice.picture}
alt={currentChoice.name}
draggable={false}
/>
</picture>
</div>
{/* Swipe indicators */}
{Math.abs(dragOffset.x) > 50 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-40">
<div
className={`text-6xl font-bold border-4 rounded-lg p-4 ${
dragOffset.x > 0
? "text-green-500 border-green-500 bg-green-500/20"
: "text-red-500 border-red-500 bg-red-500/20"
}`}
style={{
opacity: Math.min(Math.abs(dragOffset.x) / 150, 1),
transform: `rotate(${dragOffset.x > 0 ? -15 : 15}deg)`,
}}
>
{dragOffset.x > 0 ? "LIKE" : "PASS"}
</div>
</div>
)}
<div
className="absolute bottom-0 left-0 w-full h-[50%] pointer-events-none"
style={{
background:
"linear-gradient(180deg,rgba(42, 123, 155, 1) 0%, rgba(255, 255, 255, 0) 0%, rgba(36, 36, 36, 1) 66%, rgba(0, 0, 0, 1) 100%)",
}}
></div>
<div className="flex flex-col z-50 my-auto mb-0 gap-4 pointer-events-none">
<div>
<h1 className="text-2xl font-bold text-white text-center">
{currentChoice.name}
</h1>
<p className="text-sm text-white/70 text-center mt-2">
{currentIndex + 1} of {props.choices.length}
</p>
</div>
<div className="flex items-center gap-24 justify-center pointer-events-auto">
<Button
size="icon"
className="size-16 text-2xl rounded-full hover:scale-110 transition-transform"
variant="destructive"
onClick={handlePass}
disabled={isAnimating}
>
</Button>
<Button
size="icon"
className="size-16 text-2xl rounded-full hover:scale-110 transition-transform"
onClick={handleLike}
disabled={isAnimating}
>
</Button>
</div>
</div>
</div>
);
}

View File

@ -2,6 +2,7 @@ import { Button } from "@/components/ui/button";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
import { ChevronLeftIcon } from "lucide-react"; import { ChevronLeftIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import Polls from "./_components/polls";
interface PageProps { interface PageProps {
params: Promise<{ id: string; memberId: string }>; params: Promise<{ id: string; memberId: string }>;
@ -16,6 +17,10 @@ export default async function Page(props: PageProps) {
.select() .select()
.eq("poll_id", id) .eq("poll_id", id)
.eq("id", parseInt(memberId)); .eq("id", parseInt(memberId));
const { data: choices } = await supabase
.from("poll_choices")
.select()
.eq("poll_id", id);
if (!polls || polls.length === 0) { if (!polls || polls.length === 0) {
return <div className="max-w-6xl mx-auto py-2 w-[95%]">Poll not found</div>; return <div className="max-w-6xl mx-auto py-2 w-[95%]">Poll not found</div>;
@ -43,6 +48,15 @@ export default async function Page(props: PageProps) {
</h1> </h1>
<Button>{member.name}</Button> <Button>{member.name}</Button>
</div> </div>
<Polls
choices={(choices || []).map((c) => ({
id: c.id,
name: c.name,
picture: c.picture,
distance: c.distance || undefined,
}))}
/>
</main> </main>
); );
} }

View File

@ -14,6 +14,41 @@ export type Database = {
} }
public: { public: {
Tables: { Tables: {
poll_choices: {
Row: {
created_at: string
distance: number | null
id: string
name: string
picture: string
poll_id: string
}
Insert: {
created_at?: string
distance?: number | null
id?: string
name: string
picture: string
poll_id: string
}
Update: {
created_at?: string
distance?: number | null
id?: string
name?: string
picture?: string
poll_id?: string
}
Relationships: [
{
foreignKeyName: "poll_choices_poll_id_fkey"
columns: ["poll_id"]
isOneToOne: false
referencedRelation: "polls"
referencedColumns: ["id"]
},
]
}
poll_members: { poll_members: {
Row: { Row: {
created_at: string created_at: string