From 790b2d608385f7866ba6dcf86044d78891745372 Mon Sep 17 00:00:00 2001 From: Oronemu Date: Wed, 17 Dec 2025 20:59:47 +0700 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=9E=D0=A7=D0=A2=D0=98=20=D0=93=D0=9E?= =?UTF-8?q?=D0=A2=D0=9E=D0=92=D0=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 6 + frontend/src/components/SpinWheel.tsx | 469 ++++++++++++---------- frontend/src/components/ui/NeonButton.tsx | 14 +- frontend/src/pages/PlayPage.tsx | 3 +- frontend/src/pages/TeapotPage.tsx | 192 +++++++++ frontend/src/pages/index.ts | 1 + 6 files changed, 479 insertions(+), 206 deletions(-) create mode 100644 frontend/src/pages/TeapotPage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5ca5940..f28c9ca 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,6 +20,7 @@ import { AssignmentDetailPage } from '@/pages/AssignmentDetailPage' import { ProfilePage } from '@/pages/ProfilePage' import { UserProfilePage } from '@/pages/UserProfilePage' import { NotFoundPage } from '@/pages/NotFoundPage' +import { TeapotPage } from '@/pages/TeapotPage' // Protected route wrapper function ProtectedRoute({ children }: { children: React.ReactNode }) { @@ -148,6 +149,11 @@ function App() { } /> + {/* Easter egg - 418 I'm a teapot */} + } /> + } /> + } /> + {/* 404 - must be last */} } /> diff --git a/frontend/src/components/SpinWheel.tsx b/frontend/src/components/SpinWheel.tsx index 2fbbead..4c2ffde 100644 --- a/frontend/src/components/SpinWheel.tsx +++ b/frontend/src/components/SpinWheel.tsx @@ -1,7 +1,6 @@ -import { useState, useEffect, useRef, useCallback } from 'react' +import { useState, useCallback, useMemo } from 'react' import type { Game } from '@/types' -import { Gamepad2, Loader2, Sparkles } from 'lucide-react' -import { NeonButton } from './ui' +import { Gamepad2, Loader2 } from 'lucide-react' interface SpinWheelProps { games: Game[] @@ -10,33 +9,80 @@ interface SpinWheelProps { disabled?: boolean } -const ITEM_HEIGHT = 100 -const VISIBLE_ITEMS = 5 -const SPIN_DURATION = 4000 -const EXTRA_ROTATIONS = 3 +const SPIN_DURATION = 5000 // ms +const EXTRA_ROTATIONS = 5 + +// Цветовая палитра секторов +const SECTOR_COLORS = [ + { bg: '#0d9488', border: '#14b8a6' }, // teal + { bg: '#7c3aed', border: '#8b5cf6' }, // violet + { bg: '#0891b2', border: '#06b6d4' }, // cyan + { bg: '#c026d3', border: '#d946ef' }, // fuchsia + { bg: '#059669', border: '#10b981' }, // emerald + { bg: '#7c2d12', border: '#ea580c' }, // orange + { bg: '#1d4ed8', border: '#3b82f6' }, // blue + { bg: '#be123c', border: '#e11d48' }, // rose +] 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) + const [rotation, setRotation] = useState(0) - // Create extended list for seamless looping - const extendedGames = [...games, ...games, ...games, ...games, ...games] + // Размеры колеса + const wheelSize = 400 + const centerX = wheelSize / 2 + const centerY = wheelSize / 2 + const radius = wheelSize / 2 - 10 + + // Рассчитываем углы секторов + const sectorAngle = games.length > 0 ? 360 / games.length : 360 + + // Создаём path для сектора + const createSectorPath = useCallback((index: number, total: number) => { + const angle = 360 / total + const startAngle = index * angle - 90 // Начинаем сверху + const endAngle = startAngle + angle + + const startRad = (startAngle * Math.PI) / 180 + const endRad = (endAngle * Math.PI) / 180 + + const x1 = centerX + radius * Math.cos(startRad) + const y1 = centerY + radius * Math.sin(startRad) + const x2 = centerX + radius * Math.cos(endRad) + const y2 = centerY + radius * Math.sin(endRad) + + const largeArcFlag = angle > 180 ? 1 : 0 + + return `M ${centerX} ${centerY} L ${x1} ${y1} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${x2} ${y2} Z` + }, [centerX, centerY, radius]) + + // Позиция текста в секторе + const getTextPosition = useCallback((index: number, total: number) => { + const angle = 360 / total + const midAngle = index * angle + angle / 2 - 90 + const midRad = (midAngle * Math.PI) / 180 + const textRadius = radius * 0.65 + + return { + x: centerX + textRadius * Math.cos(midRad), + y: centerY + textRadius * Math.sin(midRad), + rotation: midAngle + 90, // Текст читается от центра к краю + } + }, [centerX, centerY, radius]) const handleSpin = useCallback(async () => { if (isSpinning || disabled || games.length === 0) return setIsSpinning(true) - // Get result from API first + // Получаем результат от API 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) @@ -44,43 +90,44 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel return } - // Calculate animation - const totalItems = games.length - const fullRotations = EXTRA_ROTATIONS * totalItems - const finalPosition = (fullRotations + targetIndex) * ITEM_HEIGHT + // Рассчитываем угол для остановки + // Указатель находится сверху (на 0°/360°) + // Нам нужно чтобы нужный сектор оказался под указателем + const targetSectorMidAngle = targetIndex * sectorAngle + sectorAngle / 2 - // Animate - const startTime = Date.now() - const startOffset = offset % (totalItems * ITEM_HEIGHT) + // Полные обороты + угол до центра сектора + // Колесо крутится по часовой стрелке, указатель сверху + // Чтобы сектор оказался сверху, нужно повернуть на (360 - targetSectorMidAngle) + const baseRotation = rotation % 360 + const fullRotations = EXTRA_ROTATIONS * 360 + const finalAngle = fullRotations + (360 - targetSectorMidAngle) + baseRotation - const animate = () => { - const elapsed = Date.now() - startTime - const progress = Math.min(elapsed / SPIN_DURATION, 1) + setRotation(rotation + finalAngle) - // Easing function - starts fast, slows down at end - const easeOut = 1 - Math.pow(1 - progress, 4) + // Ждём окончания анимации + setTimeout(() => { + setIsSpinning(false) + onSpinComplete(resultGame) + }, SPIN_DURATION) + }, [isSpinning, disabled, games, rotation, sectorAngle, onSpin, onSpinComplete]) - const currentOffset = startOffset + (finalPosition - startOffset) * easeOut - setOffset(currentOffset) + // Сокращаем название игры для отображения + const truncateText = (text: string, maxLength: number) => { + if (text.length <= maxLength) return text + return text.slice(0, maxLength - 2) + '...' + } - if (progress < 1) { - animationRef.current = requestAnimationFrame(animate) - } else { - setIsSpinning(false) - onSpinComplete(resultGame) - } - } + // Мемоизируем секторы для производительности + const sectors = useMemo(() => { + return games.map((game, index) => { + const color = SECTOR_COLORS[index % SECTOR_COLORS.length] + const path = createSectorPath(index, games.length) + const textPos = getTextPosition(index, games.length) + const maxTextLength = games.length > 8 ? 10 : games.length > 5 ? 14 : 18 - animationRef.current = requestAnimationFrame(animate) - }, [isSpinning, disabled, games, offset, onSpin, onSpinComplete]) - - useEffect(() => { - return () => { - if (animationRef.current) { - cancelAnimationFrame(animationRef.current) - } - } - }, []) + return { game, color, path, textPos, maxTextLength } + }) + }, [games, createSectorPath, getTextPosition]) if (games.length === 0) { return ( @@ -93,175 +140,195 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel ) } - const containerHeight = VISIBLE_ITEMS * ITEM_HEIGHT - const currentIndex = Math.round(offset / ITEM_HEIGHT) % games.length - - // Calculate opacity and scale based on distance from center - const getItemStyle = (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 normalizedDistance = distanceFromCenter / maxDistance - - const opacity = Math.max(0.15, 1 - normalizedDistance * 0.85) - const scale = Math.max(0.85, 1 - normalizedDistance * 0.15) - - return { opacity, scale } - } - return (
- {/* Wheel container */} -
- {/* Outer glow effect */} -
+ {/* Контейнер колеса */} +
+ {/* Внешнее свечение */} +
- {/* Main container with glass effect */} -
- {/* Selection indicator - center highlight */} -
- {/* Neon border glow */} -
- - {/* Side arrows */} -
-
-
-
-
-
- - {/* Inner glow */} -
-
- - {/* Top fade gradient */} -
- - {/* Bottom fade gradient */} -
- - {/* Items container */} -
-
- {extendedGames.map((game, index) => { - const realIndex = index % games.length - const isSelected = !isSpinning && realIndex === currentIndex - const { opacity, scale } = getItemStyle(index) - - return ( -
-
- {/* Game cover */} -
- {game.cover_url ? ( - {game.title} - ) : ( -
- -
- )} -
- - {/* Game info */} -
-

- {game.title} -

- {game.genre && ( -

{game.genre}

- )} -
- - {/* Selected indicator */} - {isSelected && ( -
- -
- )} -
-
- ) - })} + {/* Указатель (стрелка сверху) */} +
+
+ {/* Свечение указателя */} +
+ + +
+ {/* Указатель */} + + + + + + + + +
- {/* Spinning indicator lines */} + {/* Колесо */} +
+ {/* Внешний ободок */} +
+ + {/* SVG колесо */} + + + {/* Тени для секторов */} + + + + + + {/* Секторы */} + {sectors.map(({ game, color, path, textPos, maxTextLength }, index) => ( + + {/* Сектор */} + + + {/* Текст названия игры */} + 10 ? "10" : games.length > 6 ? "11" : "13"} + fontWeight="bold" + style={{ + textShadow: '0 1px 3px rgba(0,0,0,0.8)', + pointerEvents: 'none', + }} + > + {truncateText(game.title, maxTextLength)} + + + {/* Разделительная линия */} + + + ))} + + {/* Центральный круг */} + + + + + + + + + + {/* Кнопка КРУТИТЬ в центре */} + +
+ + {/* Декоративные элементы при вращении */} {isSpinning && ( <> -
-
-
+
+
)}
- {/* Spin button */} - : } - > - {isSpinning ? 'Крутится...' : 'КРУТИТЬ!'} - + {/* Подсказка */} +

