Add swipping mechanic
This commit is contained in:
parent
bde97a3c42
commit
e80f7e6a58
254
app/polls/[id]/members/[memberId]/_components/polls/index.tsx
Normal file
254
app/polls/[id]/members/[memberId]/_components/polls/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -2,6 +2,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { ChevronLeftIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import Polls from "./_components/polls";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string; memberId: string }>;
|
||||
@ -16,6 +17,10 @@ export default async function Page(props: PageProps) {
|
||||
.select()
|
||||
.eq("poll_id", id)
|
||||
.eq("id", parseInt(memberId));
|
||||
const { data: choices } = await supabase
|
||||
.from("poll_choices")
|
||||
.select()
|
||||
.eq("poll_id", id);
|
||||
|
||||
if (!polls || polls.length === 0) {
|
||||
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>
|
||||
<Button>{member.name}</Button>
|
||||
</div>
|
||||
|
||||
<Polls
|
||||
choices={(choices || []).map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
picture: c.picture,
|
||||
distance: c.distance || undefined,
|
||||
}))}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@ -14,6 +14,41 @@ export type Database = {
|
||||
}
|
||||
public: {
|
||||
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: {
|
||||
Row: {
|
||||
created_at: string
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user