Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s
203 lines
5.3 KiB
React
203 lines
5.3 KiB
React
import { useState, useEffect, createContext, useContext } from 'react'
|
||
import {
|
||
IconCheck,
|
||
IconX,
|
||
IconAlertTriangle,
|
||
IconInfoCircle,
|
||
IconRefresh
|
||
} from '@tabler/icons-react'
|
||
|
||
/**
|
||
* Контекст для глобального управления toast уведомлениями
|
||
*/
|
||
const ToastContext = createContext({
|
||
success: () => {},
|
||
error: () => {},
|
||
warning: () => {},
|
||
info: () => {},
|
||
})
|
||
|
||
export const useToast = () => useContext(ToastContext)
|
||
|
||
/**
|
||
* Toast уведомление (отдельный элемент)
|
||
*/
|
||
function Toast({ toast, onClose }) {
|
||
const [isLeaving, setIsLeaving] = useState(false)
|
||
|
||
useEffect(() => {
|
||
if (toast.duration && toast.duration > 0) {
|
||
const timer = setTimeout(() => {
|
||
handleClose()
|
||
}, toast.duration)
|
||
return () => clearTimeout(timer)
|
||
}
|
||
}, [toast.duration])
|
||
|
||
const handleClose = () => {
|
||
setIsLeaving(true)
|
||
setTimeout(() => onClose(toast.id), 300) // анимация 300ms
|
||
}
|
||
|
||
const getIcon = () => {
|
||
switch (toast.type) {
|
||
case 'success': return <IconCheck size={20} />
|
||
case 'error': return <IconX size={20} />
|
||
case 'warning': return <IconAlertTriangle size={20} />
|
||
case 'info': return <IconInfoCircle size={20} />
|
||
default: return null
|
||
}
|
||
}
|
||
|
||
const getColorClass = () => {
|
||
switch (toast.type) {
|
||
case 'success': return 'alert-success'
|
||
case 'error': return 'alert-danger'
|
||
case 'warning': return 'alert-warning'
|
||
case 'info': return 'alert-info'
|
||
default: return 'alert-info'
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className={`alert ${getColorClass()} alert-dismissible fade ${isLeaving ? '' : 'show'}`}
|
||
role="alert"
|
||
style={{
|
||
minWidth: '300px',
|
||
maxWidth: '400px',
|
||
boxShadow: '0 0.5rem 1rem rgba(0,0,0,.15)',
|
||
animation: isLeaving ? 'slideOutRight 0.3s ease-out' : 'slideInRight 0.3s ease-out'
|
||
}}
|
||
>
|
||
<div className="d-flex align-items-start">
|
||
<div className="me-2 mt-1">
|
||
{getIcon()}
|
||
</div>
|
||
<div className="flex-grow-1">
|
||
{toast.title && (
|
||
<h4 className="alert-title">{toast.title}</h4>
|
||
)}
|
||
<div className="text-secondary">{toast.message}</div>
|
||
|
||
{/* Действия */}
|
||
{toast.actions && toast.actions.length > 0 && (
|
||
<div className="btn-list mt-2">
|
||
{toast.actions.map((action, idx) => (
|
||
<button
|
||
key={idx}
|
||
className={`btn btn-sm ${action.variant || 'btn-outline-primary'}`}
|
||
onClick={() => {
|
||
action.onClick?.()
|
||
handleClose()
|
||
}}
|
||
>
|
||
{action.icon && <action.icon size={14} className="me-1" />}
|
||
{action.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="btn-close"
|
||
onClick={handleClose}
|
||
aria-label="Close"
|
||
></button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Контейнер для всех toast уведомлений
|
||
*/
|
||
export function ToastContainer({ children }) {
|
||
const [toasts, setToasts] = useState([])
|
||
|
||
const addToast = (type, message, options = {}) => {
|
||
const id = Date.now()
|
||
const toast = {
|
||
id,
|
||
type,
|
||
message,
|
||
title: options.title,
|
||
duration: options.duration !== undefined ? options.duration : (type === 'error' ? 0 :
|
||
type === 'warning' ? 5000 :
|
||
type === 'info' ? 4000 :
|
||
3000),
|
||
actions: options.actions
|
||
}
|
||
setToasts(prev => [...prev, toast])
|
||
return id
|
||
}
|
||
|
||
const removeToast = (id) => {
|
||
setToasts(prev => prev.filter(t => t.id !== id))
|
||
}
|
||
|
||
const contextValue = {
|
||
success: (message, options) => addToast('success', message, options),
|
||
error: (message, options) => addToast('error', message, options),
|
||
warning: (message, options) => addToast('warning', message, options),
|
||
info: (message, options) => addToast('info', message, options),
|
||
}
|
||
|
||
// Глобальный доступ через window.toast (для обратной совместимости)
|
||
useEffect(() => {
|
||
window.toast = contextValue
|
||
}, [])
|
||
|
||
return (
|
||
<ToastContext.Provider value={contextValue}>
|
||
{children}
|
||
|
||
{/* Контейнер с toast'ами */}
|
||
<div
|
||
style={{
|
||
position: 'fixed',
|
||
bottom: '20px',
|
||
right: '20px',
|
||
zIndex: 9999,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '10px'
|
||
}}
|
||
>
|
||
{toasts.map(toast => (
|
||
<Toast key={toast.id} toast={toast} onClose={removeToast} />
|
||
))}
|
||
</div>
|
||
|
||
{/* CSS анимации */}
|
||
<style>{`
|
||
@keyframes slideInRight {
|
||
from {
|
||
transform: translateX(100%);
|
||
opacity: 0;
|
||
}
|
||
to {
|
||
transform: translateX(0);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
@keyframes slideOutRight {
|
||
from {
|
||
transform: translateX(0);
|
||
opacity: 1;
|
||
}
|
||
to {
|
||
transform: translateX(100%);
|
||
opacity: 0;
|
||
}
|
||
}
|
||
`}</style>
|
||
</ToastContext.Provider>
|
||
)
|
||
}
|
||
|
||
export default ToastContainer
|
||
|