+ {isSpinning ? 'Колесо вращается...' : 'Нажмите на колесо, чтобы крутить!'} +

) } diff --git a/frontend/src/components/ui/NeonButton.tsx b/frontend/src/components/ui/NeonButton.tsx index dfc12cb..4eb5436 100644 --- a/frontend/src/components/ui/NeonButton.tsx +++ b/frontend/src/components/ui/NeonButton.tsx @@ -40,6 +40,8 @@ export const NeonButton = forwardRef( danger: 'bg-red-600 hover:bg-red-700 text-white', glow: '0 0 12px rgba(34, 211, 238, 0.4)', glowHover: '0 0 18px rgba(34, 211, 238, 0.55)', + glowDanger: '0 0 12px rgba(239, 68, 68, 0.4)', + glowDangerHover: '0 0 18px rgba(239, 68, 68, 0.55)', }, purple: { primary: 'bg-accent-500 hover:bg-accent-400 text-white', @@ -49,6 +51,8 @@ export const NeonButton = forwardRef( danger: 'bg-red-600 hover:bg-red-700 text-white', glow: '0 0 12px rgba(139, 92, 246, 0.4)', glowHover: '0 0 18px rgba(139, 92, 246, 0.55)', + glowDanger: '0 0 12px rgba(239, 68, 68, 0.4)', + glowDangerHover: '0 0 18px rgba(239, 68, 68, 0.55)', }, pink: { primary: 'bg-pink-500 hover:bg-pink-400 text-white', @@ -58,6 +62,8 @@ export const NeonButton = forwardRef( danger: 'bg-red-600 hover:bg-red-700 text-white', glow: '0 0 12px rgba(244, 114, 182, 0.4)', glowHover: '0 0 18px rgba(244, 114, 182, 0.55)', + glowDanger: '0 0 12px rgba(239, 68, 68, 0.4)', + glowDangerHover: '0 0 18px rgba(239, 68, 68, 0.55)', }, } @@ -93,17 +99,19 @@ export const NeonButton = forwardRef( className )} style={{ - boxShadow: glow && !disabled && variant !== 'ghost' ? colors.glow : undefined, + boxShadow: glow && !disabled && variant !== 'ghost' + ? (variant === 'danger' ? colors.glowDanger : colors.glow) + : undefined, }} onMouseEnter={(e) => { if (glow && !disabled && variant !== 'ghost') { - e.currentTarget.style.boxShadow = colors.glowHover + e.currentTarget.style.boxShadow = variant === 'danger' ? colors.glowDangerHover : colors.glowHover } props.onMouseEnter?.(e) }} onMouseLeave={(e) => { if (glow && !disabled && variant !== 'ghost') { - e.currentTarget.style.boxShadow = colors.glow + e.currentTarget.style.boxShadow = variant === 'danger' ? colors.glowDanger : colors.glow } props.onMouseLeave?.(e) }} diff --git a/frontend/src/pages/PlayPage.tsx b/frontend/src/pages/PlayPage.tsx index 8dde144..05b66a9 100644 --- a/frontend/src/pages/PlayPage.tsx +++ b/frontend/src/pages/PlayPage.tsx @@ -1006,8 +1006,7 @@ export function PlayPage() { Выполнено } diff --git a/frontend/src/pages/TeapotPage.tsx b/frontend/src/pages/TeapotPage.tsx new file mode 100644 index 0000000..103756c --- /dev/null +++ b/frontend/src/pages/TeapotPage.tsx @@ -0,0 +1,192 @@ +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { NeonButton } from '@/components/ui' +import { Home, Sparkles, Coffee } from 'lucide-react' + +export function TeapotPage() { + const [isPoured, setIsPoured] = useState(false) + + return ( +
+ {/* Background effects */} +
+
+
+
+ + {/* Teapot ASCII art / SVG */} +
setIsPoured(!isPoured)} + > + {/* Steam animation */} +
+
+
+
+
+ + {/* Teapot */} + + {/* Body */} + + + {/* Lid */} + + + + + {/* Spout */} + + + + {/* Handle */} + + + + {/* Face */} + {/* Left eye */} + {/* Right eye */} + {/* Left eye shine */} + {/* Right eye shine */} + {/* Smile */} + + {/* Blush */} + + + + {/* Gradients */} + + + + + + + + + + + + + + {/* Tea drops when pouring */} + {isPoured && ( +
+
+
+
+
+ )} + + {/* Glow effect */} +
+
+ + {/* 418 text */} +
+

+ 418 +

+
+ 418 +
+
+ +

+ I'm a teapot +

+ +

+ Сервер отказывается варить кофе, потому что он чайник. +

+

+ RFC 2324, Hyper Text Coffee Pot Control Protocol +

+ + {/* Fun fact */} +
+
+ + Fun fact +
+

+ Это настоящий HTTP-код ответа из первоапрельской шутки 1998 года. + Нажми на чайник! +

+
+ + {/* Button */} + + }> + На главную + + + + {/* Decorative sparkles */} +
+ +
+
+ +
+ + {/* Custom animations */} + +
+ ) +} diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts index bcbc7a0..3c1da62 100644 --- a/frontend/src/pages/index.ts +++ b/frontend/src/pages/index.ts @@ -10,3 +10,4 @@ export { LeaderboardPage } from './LeaderboardPage' export { ProfilePage } from './ProfilePage' export { UserProfilePage } from './UserProfilePage' export { NotFoundPage } from './NotFoundPage' +export { TeapotPage } from './TeapotPage'