dynamic plugin system, toast notifications, board delete, gitea-sync plugin rewrite, granular locking fixes
This commit is contained in:
174
packages/web/src/hooks/useToast.tsx
Normal file
174
packages/web/src/hooks/useToast.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { IconCheck, IconX, IconInfoCircle, IconAlertTriangle } from '@tabler/icons-react'
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info' | 'warning'
|
||||
|
||||
interface Toast {
|
||||
id: string
|
||||
message: string
|
||||
type: ToastType
|
||||
duration: number
|
||||
}
|
||||
|
||||
interface ToastAPI {
|
||||
success: (msg: string, duration?: number) => void
|
||||
error: (msg: string, duration?: number) => void
|
||||
info: (msg: string, duration?: number) => void
|
||||
warning: (msg: string, duration?: number) => void
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastAPI>({
|
||||
success: () => {},
|
||||
error: () => {},
|
||||
info: () => {},
|
||||
warning: () => {},
|
||||
})
|
||||
|
||||
export function useToast() {
|
||||
return useContext(ToastContext)
|
||||
}
|
||||
|
||||
// global emitter for non-React code (api.ts etc)
|
||||
type ToastFn = (msg: string, type?: string) => void
|
||||
let globalToast: ToastFn | null = null
|
||||
export function setGlobalToast(fn: ToastFn) { globalToast = fn }
|
||||
export function emitToast(msg: string, type: ToastType = 'info') {
|
||||
if (globalToast) globalToast(msg, type)
|
||||
}
|
||||
|
||||
let idCounter = 0
|
||||
|
||||
const TYPE_COLORS: Record<ToastType, string> = {
|
||||
success: 'var(--success)',
|
||||
error: 'var(--error)',
|
||||
warning: 'var(--warning)',
|
||||
info: 'var(--info)',
|
||||
}
|
||||
|
||||
const TYPE_ICONS: Record<ToastType, typeof IconCheck> = {
|
||||
success: IconCheck,
|
||||
error: IconX,
|
||||
warning: IconAlertTriangle,
|
||||
info: IconInfoCircle,
|
||||
}
|
||||
|
||||
const MAX_VISIBLE = 5
|
||||
|
||||
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: string) => void }) {
|
||||
const [exiting, setExiting] = useState(false)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const Icon = TYPE_ICONS[toast.type]
|
||||
|
||||
useEffect(() => {
|
||||
timerRef.current = setTimeout(() => {
|
||||
setExiting(true)
|
||||
setTimeout(() => onDismiss(toast.id), 200)
|
||||
}, toast.duration)
|
||||
return () => clearTimeout(timerRef.current)
|
||||
}, [toast.id, toast.duration, onDismiss])
|
||||
|
||||
const handleClose = () => {
|
||||
clearTimeout(timerRef.current)
|
||||
setExiting(true)
|
||||
setTimeout(() => onDismiss(toast.id), 200)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '10px 14px',
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderLeft: `3px solid ${TYPE_COLORS[toast.type]}`,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
boxShadow: 'var(--shadow-md)',
|
||||
fontSize: 'var(--text-sm)',
|
||||
color: 'var(--text)',
|
||||
maxWidth: 400,
|
||||
minWidth: 220,
|
||||
animation: exiting ? 'toastOut 200ms ease-out forwards' : 'toastIn 250ms var(--ease-out)',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
size={16}
|
||||
stroke={2.5}
|
||||
style={{ color: TYPE_COLORS[toast.type], flexShrink: 0 }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span style={{ flex: 1, lineHeight: 1.4 }}>{toast.message}</span>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
aria-label="Dismiss"
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--text-tertiary)',
|
||||
cursor: 'pointer',
|
||||
padding: 2,
|
||||
lineHeight: 0,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IconX size={14} stroke={2} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
|
||||
const dismiss = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, [])
|
||||
|
||||
const add = useCallback((message: string, type: ToastType, duration = 4000) => {
|
||||
const id = `toast-${++idCounter}`
|
||||
setToasts((prev) => {
|
||||
const next = [...prev, { id, message, type, duration }]
|
||||
return next.slice(-MAX_VISIBLE)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const api: ToastAPI = {
|
||||
success: useCallback((msg: string, dur?: number) => add(msg, 'success', dur), [add]),
|
||||
error: useCallback((msg: string, dur?: number) => add(msg, 'error', dur), [add]),
|
||||
info: useCallback((msg: string, dur?: number) => add(msg, 'info', dur), [add]),
|
||||
warning: useCallback((msg: string, dur?: number) => add(msg, 'warning', dur), [add]),
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setGlobalToast((msg, type) => add(msg, (type as ToastType) || 'info'))
|
||||
return () => { globalToast = null }
|
||||
}, [add])
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={api}>
|
||||
{children}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="Notifications"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 16,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{toasts.map((t) => (
|
||||
<ToastItem key={t.id} toast={t} onDismiss={dismiss} />
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user