diff --git a/frontend/src/components/SpinWheel.tsx b/frontend/src/components/SpinWheel.tsx new file mode 100644 index 0000000..698e252 --- /dev/null +++ b/frontend/src/components/SpinWheel.tsx @@ -0,0 +1,209 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import type { Game } from '@/types' + +interface SpinWheelProps { + games: Game[] + onSpin: () => Promise + onSpinComplete: (game: Game) => void + disabled?: boolean +} + +const ITEM_HEIGHT = 100 +const VISIBLE_ITEMS = 5 +const SPIN_DURATION = 4000 +const EXTRA_ROTATIONS = 3 + +export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheelProps) { + const [isSpinning, setIsSpinning] = useState(false) + const [offset, setOffset] = useState(0) + const containerRef = useRef(null) + const animationRef = useRef(null) + + // Create extended list for seamless looping + const extendedGames = [...games, ...games, ...games, ...games, ...games] + + const handleSpin = useCallback(async () => { + if (isSpinning || disabled || games.length === 0) return + + setIsSpinning(true) + + // Get result from API first + const resultGame = await onSpin() + if (!resultGame) { + setIsSpinning(false) + return + } + + // Find target index + const targetIndex = games.findIndex(g => g.id === resultGame.id) + if (targetIndex === -1) { + setIsSpinning(false) + onSpinComplete(resultGame) + return + } + + // Calculate animation + const totalItems = games.length + const fullRotations = EXTRA_ROTATIONS * totalItems + const finalPosition = (fullRotations + targetIndex) * ITEM_HEIGHT + + // Animate + const startTime = Date.now() + const startOffset = offset % (totalItems * ITEM_HEIGHT) + + const animate = () => { + const elapsed = Date.now() - startTime + const progress = Math.min(elapsed / SPIN_DURATION, 1) + + // Easing function - starts fast, slows down at end + const easeOut = 1 - Math.pow(1 - progress, 4) + + const currentOffset = startOffset + (finalPosition - startOffset) * easeOut + setOffset(currentOffset) + + if (progress < 1) { + animationRef.current = requestAnimationFrame(animate) + } else { + setIsSpinning(false) + onSpinComplete(resultGame) + } + } + + animationRef.current = requestAnimationFrame(animate) + }, [isSpinning, disabled, games, offset, onSpin, onSpinComplete]) + + useEffect(() => { + return () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current) + } + } + }, []) + + if (games.length === 0) { + return ( +
+ Нет доступных игр для прокрутки +
+ ) + } + + const containerHeight = VISIBLE_ITEMS * ITEM_HEIGHT + const currentIndex = Math.round(offset / ITEM_HEIGHT) % games.length + + // Calculate opacity based on distance from center + const getItemOpacity = (itemIndex: number) => { + const itemPosition = itemIndex * ITEM_HEIGHT - offset + const centerPosition = containerHeight / 2 - ITEM_HEIGHT / 2 + const distanceFromCenter = Math.abs(itemPosition - centerPosition) + const maxDistance = containerHeight / 2 + const opacity = Math.max(0, 1 - (distanceFromCenter / maxDistance) * 0.8) + return opacity + } + + return ( +
+ {/* Wheel container */} +
+ {/* Selection indicator */} +
+
+
+
+ + {/* Items container */} +
+
+ {extendedGames.map((game, index) => { + const realIndex = index % games.length + const isSelected = !isSpinning && realIndex === currentIndex + const opacity = getItemOpacity(index) + + return ( +
+ {/* Game cover */} +
+ {game.cover_url ? ( + {game.title} + ) : ( +
+ 🎮 +
+ )} +
+ + {/* Game info */} +
+

+ {game.title} +

+ {game.genre && ( +

{game.genre}

+ )} +
+
+ ) + })} +
+
+
+ + {/* Spin button */} + +
+ ) +} diff --git a/frontend/src/pages/PlayPage.tsx b/frontend/src/pages/PlayPage.tsx index abbeee8..ec77c38 100644 --- a/frontend/src/pages/PlayPage.tsx +++ b/frontend/src/pages/PlayPage.tsx @@ -1,8 +1,9 @@ import { useState, useEffect, useRef } from 'react' import { useParams } from 'react-router-dom' -import { marathonsApi, wheelApi } from '@/api' -import type { Marathon, Assignment, SpinResult } from '@/types' +import { marathonsApi, wheelApi, gamesApi } from '@/api' +import type { Marathon, Assignment, SpinResult, Game } from '@/types' import { Button, Card, CardContent } from '@/components/ui' +import { SpinWheel } from '@/components/SpinWheel' import { Loader2, Upload, X } from 'lucide-react' export function PlayPage() { @@ -11,11 +12,9 @@ export function PlayPage() { const [marathon, setMarathon] = useState(null) const [currentAssignment, setCurrentAssignment] = useState(null) const [spinResult, setSpinResult] = useState(null) + const [games, setGames] = useState([]) const [isLoading, setIsLoading] = useState(true) - // Spin state - const [isSpinning, setIsSpinning] = useState(false) - // Complete state const [proofFile, setProofFile] = useState(null) const [proofUrl, setProofUrl] = useState('') @@ -34,12 +33,14 @@ export function PlayPage() { const loadData = async () => { if (!id) return try { - const [marathonData, assignment] = await Promise.all([ + const [marathonData, assignment, gamesData] = await Promise.all([ marathonsApi.get(parseInt(id)), wheelApi.getCurrentAssignment(parseInt(id)), + gamesApi.list(parseInt(id), 'approved'), ]) setMarathon(marathonData) setCurrentAssignment(assignment) + setGames(gamesData) } catch (error) { console.error('Failed to load data:', error) } finally { @@ -47,24 +48,27 @@ export function PlayPage() { } } - const handleSpin = async () => { - if (!id) return + const handleSpin = async (): Promise => { + if (!id) return null - setIsSpinning(true) - setSpinResult(null) try { const result = await wheelApi.spin(parseInt(id)) setSpinResult(result) - // Reload to get assignment - await loadData() + return result.game } catch (err: unknown) { const error = err as { response?: { data?: { detail?: string } } } alert(error.response?.data?.detail || 'Не удалось крутить') - } finally { - setIsSpinning(false) + return null } } + const handleSpinComplete = async () => { + // Small delay then reload data to show the assignment + setTimeout(async () => { + await loadData() + }, 500) + } + const handleComplete = async () => { if (!currentAssignment) return if (!proofFile && !proofUrl) { @@ -162,17 +166,19 @@ export function PlayPage() {
- {/* No active assignment - show spin */} + {/* No active assignment - show spin wheel */} {!currentAssignment && ( - - -

Крутите колесо!

-

+ + +

Крутите колесо!

+

Получите случайную игру и задание для выполнения

- +
)}