Add modals

This commit is contained in:
2025-12-16 01:50:40 +07:00
parent 87ecd9756c
commit 574140e67d
11 changed files with 439 additions and 40 deletions

View File

@@ -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>
</>
)
}

View File

@@ -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 {

View 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>
)
}

View 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>
)
}

View File

@@ -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'

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View 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
}

View 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),
}
}