dynamic plugin system, toast notifications, board delete, gitea-sync plugin rewrite, granular locking fixes

This commit is contained in:
2026-03-21 19:26:35 +02:00
parent a8ac768e3f
commit d52088a88b
37 changed files with 1653 additions and 48 deletions

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