Add modals
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { ToastContainer, ConfirmModal } from '@/components/ui'
|
||||
|
||||
// Layout
|
||||
import { Layout } from '@/components/layout/Layout'
|
||||
@@ -41,6 +42,9 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<>
|
||||
<ToastContainer />
|
||||
<ConfirmModal />
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<HomePage />} />
|
||||
@@ -130,6 +134,7 @@ function App() {
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Button } from '@/components/ui'
|
||||
import { eventsApi } from '@/api'
|
||||
import type { ActiveEvent, EventType, Challenge } from '@/types'
|
||||
import { EVENT_INFO } from '@/types'
|
||||
import { useToast } from '@/store/toast'
|
||||
import { useConfirm } from '@/store/confirm'
|
||||
|
||||
interface EventControlProps {
|
||||
marathonId: number
|
||||
@@ -36,6 +38,8 @@ export function EventControl({
|
||||
challenges,
|
||||
onEventChange,
|
||||
}: EventControlProps) {
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
const [selectedType, setSelectedType] = useState<EventType>('golden_hour')
|
||||
const [selectedChallengeId, setSelectedChallengeId] = useState<number | null>(null)
|
||||
const [isStarting, setIsStarting] = useState(false)
|
||||
@@ -43,7 +47,7 @@ export function EventControl({
|
||||
|
||||
const handleStart = async () => {
|
||||
if (selectedType === 'common_enemy' && !selectedChallengeId) {
|
||||
alert('Выберите челлендж для события "Общий враг"')
|
||||
toast.warning('Выберите челлендж для события "Общий враг"')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -56,14 +60,21 @@ export function EventControl({
|
||||
onEventChange()
|
||||
} catch (error) {
|
||||
console.error('Failed to start event:', error)
|
||||
alert('Не удалось запустить событие')
|
||||
toast.error('Не удалось запустить событие')
|
||||
} finally {
|
||||
setIsStarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStop = async () => {
|
||||
if (!confirm('Остановить событие досрочно?')) return
|
||||
const confirmed = await confirm({
|
||||
title: 'Остановить событие?',
|
||||
message: 'Событие будет завершено досрочно.',
|
||||
confirmText: 'Остановить',
|
||||
cancelText: 'Отмена',
|
||||
variant: 'warning',
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
setIsStopping(true)
|
||||
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 { Input } from './Input'
|
||||
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 { Card, CardContent, Button } from '@/components/ui'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useToast } from '@/store/toast'
|
||||
import {
|
||||
ArrowLeft, Loader2, ExternalLink, Image, MessageSquare,
|
||||
ThumbsUp, ThumbsDown, AlertTriangle, Clock, CheckCircle, XCircle,
|
||||
@@ -14,6 +15,7 @@ export function AssignmentDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const user = useAuthStore((state) => state.user)
|
||||
const toast = useToast()
|
||||
|
||||
const [assignment, setAssignment] = useState<AssignmentDetail | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@@ -78,7 +80,7 @@ export function AssignmentDetailPage() {
|
||||
await loadAssignment()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
alert(error.response?.data?.detail || 'Не удалось создать оспаривание')
|
||||
toast.error(error.response?.data?.detail || 'Не удалось создать оспаривание')
|
||||
} finally {
|
||||
setIsCreatingDispute(false)
|
||||
}
|
||||
@@ -93,7 +95,7 @@ export function AssignmentDetailPage() {
|
||||
await loadAssignment()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
alert(error.response?.data?.detail || 'Не удалось проголосовать')
|
||||
toast.error(error.response?.data?.detail || 'Не удалось проголосовать')
|
||||
} finally {
|
||||
setIsVoting(false)
|
||||
}
|
||||
@@ -109,7 +111,7 @@ export function AssignmentDetailPage() {
|
||||
await loadAssignment()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
alert(error.response?.data?.detail || 'Не удалось добавить комментарий')
|
||||
toast.error(error.response?.data?.detail || 'Не удалось добавить комментарий')
|
||||
} finally {
|
||||
setIsAddingComment(false)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { marathonsApi, gamesApi } from '@/api'
|
||||
import type { Marathon, Game, Challenge, ChallengePreview } from '@/types'
|
||||
import { Button, Input, Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useToast } from '@/store/toast'
|
||||
import { useConfirm } from '@/store/confirm'
|
||||
import {
|
||||
Plus, Trash2, Sparkles, Play, Loader2, Gamepad2, X, Save, Eye,
|
||||
ChevronDown, ChevronUp, Edit2, Check, CheckCircle, XCircle, Clock, User, ArrowLeft
|
||||
@@ -13,6 +15,8 @@ export function LobbyPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const user = useAuthStore((state) => state.user)
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
|
||||
const [marathon, setMarathon] = useState<Marathon | null>(null)
|
||||
const [games, setGames] = useState<Game[]>([])
|
||||
@@ -99,7 +103,14 @@ export function LobbyPage() {
|
||||
}
|
||||
|
||||
const handleDeleteGame = async (gameId: number) => {
|
||||
if (!confirm('Удалить эту игру?')) return
|
||||
const confirmed = await confirm({
|
||||
title: 'Удалить игру?',
|
||||
message: 'Игра и все её челленджи будут удалены.',
|
||||
confirmText: 'Удалить',
|
||||
cancelText: 'Отмена',
|
||||
variant: 'danger',
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await gamesApi.delete(gameId)
|
||||
@@ -122,7 +133,14 @@ export function LobbyPage() {
|
||||
}
|
||||
|
||||
const handleRejectGame = async (gameId: number) => {
|
||||
if (!confirm('Отклонить эту игру?')) return
|
||||
const confirmed = await confirm({
|
||||
title: 'Отклонить игру?',
|
||||
message: 'Игра будет удалена из списка ожидающих.',
|
||||
confirmText: 'Отклонить',
|
||||
cancelText: 'Отмена',
|
||||
variant: 'danger',
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
setModeratingGameId(gameId)
|
||||
try {
|
||||
@@ -157,7 +175,14 @@ export function LobbyPage() {
|
||||
}
|
||||
|
||||
const handleDeleteChallenge = async (challengeId: number, gameId: number) => {
|
||||
if (!confirm('Удалить это задание?')) return
|
||||
const confirmed = await confirm({
|
||||
title: 'Удалить задание?',
|
||||
message: 'Это действие нельзя отменить.',
|
||||
confirmText: 'Удалить',
|
||||
cancelText: 'Отмена',
|
||||
variant: 'danger',
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await gamesApi.deleteChallenge(challengeId)
|
||||
@@ -227,7 +252,16 @@ export function LobbyPage() {
|
||||
}
|
||||
|
||||
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)
|
||||
try {
|
||||
@@ -235,7 +269,7 @@ export function LobbyPage() {
|
||||
navigate(`/marathons/${id}/play`)
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
alert(error.response?.data?.detail || 'Не удалось запустить марафон')
|
||||
toast.error(error.response?.data?.detail || 'Не удалось запустить марафон')
|
||||
} finally {
|
||||
setIsStarting(false)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { marathonsApi, eventsApi, challengesApi } from '@/api'
|
||||
import type { Marathon, ActiveEvent, Challenge } from '@/types'
|
||||
import { Button, Card, CardContent } from '@/components/ui'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useToast } from '@/store/toast'
|
||||
import { useConfirm } from '@/store/confirm'
|
||||
import { EventBanner } from '@/components/EventBanner'
|
||||
import { EventControl } from '@/components/EventControl'
|
||||
import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed'
|
||||
@@ -14,6 +16,8 @@ export function MarathonPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const user = useAuthStore((state) => state.user)
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
const [marathon, setMarathon] = useState<Marathon | null>(null)
|
||||
const [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null)
|
||||
const [challenges, setChallenges] = useState<Challenge[]>([])
|
||||
@@ -83,7 +87,16 @@ export function MarathonPage() {
|
||||
}
|
||||
|
||||
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)
|
||||
try {
|
||||
@@ -91,7 +104,7 @@ export function MarathonPage() {
|
||||
navigate('/marathons')
|
||||
} catch (error) {
|
||||
console.error('Failed to delete marathon:', error)
|
||||
alert('Не удалось удалить марафон')
|
||||
toast.error('Не удалось удалить марафон')
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
@@ -106,7 +119,7 @@ export function MarathonPage() {
|
||||
setMarathon(updated)
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
alert(error.response?.data?.detail || 'Не удалось присоединиться')
|
||||
toast.error(error.response?.data?.detail || 'Не удалось присоединиться')
|
||||
} finally {
|
||||
setIsJoining(false)
|
||||
}
|
||||
|
||||
@@ -6,9 +6,13 @@ import { Button, Card, CardContent } from '@/components/ui'
|
||||
import { SpinWheel } from '@/components/SpinWheel'
|
||||
import { EventBanner } from '@/components/EventBanner'
|
||||
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() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
|
||||
const [marathon, setMarathon] = useState<Marathon | null>(null)
|
||||
const [currentAssignment, setCurrentAssignment] = useState<Assignment | null>(null)
|
||||
@@ -99,7 +103,7 @@ export function PlayPage() {
|
||||
setGameChoiceChallenges(challenges)
|
||||
} catch (error) {
|
||||
console.error('Failed to load game choice challenges:', error)
|
||||
alert('Не удалось загрузить челленджи для этой игры')
|
||||
toast.error('Не удалось загрузить челленджи для этой игры')
|
||||
} finally {
|
||||
setIsLoadingChallenges(false)
|
||||
}
|
||||
@@ -181,7 +185,7 @@ export function PlayPage() {
|
||||
return result.game
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
alert(error.response?.data?.detail || 'Не удалось крутить')
|
||||
toast.error(error.response?.data?.detail || 'Не удалось крутить')
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -196,7 +200,7 @@ export function PlayPage() {
|
||||
const handleComplete = async () => {
|
||||
if (!currentAssignment) return
|
||||
if (!proofFile && !proofUrl) {
|
||||
alert('Пожалуйста, предоставьте доказательство (файл или ссылку)')
|
||||
toast.warning('Пожалуйста, предоставьте доказательство (файл или ссылку)')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -208,7 +212,7 @@ export function PlayPage() {
|
||||
comment: comment || undefined,
|
||||
})
|
||||
|
||||
alert(`Выполнено! +${result.points_earned} очков (бонус серии: +${result.streak_bonus})`)
|
||||
toast.success(`Выполнено! +${result.points_earned} очков (бонус серии: +${result.streak_bonus})`)
|
||||
|
||||
// Reset form
|
||||
setProofFile(null)
|
||||
@@ -219,7 +223,7 @@ export function PlayPage() {
|
||||
await loadData()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
alert(error.response?.data?.detail || 'Не удалось выполнить')
|
||||
toast.error(error.response?.data?.detail || 'Не удалось выполнить')
|
||||
} finally {
|
||||
setIsCompleting(false)
|
||||
}
|
||||
@@ -229,18 +233,25 @@ export function PlayPage() {
|
||||
if (!currentAssignment) return
|
||||
|
||||
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)
|
||||
try {
|
||||
const result = await wheelApi.drop(currentAssignment.id)
|
||||
alert(`Пропущено. Штраф: -${result.penalty} очков`)
|
||||
toast.info(`Пропущено. Штраф: -${result.penalty} очков`)
|
||||
|
||||
setSpinResult(null)
|
||||
await loadData()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
alert(error.response?.data?.detail || 'Не удалось пропустить')
|
||||
toast.error(error.response?.data?.detail || 'Не удалось пропустить')
|
||||
} finally {
|
||||
setIsDropping(false)
|
||||
}
|
||||
@@ -249,7 +260,7 @@ export function PlayPage() {
|
||||
const handleEventComplete = async () => {
|
||||
if (!eventAssignment?.assignment) return
|
||||
if (!eventProofFile && !eventProofUrl) {
|
||||
alert('Пожалуйста, предоставьте доказательство (файл или ссылку)')
|
||||
toast.warning('Пожалуйста, предоставьте доказательство (файл или ссылку)')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -261,7 +272,7 @@ export function PlayPage() {
|
||||
comment: eventComment || undefined,
|
||||
})
|
||||
|
||||
alert(`Выполнено! +${result.points_earned} очков`)
|
||||
toast.success(`Выполнено! +${result.points_earned} очков`)
|
||||
|
||||
// Reset form
|
||||
setEventProofFile(null)
|
||||
@@ -271,7 +282,7 @@ export function PlayPage() {
|
||||
await loadData()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
alert(error.response?.data?.detail || 'Не удалось выполнить')
|
||||
toast.error(error.response?.data?.detail || 'Не удалось выполнить')
|
||||
} finally {
|
||||
setIsEventCompleting(false)
|
||||
}
|
||||
@@ -286,22 +297,27 @@ export function PlayPage() {
|
||||
if (!id) return
|
||||
|
||||
const hasActiveAssignment = !!currentAssignment
|
||||
const confirmMessage = hasActiveAssignment
|
||||
? 'Выбрать этот челлендж? Текущее задание будет заменено без штрафа.'
|
||||
: 'Выбрать этот челлендж?'
|
||||
|
||||
if (!confirm(confirmMessage)) return
|
||||
const confirmed = await confirm({
|
||||
title: 'Выбрать челлендж?',
|
||||
message: hasActiveAssignment
|
||||
? 'Текущее задание будет заменено без штрафа.'
|
||||
: 'Вы уверены, что хотите выбрать этот челлендж?',
|
||||
confirmText: 'Выбрать',
|
||||
cancelText: 'Отмена',
|
||||
variant: 'info',
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
setIsSelectingChallenge(true)
|
||||
try {
|
||||
const result = await eventsApi.selectGameChoiceChallenge(parseInt(id), challengeId)
|
||||
alert(result.message)
|
||||
toast.success(result.message)
|
||||
setSelectedGameId(null)
|
||||
setGameChoiceChallenges(null)
|
||||
await loadData()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
alert(error.response?.data?.detail || 'Не удалось выбрать челлендж')
|
||||
toast.error(error.response?.data?.detail || 'Не удалось выбрать челлендж')
|
||||
} finally {
|
||||
setIsSelectingChallenge(false)
|
||||
}
|
||||
@@ -310,17 +326,24 @@ export function PlayPage() {
|
||||
const handleSendSwapRequest = async (participantId: number, participantName: string, theirChallenge: string) => {
|
||||
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)
|
||||
try {
|
||||
await eventsApi.createSwapRequest(parseInt(id), participantId)
|
||||
alert('Запрос на обмен отправлен! Ожидайте подтверждения.')
|
||||
toast.success('Запрос на обмен отправлен! Ожидайте подтверждения.')
|
||||
await loadSwapRequests()
|
||||
await loadSwapCandidates()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
alert(error.response?.data?.detail || 'Не удалось отправить запрос')
|
||||
toast.error(error.response?.data?.detail || 'Не удалось отправить запрос')
|
||||
} finally {
|
||||
setSendingRequestTo(null)
|
||||
}
|
||||
@@ -329,16 +352,23 @@ export function PlayPage() {
|
||||
const handleAcceptSwapRequest = async (requestId: number) => {
|
||||
if (!id) return
|
||||
|
||||
if (!confirm('Принять обмен? Задания будут обменяны сразу после подтверждения.')) return
|
||||
const confirmed = await confirm({
|
||||
title: 'Принять обмен?',
|
||||
message: 'Задания будут обменяны сразу после подтверждения.',
|
||||
confirmText: 'Принять',
|
||||
cancelText: 'Отмена',
|
||||
variant: 'info',
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
setProcessingRequestId(requestId)
|
||||
try {
|
||||
await eventsApi.acceptSwapRequest(parseInt(id), requestId)
|
||||
alert('Обмен выполнен!')
|
||||
toast.success('Обмен выполнен!')
|
||||
await loadData()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
alert(error.response?.data?.detail || 'Не удалось выполнить обмен')
|
||||
toast.error(error.response?.data?.detail || 'Не удалось выполнить обмен')
|
||||
} finally {
|
||||
setProcessingRequestId(null)
|
||||
}
|
||||
@@ -353,7 +383,7 @@ export function PlayPage() {
|
||||
await loadSwapRequests()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
alert(error.response?.data?.detail || 'Не удалось отклонить запрос')
|
||||
toast.error(error.response?.data?.detail || 'Не удалось отклонить запрос')
|
||||
} finally {
|
||||
setProcessingRequestId(null)
|
||||
}
|
||||
@@ -369,7 +399,7 @@ export function PlayPage() {
|
||||
await loadSwapCandidates()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
alert(error.response?.data?.detail || 'Не удалось отменить запрос')
|
||||
toast.error(error.response?.data?.detail || 'Не удалось отменить запрос')
|
||||
} finally {
|
||||
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