From 574140e67d9cc439167e0df28a422f719a8994dd Mon Sep 17 00:00:00 2001 From: Oronemu Date: Tue, 16 Dec 2025 01:50:40 +0700 Subject: [PATCH] Add modals --- frontend/src/App.tsx | 5 + frontend/src/components/EventControl.tsx | 17 ++- frontend/src/components/ui/ConfirmModal.tsx | 111 ++++++++++++++++++++ frontend/src/components/ui/Toast.tsx | 83 +++++++++++++++ frontend/src/components/ui/index.ts | 2 + frontend/src/pages/AssignmentDetailPage.tsx | 8 +- frontend/src/pages/LobbyPage.tsx | 44 +++++++- frontend/src/pages/MarathonPage.tsx | 19 +++- frontend/src/pages/PlayPage.tsx | 82 ++++++++++----- frontend/src/store/confirm.ts | 55 ++++++++++ frontend/src/store/toast.ts | 53 ++++++++++ 11 files changed, 439 insertions(+), 40 deletions(-) create mode 100644 frontend/src/components/ui/ConfirmModal.tsx create mode 100644 frontend/src/components/ui/Toast.tsx create mode 100644 frontend/src/store/confirm.ts create mode 100644 frontend/src/store/toast.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 442dac6..b90a703 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( + <> + + }> } /> @@ -130,6 +134,7 @@ function App() { /> + ) } diff --git a/frontend/src/components/EventControl.tsx b/frontend/src/components/EventControl.tsx index 845a6e8..dac2610 100644 --- a/frontend/src/components/EventControl.tsx +++ b/frontend/src/components/EventControl.tsx @@ -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('golden_hour') const [selectedChallengeId, setSelectedChallengeId] = useState(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 { diff --git a/frontend/src/components/ui/ConfirmModal.tsx b/frontend/src/components/ui/ConfirmModal.tsx new file mode 100644 index 0000000..8fae06f --- /dev/null +++ b/frontend/src/components/ui/ConfirmModal.tsx @@ -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 = { + danger: , + warning: , + info: , +} + +const iconStyles: Record = { + 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 = { + 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 ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Close button */} + + +
+ {/* Icon */} +
+ {Icon} +
+ + {/* Title */} +

+ {options.title} +

+ + {/* Message */} +

+ {options.message} +

+ + {/* Actions */} +
+ + +
+
+
+
+ ) +} diff --git a/frontend/src/components/ui/Toast.tsx b/frontend/src/components/ui/Toast.tsx new file mode 100644 index 0000000..e3a9e4f --- /dev/null +++ b/frontend/src/components/ui/Toast.tsx @@ -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 ( +
+ +

{toast.message}

+ +
+ ) +} + +export function ToastContainer() { + const toasts = useToastStore((state) => state.toasts) + const removeToast = useToastStore((state) => state.removeToast) + + if (toasts.length === 0) return null + + return ( +
+ {toasts.map((toast) => ( +
+ +
+ ))} +
+ ) +} diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index c3beed3..3336266 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -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' diff --git a/frontend/src/pages/AssignmentDetailPage.tsx b/frontend/src/pages/AssignmentDetailPage.tsx index 178b9c6..0be316f 100644 --- a/frontend/src/pages/AssignmentDetailPage.tsx +++ b/frontend/src/pages/AssignmentDetailPage.tsx @@ -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(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) } diff --git a/frontend/src/pages/LobbyPage.tsx b/frontend/src/pages/LobbyPage.tsx index 3a0fde5..35427be 100644 --- a/frontend/src/pages/LobbyPage.tsx +++ b/frontend/src/pages/LobbyPage.tsx @@ -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(null) const [games, setGames] = useState([]) @@ -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) } diff --git a/frontend/src/pages/MarathonPage.tsx b/frontend/src/pages/MarathonPage.tsx index 7bde190..0ea2101 100644 --- a/frontend/src/pages/MarathonPage.tsx +++ b/frontend/src/pages/MarathonPage.tsx @@ -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(null) const [activeEvent, setActiveEvent] = useState(null) const [challenges, setChallenges] = useState([]) @@ -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) } diff --git a/frontend/src/pages/PlayPage.tsx b/frontend/src/pages/PlayPage.tsx index 280bc48..cd6d52d 100644 --- a/frontend/src/pages/PlayPage.tsx +++ b/frontend/src/pages/PlayPage.tsx @@ -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(null) const [currentAssignment, setCurrentAssignment] = useState(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) } diff --git a/frontend/src/store/confirm.ts b/frontend/src/store/confirm.ts new file mode 100644 index 0000000..4ba77fa --- /dev/null +++ b/frontend/src/store/confirm.ts @@ -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 + handleConfirm: () => void + handleCancel: () => void +} + +export const useConfirmStore = create((set, get) => ({ + isOpen: false, + options: null, + resolve: null, + + confirm: (options) => { + return new Promise((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 +} diff --git a/frontend/src/store/toast.ts b/frontend/src/store/toast.ts new file mode 100644 index 0000000..4a10785 --- /dev/null +++ b/frontend/src/store/toast.ts @@ -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((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), + } +}