Add modals
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
import { ToastContainer, ConfirmModal } from '@/components/ui'
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
import { Layout } from '@/components/layout/Layout'
|
import { Layout } from '@/components/layout/Layout'
|
||||||
@@ -41,6 +42,9 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<ToastContainer />
|
||||||
|
<ConfirmModal />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Layout />}>
|
<Route path="/" element={<Layout />}>
|
||||||
<Route index element={<HomePage />} />
|
<Route index element={<HomePage />} />
|
||||||
@@ -130,6 +134,7 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { Button } from '@/components/ui'
|
|||||||
import { eventsApi } from '@/api'
|
import { eventsApi } from '@/api'
|
||||||
import type { ActiveEvent, EventType, Challenge } from '@/types'
|
import type { ActiveEvent, EventType, Challenge } from '@/types'
|
||||||
import { EVENT_INFO } from '@/types'
|
import { EVENT_INFO } from '@/types'
|
||||||
|
import { useToast } from '@/store/toast'
|
||||||
|
import { useConfirm } from '@/store/confirm'
|
||||||
|
|
||||||
interface EventControlProps {
|
interface EventControlProps {
|
||||||
marathonId: number
|
marathonId: number
|
||||||
@@ -36,6 +38,8 @@ export function EventControl({
|
|||||||
challenges,
|
challenges,
|
||||||
onEventChange,
|
onEventChange,
|
||||||
}: EventControlProps) {
|
}: EventControlProps) {
|
||||||
|
const toast = useToast()
|
||||||
|
const confirm = useConfirm()
|
||||||
const [selectedType, setSelectedType] = useState<EventType>('golden_hour')
|
const [selectedType, setSelectedType] = useState<EventType>('golden_hour')
|
||||||
const [selectedChallengeId, setSelectedChallengeId] = useState<number | null>(null)
|
const [selectedChallengeId, setSelectedChallengeId] = useState<number | null>(null)
|
||||||
const [isStarting, setIsStarting] = useState(false)
|
const [isStarting, setIsStarting] = useState(false)
|
||||||
@@ -43,7 +47,7 @@ export function EventControl({
|
|||||||
|
|
||||||
const handleStart = async () => {
|
const handleStart = async () => {
|
||||||
if (selectedType === 'common_enemy' && !selectedChallengeId) {
|
if (selectedType === 'common_enemy' && !selectedChallengeId) {
|
||||||
alert('Выберите челлендж для события "Общий враг"')
|
toast.warning('Выберите челлендж для события "Общий враг"')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,14 +60,21 @@ export function EventControl({
|
|||||||
onEventChange()
|
onEventChange()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to start event:', error)
|
console.error('Failed to start event:', error)
|
||||||
alert('Не удалось запустить событие')
|
toast.error('Не удалось запустить событие')
|
||||||
} finally {
|
} finally {
|
||||||
setIsStarting(false)
|
setIsStarting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleStop = async () => {
|
const handleStop = async () => {
|
||||||
if (!confirm('Остановить событие досрочно?')) return
|
const confirmed = await confirm({
|
||||||
|
title: 'Остановить событие?',
|
||||||
|
message: 'Событие будет завершено досрочно.',
|
||||||
|
confirmText: 'Остановить',
|
||||||
|
cancelText: 'Отмена',
|
||||||
|
variant: 'warning',
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
setIsStopping(true)
|
setIsStopping(true)
|
||||||
try {
|
try {
|
||||||
|
|||||||
111
frontend/src/components/ui/ConfirmModal.tsx
Normal file
111
frontend/src/components/ui/ConfirmModal.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { AlertTriangle, Info, Trash2, X } from 'lucide-react'
|
||||||
|
import { clsx } from 'clsx'
|
||||||
|
import { useConfirmStore, type ConfirmVariant } from '@/store/confirm'
|
||||||
|
import { Button } from './Button'
|
||||||
|
|
||||||
|
const icons: Record<ConfirmVariant, React.ReactNode> = {
|
||||||
|
danger: <Trash2 className="w-6 h-6" />,
|
||||||
|
warning: <AlertTriangle className="w-6 h-6" />,
|
||||||
|
info: <Info className="w-6 h-6" />,
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconStyles: Record<ConfirmVariant, string> = {
|
||||||
|
danger: 'bg-red-500/20 text-red-500',
|
||||||
|
warning: 'bg-yellow-500/20 text-yellow-500',
|
||||||
|
info: 'bg-blue-500/20 text-blue-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttonVariants: Record<ConfirmVariant, 'danger' | 'primary' | 'secondary'> = {
|
||||||
|
danger: 'danger',
|
||||||
|
warning: 'primary',
|
||||||
|
info: 'primary',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmModal() {
|
||||||
|
const { isOpen, options, handleConfirm, handleCancel } = useConfirmStore()
|
||||||
|
|
||||||
|
// Handle escape key
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && isOpen) {
|
||||||
|
handleCancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [isOpen, handleCancel])
|
||||||
|
|
||||||
|
// Prevent body scroll when modal is open
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
if (!isOpen || !options) return null
|
||||||
|
|
||||||
|
const variant = options.variant || 'warning'
|
||||||
|
const Icon = icons[variant]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/70 backdrop-blur-sm animate-in fade-in duration-200"
|
||||||
|
onClick={handleCancel}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<div className="relative bg-gray-800 rounded-xl shadow-2xl max-w-md w-full animate-in zoom-in-95 fade-in duration-200 border border-gray-700">
|
||||||
|
{/* Close button */}
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Icon */}
|
||||||
|
<div className={clsx('w-12 h-12 rounded-full flex items-center justify-center mb-4', iconStyles[variant])}>
|
||||||
|
{Icon}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h3 className="text-xl font-bold text-white mb-2">
|
||||||
|
{options.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
<p className="text-gray-400 mb-6 whitespace-pre-line">
|
||||||
|
{options.message}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={handleCancel}
|
||||||
|
>
|
||||||
|
{options.cancelText || 'Отмена'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={buttonVariants[variant]}
|
||||||
|
className="flex-1"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
>
|
||||||
|
{options.confirmText || 'Подтвердить'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
83
frontend/src/components/ui/Toast.tsx
Normal file
83
frontend/src/components/ui/Toast.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { X, CheckCircle, XCircle, AlertTriangle, Info } from 'lucide-react'
|
||||||
|
import { clsx } from 'clsx'
|
||||||
|
import { useToastStore, type Toast as ToastType } from '@/store/toast'
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
success: CheckCircle,
|
||||||
|
error: XCircle,
|
||||||
|
warning: AlertTriangle,
|
||||||
|
info: Info,
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
success: 'bg-green-500/20 border-green-500/50 text-green-400',
|
||||||
|
error: 'bg-red-500/20 border-red-500/50 text-red-400',
|
||||||
|
warning: 'bg-yellow-500/20 border-yellow-500/50 text-yellow-400',
|
||||||
|
info: 'bg-blue-500/20 border-blue-500/50 text-blue-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconStyles = {
|
||||||
|
success: 'text-green-500',
|
||||||
|
error: 'text-red-500',
|
||||||
|
warning: 'text-yellow-500',
|
||||||
|
info: 'text-blue-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastItemProps {
|
||||||
|
toast: ToastType
|
||||||
|
onRemove: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToastItem({ toast, onRemove }: ToastItemProps) {
|
||||||
|
const [isVisible, setIsVisible] = useState(false)
|
||||||
|
const [isLeaving, setIsLeaving] = useState(false)
|
||||||
|
const Icon = icons[toast.type]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Trigger enter animation
|
||||||
|
requestAnimationFrame(() => setIsVisible(true))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleRemove = () => {
|
||||||
|
setIsLeaving(true)
|
||||||
|
setTimeout(() => onRemove(toast.id), 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'flex items-start gap-3 p-4 rounded-lg border backdrop-blur-sm shadow-lg',
|
||||||
|
'transition-all duration-200 ease-out',
|
||||||
|
styles[toast.type],
|
||||||
|
isVisible && !isLeaving ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className={clsx('w-5 h-5 flex-shrink-0 mt-0.5', iconStyles[toast.type])} />
|
||||||
|
<p className="flex-1 text-sm text-white">{toast.message}</p>
|
||||||
|
<button
|
||||||
|
onClick={handleRemove}
|
||||||
|
className="flex-shrink-0 text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToastContainer() {
|
||||||
|
const toasts = useToastStore((state) => state.toasts)
|
||||||
|
const removeToast = useToastStore((state) => state.removeToast)
|
||||||
|
|
||||||
|
if (toasts.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-full pointer-events-none">
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<div key={toast.id} className="pointer-events-auto">
|
||||||
|
<ToastItem toast={toast} onRemove={removeToast} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
export { Button } from './Button'
|
export { Button } from './Button'
|
||||||
export { Input } from './Input'
|
export { Input } from './Input'
|
||||||
export { Card, CardHeader, CardTitle, CardContent } from './Card'
|
export { Card, CardHeader, CardTitle, CardContent } from './Card'
|
||||||
|
export { ToastContainer } from './Toast'
|
||||||
|
export { ConfirmModal } from './ConfirmModal'
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { assignmentsApi } from '@/api'
|
|||||||
import type { AssignmentDetail } from '@/types'
|
import type { AssignmentDetail } from '@/types'
|
||||||
import { Card, CardContent, Button } from '@/components/ui'
|
import { Card, CardContent, Button } from '@/components/ui'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
import { useToast } from '@/store/toast'
|
||||||
import {
|
import {
|
||||||
ArrowLeft, Loader2, ExternalLink, Image, MessageSquare,
|
ArrowLeft, Loader2, ExternalLink, Image, MessageSquare,
|
||||||
ThumbsUp, ThumbsDown, AlertTriangle, Clock, CheckCircle, XCircle,
|
ThumbsUp, ThumbsDown, AlertTriangle, Clock, CheckCircle, XCircle,
|
||||||
@@ -14,6 +15,7 @@ export function AssignmentDetailPage() {
|
|||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const user = useAuthStore((state) => state.user)
|
const user = useAuthStore((state) => state.user)
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
const [assignment, setAssignment] = useState<AssignmentDetail | null>(null)
|
const [assignment, setAssignment] = useState<AssignmentDetail | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
@@ -78,7 +80,7 @@ export function AssignmentDetailPage() {
|
|||||||
await loadAssignment()
|
await loadAssignment()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { detail?: string } } }
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
alert(error.response?.data?.detail || 'Не удалось создать оспаривание')
|
toast.error(error.response?.data?.detail || 'Не удалось создать оспаривание')
|
||||||
} finally {
|
} finally {
|
||||||
setIsCreatingDispute(false)
|
setIsCreatingDispute(false)
|
||||||
}
|
}
|
||||||
@@ -93,7 +95,7 @@ export function AssignmentDetailPage() {
|
|||||||
await loadAssignment()
|
await loadAssignment()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { detail?: string } } }
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
alert(error.response?.data?.detail || 'Не удалось проголосовать')
|
toast.error(error.response?.data?.detail || 'Не удалось проголосовать')
|
||||||
} finally {
|
} finally {
|
||||||
setIsVoting(false)
|
setIsVoting(false)
|
||||||
}
|
}
|
||||||
@@ -109,7 +111,7 @@ export function AssignmentDetailPage() {
|
|||||||
await loadAssignment()
|
await loadAssignment()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { detail?: string } } }
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
alert(error.response?.data?.detail || 'Не удалось добавить комментарий')
|
toast.error(error.response?.data?.detail || 'Не удалось добавить комментарий')
|
||||||
} finally {
|
} finally {
|
||||||
setIsAddingComment(false)
|
setIsAddingComment(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { marathonsApi, gamesApi } from '@/api'
|
|||||||
import type { Marathon, Game, Challenge, ChallengePreview } from '@/types'
|
import type { Marathon, Game, Challenge, ChallengePreview } from '@/types'
|
||||||
import { Button, Input, Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
|
import { Button, Input, Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
import { useToast } from '@/store/toast'
|
||||||
|
import { useConfirm } from '@/store/confirm'
|
||||||
import {
|
import {
|
||||||
Plus, Trash2, Sparkles, Play, Loader2, Gamepad2, X, Save, Eye,
|
Plus, Trash2, Sparkles, Play, Loader2, Gamepad2, X, Save, Eye,
|
||||||
ChevronDown, ChevronUp, Edit2, Check, CheckCircle, XCircle, Clock, User, ArrowLeft
|
ChevronDown, ChevronUp, Edit2, Check, CheckCircle, XCircle, Clock, User, ArrowLeft
|
||||||
@@ -13,6 +15,8 @@ export function LobbyPage() {
|
|||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const user = useAuthStore((state) => state.user)
|
const user = useAuthStore((state) => state.user)
|
||||||
|
const toast = useToast()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
|
||||||
const [marathon, setMarathon] = useState<Marathon | null>(null)
|
const [marathon, setMarathon] = useState<Marathon | null>(null)
|
||||||
const [games, setGames] = useState<Game[]>([])
|
const [games, setGames] = useState<Game[]>([])
|
||||||
@@ -99,7 +103,14 @@ export function LobbyPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteGame = async (gameId: number) => {
|
const handleDeleteGame = async (gameId: number) => {
|
||||||
if (!confirm('Удалить эту игру?')) return
|
const confirmed = await confirm({
|
||||||
|
title: 'Удалить игру?',
|
||||||
|
message: 'Игра и все её челленджи будут удалены.',
|
||||||
|
confirmText: 'Удалить',
|
||||||
|
cancelText: 'Отмена',
|
||||||
|
variant: 'danger',
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await gamesApi.delete(gameId)
|
await gamesApi.delete(gameId)
|
||||||
@@ -122,7 +133,14 @@ export function LobbyPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleRejectGame = async (gameId: number) => {
|
const handleRejectGame = async (gameId: number) => {
|
||||||
if (!confirm('Отклонить эту игру?')) return
|
const confirmed = await confirm({
|
||||||
|
title: 'Отклонить игру?',
|
||||||
|
message: 'Игра будет удалена из списка ожидающих.',
|
||||||
|
confirmText: 'Отклонить',
|
||||||
|
cancelText: 'Отмена',
|
||||||
|
variant: 'danger',
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
setModeratingGameId(gameId)
|
setModeratingGameId(gameId)
|
||||||
try {
|
try {
|
||||||
@@ -157,7 +175,14 @@ export function LobbyPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteChallenge = async (challengeId: number, gameId: number) => {
|
const handleDeleteChallenge = async (challengeId: number, gameId: number) => {
|
||||||
if (!confirm('Удалить это задание?')) return
|
const confirmed = await confirm({
|
||||||
|
title: 'Удалить задание?',
|
||||||
|
message: 'Это действие нельзя отменить.',
|
||||||
|
confirmText: 'Удалить',
|
||||||
|
cancelText: 'Отмена',
|
||||||
|
variant: 'danger',
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await gamesApi.deleteChallenge(challengeId)
|
await gamesApi.deleteChallenge(challengeId)
|
||||||
@@ -227,7 +252,16 @@ export function LobbyPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleStartMarathon = async () => {
|
const handleStartMarathon = async () => {
|
||||||
if (!id || !confirm('Начать марафон? После этого нельзя будет добавить новые игры.')) return
|
if (!id) return
|
||||||
|
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: 'Начать марафон?',
|
||||||
|
message: 'После старта нельзя будет добавить новые игры.',
|
||||||
|
confirmText: 'Начать',
|
||||||
|
cancelText: 'Отмена',
|
||||||
|
variant: 'warning',
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
setIsStarting(true)
|
setIsStarting(true)
|
||||||
try {
|
try {
|
||||||
@@ -235,7 +269,7 @@ export function LobbyPage() {
|
|||||||
navigate(`/marathons/${id}/play`)
|
navigate(`/marathons/${id}/play`)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { detail?: string } } }
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
alert(error.response?.data?.detail || 'Не удалось запустить марафон')
|
toast.error(error.response?.data?.detail || 'Не удалось запустить марафон')
|
||||||
} finally {
|
} finally {
|
||||||
setIsStarting(false)
|
setIsStarting(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { marathonsApi, eventsApi, challengesApi } from '@/api'
|
|||||||
import type { Marathon, ActiveEvent, Challenge } from '@/types'
|
import type { Marathon, ActiveEvent, Challenge } from '@/types'
|
||||||
import { Button, Card, CardContent } from '@/components/ui'
|
import { Button, Card, CardContent } from '@/components/ui'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
import { useToast } from '@/store/toast'
|
||||||
|
import { useConfirm } from '@/store/confirm'
|
||||||
import { EventBanner } from '@/components/EventBanner'
|
import { EventBanner } from '@/components/EventBanner'
|
||||||
import { EventControl } from '@/components/EventControl'
|
import { EventControl } from '@/components/EventControl'
|
||||||
import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed'
|
import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed'
|
||||||
@@ -14,6 +16,8 @@ export function MarathonPage() {
|
|||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const user = useAuthStore((state) => state.user)
|
const user = useAuthStore((state) => state.user)
|
||||||
|
const toast = useToast()
|
||||||
|
const confirm = useConfirm()
|
||||||
const [marathon, setMarathon] = useState<Marathon | null>(null)
|
const [marathon, setMarathon] = useState<Marathon | null>(null)
|
||||||
const [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null)
|
const [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null)
|
||||||
const [challenges, setChallenges] = useState<Challenge[]>([])
|
const [challenges, setChallenges] = useState<Challenge[]>([])
|
||||||
@@ -83,7 +87,16 @@ export function MarathonPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!marathon || !confirm('Вы уверены, что хотите удалить этот марафон? Это действие нельзя отменить.')) return
|
if (!marathon) return
|
||||||
|
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: 'Удалить марафон?',
|
||||||
|
message: 'Все данные марафона будут удалены безвозвратно.',
|
||||||
|
confirmText: 'Удалить',
|
||||||
|
cancelText: 'Отмена',
|
||||||
|
variant: 'danger',
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
setIsDeleting(true)
|
setIsDeleting(true)
|
||||||
try {
|
try {
|
||||||
@@ -91,7 +104,7 @@ export function MarathonPage() {
|
|||||||
navigate('/marathons')
|
navigate('/marathons')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete marathon:', error)
|
console.error('Failed to delete marathon:', error)
|
||||||
alert('Не удалось удалить марафон')
|
toast.error('Не удалось удалить марафон')
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeleting(false)
|
setIsDeleting(false)
|
||||||
}
|
}
|
||||||
@@ -106,7 +119,7 @@ export function MarathonPage() {
|
|||||||
setMarathon(updated)
|
setMarathon(updated)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { detail?: string } } }
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
alert(error.response?.data?.detail || 'Не удалось присоединиться')
|
toast.error(error.response?.data?.detail || 'Не удалось присоединиться')
|
||||||
} finally {
|
} finally {
|
||||||
setIsJoining(false)
|
setIsJoining(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,13 @@ import { Button, Card, CardContent } from '@/components/ui'
|
|||||||
import { SpinWheel } from '@/components/SpinWheel'
|
import { SpinWheel } from '@/components/SpinWheel'
|
||||||
import { EventBanner } from '@/components/EventBanner'
|
import { EventBanner } from '@/components/EventBanner'
|
||||||
import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle } from 'lucide-react'
|
import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle } from 'lucide-react'
|
||||||
|
import { useToast } from '@/store/toast'
|
||||||
|
import { useConfirm } from '@/store/confirm'
|
||||||
|
|
||||||
export function PlayPage() {
|
export function PlayPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const toast = useToast()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
|
||||||
const [marathon, setMarathon] = useState<Marathon | null>(null)
|
const [marathon, setMarathon] = useState<Marathon | null>(null)
|
||||||
const [currentAssignment, setCurrentAssignment] = useState<Assignment | null>(null)
|
const [currentAssignment, setCurrentAssignment] = useState<Assignment | null>(null)
|
||||||
@@ -99,7 +103,7 @@ export function PlayPage() {
|
|||||||
setGameChoiceChallenges(challenges)
|
setGameChoiceChallenges(challenges)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load game choice challenges:', error)
|
console.error('Failed to load game choice challenges:', error)
|
||||||
alert('Не удалось загрузить челленджи для этой игры')
|
toast.error('Не удалось загрузить челленджи для этой игры')
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingChallenges(false)
|
setIsLoadingChallenges(false)
|
||||||
}
|
}
|
||||||
@@ -181,7 +185,7 @@ export function PlayPage() {
|
|||||||
return result.game
|
return result.game
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { detail?: string } } }
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
alert(error.response?.data?.detail || 'Не удалось крутить')
|
toast.error(error.response?.data?.detail || 'Не удалось крутить')
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,7 +200,7 @@ export function PlayPage() {
|
|||||||
const handleComplete = async () => {
|
const handleComplete = async () => {
|
||||||
if (!currentAssignment) return
|
if (!currentAssignment) return
|
||||||
if (!proofFile && !proofUrl) {
|
if (!proofFile && !proofUrl) {
|
||||||
alert('Пожалуйста, предоставьте доказательство (файл или ссылку)')
|
toast.warning('Пожалуйста, предоставьте доказательство (файл или ссылку)')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,7 +212,7 @@ export function PlayPage() {
|
|||||||
comment: comment || undefined,
|
comment: comment || undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
alert(`Выполнено! +${result.points_earned} очков (бонус серии: +${result.streak_bonus})`)
|
toast.success(`Выполнено! +${result.points_earned} очков (бонус серии: +${result.streak_bonus})`)
|
||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
setProofFile(null)
|
setProofFile(null)
|
||||||
@@ -219,7 +223,7 @@ export function PlayPage() {
|
|||||||
await loadData()
|
await loadData()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { detail?: string } } }
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
alert(error.response?.data?.detail || 'Не удалось выполнить')
|
toast.error(error.response?.data?.detail || 'Не удалось выполнить')
|
||||||
} finally {
|
} finally {
|
||||||
setIsCompleting(false)
|
setIsCompleting(false)
|
||||||
}
|
}
|
||||||
@@ -229,18 +233,25 @@ export function PlayPage() {
|
|||||||
if (!currentAssignment) return
|
if (!currentAssignment) return
|
||||||
|
|
||||||
const penalty = spinResult?.drop_penalty || 0
|
const penalty = spinResult?.drop_penalty || 0
|
||||||
if (!confirm(`Пропустить это задание? Вы потеряете ${penalty} очков.`)) return
|
const confirmed = await confirm({
|
||||||
|
title: 'Пропустить задание?',
|
||||||
|
message: `Вы потеряете ${penalty} очков.`,
|
||||||
|
confirmText: 'Пропустить',
|
||||||
|
cancelText: 'Отмена',
|
||||||
|
variant: 'warning',
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
setIsDropping(true)
|
setIsDropping(true)
|
||||||
try {
|
try {
|
||||||
const result = await wheelApi.drop(currentAssignment.id)
|
const result = await wheelApi.drop(currentAssignment.id)
|
||||||
alert(`Пропущено. Штраф: -${result.penalty} очков`)
|
toast.info(`Пропущено. Штраф: -${result.penalty} очков`)
|
||||||
|
|
||||||
setSpinResult(null)
|
setSpinResult(null)
|
||||||
await loadData()
|
await loadData()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { detail?: string } } }
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
alert(error.response?.data?.detail || 'Не удалось пропустить')
|
toast.error(error.response?.data?.detail || 'Не удалось пропустить')
|
||||||
} finally {
|
} finally {
|
||||||
setIsDropping(false)
|
setIsDropping(false)
|
||||||
}
|
}
|
||||||
@@ -249,7 +260,7 @@ export function PlayPage() {
|
|||||||
const handleEventComplete = async () => {
|
const handleEventComplete = async () => {
|
||||||
if (!eventAssignment?.assignment) return
|
if (!eventAssignment?.assignment) return
|
||||||
if (!eventProofFile && !eventProofUrl) {
|
if (!eventProofFile && !eventProofUrl) {
|
||||||
alert('Пожалуйста, предоставьте доказательство (файл или ссылку)')
|
toast.warning('Пожалуйста, предоставьте доказательство (файл или ссылку)')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +272,7 @@ export function PlayPage() {
|
|||||||
comment: eventComment || undefined,
|
comment: eventComment || undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
alert(`Выполнено! +${result.points_earned} очков`)
|
toast.success(`Выполнено! +${result.points_earned} очков`)
|
||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
setEventProofFile(null)
|
setEventProofFile(null)
|
||||||
@@ -271,7 +282,7 @@ export function PlayPage() {
|
|||||||
await loadData()
|
await loadData()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { detail?: string } } }
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
alert(error.response?.data?.detail || 'Не удалось выполнить')
|
toast.error(error.response?.data?.detail || 'Не удалось выполнить')
|
||||||
} finally {
|
} finally {
|
||||||
setIsEventCompleting(false)
|
setIsEventCompleting(false)
|
||||||
}
|
}
|
||||||
@@ -286,22 +297,27 @@ export function PlayPage() {
|
|||||||
if (!id) return
|
if (!id) return
|
||||||
|
|
||||||
const hasActiveAssignment = !!currentAssignment
|
const hasActiveAssignment = !!currentAssignment
|
||||||
const confirmMessage = hasActiveAssignment
|
const confirmed = await confirm({
|
||||||
? 'Выбрать этот челлендж? Текущее задание будет заменено без штрафа.'
|
title: 'Выбрать челлендж?',
|
||||||
: 'Выбрать этот челлендж?'
|
message: hasActiveAssignment
|
||||||
|
? 'Текущее задание будет заменено без штрафа.'
|
||||||
if (!confirm(confirmMessage)) return
|
: 'Вы уверены, что хотите выбрать этот челлендж?',
|
||||||
|
confirmText: 'Выбрать',
|
||||||
|
cancelText: 'Отмена',
|
||||||
|
variant: 'info',
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
setIsSelectingChallenge(true)
|
setIsSelectingChallenge(true)
|
||||||
try {
|
try {
|
||||||
const result = await eventsApi.selectGameChoiceChallenge(parseInt(id), challengeId)
|
const result = await eventsApi.selectGameChoiceChallenge(parseInt(id), challengeId)
|
||||||
alert(result.message)
|
toast.success(result.message)
|
||||||
setSelectedGameId(null)
|
setSelectedGameId(null)
|
||||||
setGameChoiceChallenges(null)
|
setGameChoiceChallenges(null)
|
||||||
await loadData()
|
await loadData()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { detail?: string } } }
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
alert(error.response?.data?.detail || 'Не удалось выбрать челлендж')
|
toast.error(error.response?.data?.detail || 'Не удалось выбрать челлендж')
|
||||||
} finally {
|
} finally {
|
||||||
setIsSelectingChallenge(false)
|
setIsSelectingChallenge(false)
|
||||||
}
|
}
|
||||||
@@ -310,17 +326,24 @@ export function PlayPage() {
|
|||||||
const handleSendSwapRequest = async (participantId: number, participantName: string, theirChallenge: string) => {
|
const handleSendSwapRequest = async (participantId: number, participantName: string, theirChallenge: string) => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
|
|
||||||
if (!confirm(`Отправить запрос на обмен с ${participantName}?\n\nВы предлагаете обменяться на: "${theirChallenge}"\n\n${participantName} должен будет подтвердить обмен.`)) return
|
const confirmed = await confirm({
|
||||||
|
title: 'Отправить запрос на обмен?',
|
||||||
|
message: `Вы предлагаете обменяться с ${participantName} на:\n"${theirChallenge}"\n\n${participantName} должен будет подтвердить обмен.`,
|
||||||
|
confirmText: 'Отправить',
|
||||||
|
cancelText: 'Отмена',
|
||||||
|
variant: 'info',
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
setSendingRequestTo(participantId)
|
setSendingRequestTo(participantId)
|
||||||
try {
|
try {
|
||||||
await eventsApi.createSwapRequest(parseInt(id), participantId)
|
await eventsApi.createSwapRequest(parseInt(id), participantId)
|
||||||
alert('Запрос на обмен отправлен! Ожидайте подтверждения.')
|
toast.success('Запрос на обмен отправлен! Ожидайте подтверждения.')
|
||||||
await loadSwapRequests()
|
await loadSwapRequests()
|
||||||
await loadSwapCandidates()
|
await loadSwapCandidates()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { detail?: string } } }
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
alert(error.response?.data?.detail || 'Не удалось отправить запрос')
|
toast.error(error.response?.data?.detail || 'Не удалось отправить запрос')
|
||||||
} finally {
|
} finally {
|
||||||
setSendingRequestTo(null)
|
setSendingRequestTo(null)
|
||||||
}
|
}
|
||||||
@@ -329,16 +352,23 @@ export function PlayPage() {
|
|||||||
const handleAcceptSwapRequest = async (requestId: number) => {
|
const handleAcceptSwapRequest = async (requestId: number) => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
|
|
||||||
if (!confirm('Принять обмен? Задания будут обменяны сразу после подтверждения.')) return
|
const confirmed = await confirm({
|
||||||
|
title: 'Принять обмен?',
|
||||||
|
message: 'Задания будут обменяны сразу после подтверждения.',
|
||||||
|
confirmText: 'Принять',
|
||||||
|
cancelText: 'Отмена',
|
||||||
|
variant: 'info',
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
setProcessingRequestId(requestId)
|
setProcessingRequestId(requestId)
|
||||||
try {
|
try {
|
||||||
await eventsApi.acceptSwapRequest(parseInt(id), requestId)
|
await eventsApi.acceptSwapRequest(parseInt(id), requestId)
|
||||||
alert('Обмен выполнен!')
|
toast.success('Обмен выполнен!')
|
||||||
await loadData()
|
await loadData()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { detail?: string } } }
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
alert(error.response?.data?.detail || 'Не удалось выполнить обмен')
|
toast.error(error.response?.data?.detail || 'Не удалось выполнить обмен')
|
||||||
} finally {
|
} finally {
|
||||||
setProcessingRequestId(null)
|
setProcessingRequestId(null)
|
||||||
}
|
}
|
||||||
@@ -353,7 +383,7 @@ export function PlayPage() {
|
|||||||
await loadSwapRequests()
|
await loadSwapRequests()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { detail?: string } } }
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
alert(error.response?.data?.detail || 'Не удалось отклонить запрос')
|
toast.error(error.response?.data?.detail || 'Не удалось отклонить запрос')
|
||||||
} finally {
|
} finally {
|
||||||
setProcessingRequestId(null)
|
setProcessingRequestId(null)
|
||||||
}
|
}
|
||||||
@@ -369,7 +399,7 @@ export function PlayPage() {
|
|||||||
await loadSwapCandidates()
|
await loadSwapCandidates()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { detail?: string } } }
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
alert(error.response?.data?.detail || 'Не удалось отменить запрос')
|
toast.error(error.response?.data?.detail || 'Не удалось отменить запрос')
|
||||||
} finally {
|
} finally {
|
||||||
setProcessingRequestId(null)
|
setProcessingRequestId(null)
|
||||||
}
|
}
|
||||||
|
|||||||
55
frontend/src/store/confirm.ts
Normal file
55
frontend/src/store/confirm.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
export type ConfirmVariant = 'danger' | 'warning' | 'info'
|
||||||
|
|
||||||
|
interface ConfirmOptions {
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
confirmText?: string
|
||||||
|
cancelText?: string
|
||||||
|
variant?: ConfirmVariant
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmState {
|
||||||
|
isOpen: boolean
|
||||||
|
options: ConfirmOptions | null
|
||||||
|
resolve: ((value: boolean) => void) | null
|
||||||
|
|
||||||
|
confirm: (options: ConfirmOptions) => Promise<boolean>
|
||||||
|
handleConfirm: () => void
|
||||||
|
handleCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useConfirmStore = create<ConfirmState>((set, get) => ({
|
||||||
|
isOpen: false,
|
||||||
|
options: null,
|
||||||
|
resolve: null,
|
||||||
|
|
||||||
|
confirm: (options) => {
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
set({
|
||||||
|
isOpen: true,
|
||||||
|
options,
|
||||||
|
resolve,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
handleConfirm: () => {
|
||||||
|
const { resolve } = get()
|
||||||
|
if (resolve) resolve(true)
|
||||||
|
set({ isOpen: false, options: null, resolve: null })
|
||||||
|
},
|
||||||
|
|
||||||
|
handleCancel: () => {
|
||||||
|
const { resolve } = get()
|
||||||
|
if (resolve) resolve(false)
|
||||||
|
set({ isOpen: false, options: null, resolve: null })
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Convenient hook
|
||||||
|
export const useConfirm = () => {
|
||||||
|
const confirm = useConfirmStore((state) => state.confirm)
|
||||||
|
return confirm
|
||||||
|
}
|
||||||
53
frontend/src/store/toast.ts
Normal file
53
frontend/src/store/toast.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
export type ToastType = 'success' | 'error' | 'info' | 'warning'
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: string
|
||||||
|
type: ToastType
|
||||||
|
message: string
|
||||||
|
duration?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastState {
|
||||||
|
toasts: Toast[]
|
||||||
|
addToast: (type: ToastType, message: string, duration?: number) => void
|
||||||
|
removeToast: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useToastStore = create<ToastState>((set) => ({
|
||||||
|
toasts: [],
|
||||||
|
|
||||||
|
addToast: (type, message, duration = 4000) => {
|
||||||
|
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||||
|
set((state) => ({
|
||||||
|
toasts: [...state.toasts, { id, type, message, duration }],
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (duration > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
set((state) => ({
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== id),
|
||||||
|
}))
|
||||||
|
}, duration)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeToast: (id) => {
|
||||||
|
set((state) => ({
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== id),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Helper hooks for convenience
|
||||||
|
export const useToast = () => {
|
||||||
|
const addToast = useToastStore((state) => state.addToast)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: (message: string, duration?: number) => addToast('success', message, duration),
|
||||||
|
error: (message: string, duration?: number) => addToast('error', message, duration),
|
||||||
|
info: (message: string, duration?: number) => addToast('info', message, duration),
|
||||||
|
warning: (message: string, duration?: number) => addToast('warning', message, duration),
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user