Files
router-lists-ui/frontend/src/components/NotifyProvider.jsx
T

154 lines
5.2 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { IconCheck, IconAlertTriangle, IconInfoCircle, IconX } from '@tabler/icons-react';
const NotifyContext = createContext({
add: () => {},
success: () => {},
error: () => {},
info: () => {},
warning: () => {},
remove: () => {},
clear: () => {},
});
const DEFAULT_TIMEOUT = {
success: 3000,
info: 4000,
warning: 5000,
error: 7000,
};
export function NotifyProvider({ children }) {
const [items, setItems] = useState([]); // { id, type, message, count, createdAt }
const timeouts = useRef(new Map());
const idSeq = useRef(1);
const remove = useCallback((id) => {
setItems((prev) => prev.filter((n) => n.id !== id));
const t = timeouts.current.get(id);
if (t) { clearTimeout(t); timeouts.current.delete(id); }
}, []);
const schedule = useCallback((id, type) => {
const dur = DEFAULT_TIMEOUT[type] ?? 4000;
const old = timeouts.current.get(id);
if (old) clearTimeout(old);
const t = setTimeout(() => remove(id), dur);
timeouts.current.set(id, t);
}, [remove]);
const add = useCallback((type, message, details) => {
const text = String(message || '').trim();
if (!text) return;
setItems((prev) => {
const dup = prev.find((n) => n.type === type && n.message === text);
if (dup) {
const updated = prev.map((n) => n.id === dup.id ? { ...n, count: (n.count || 1) + 1, createdAt: Date.now(), details: details ?? n.details } : n);
schedule(dup.id, type);
return updated;
}
const id = idSeq.current++;
const next = [...prev, { id, type, message: text, details, count: 1, createdAt: Date.now() }];
schedule(id, type);
return next;
});
}, [schedule]);
const clear = useCallback(() => {
setItems([]);
for (const t of timeouts.current.values()) clearTimeout(t);
timeouts.current.clear();
}, []);
const api = useMemo(() => ({
add,
success: (m, d) => add('success', m, d),
error: (m, d) => add('error', m, d),
info: (m, d) => add('info', m, d),
warning: (m, d) => add('warning', m, d),
remove,
clear,
}), [add, remove, clear]);
useEffect(() => {
// Опционально доступ из консоли / старого кода
window.notify = api;
return () => { if (window.notify === api) delete window.notify; };
}, [api]);
return (
<NotifyContext.Provider value={api}>
{children}
<NotifyViewport items={items} onClose={remove} />
</NotifyContext.Provider>
);
}
export function useNotify() {
return useContext(NotifyContext);
}
// Единый тост-успех для мутаций (POST/DELETE/PUT/PATCH)
export function notifyMutationSuccess(message, details) {
// Тихий режим: по умолчанию успех показываем не тостом, а через inline-баннер на страницах.
// Оставляем возможность вручную вызвать тост при необходимости, если передан details?.forceToast === true
try {
const text = message || 'Операция выполнена';
const forceToast = Boolean(details && details.forceToast);
if (forceToast && typeof window !== 'undefined' && window.notify?.success) {
window.notify.success(text, details);
}
} catch {}
}
function colorByType(type) {
switch (type) {
case 'success': return 'success';
case 'error': return 'danger';
case 'warning': return 'warning';
default: return 'info';
}
}
function iconByType(type) {
switch (type) {
case 'success': return <IconCheck className="me-2" />;
case 'warning': return <IconAlertTriangle className="me-2" />;
case 'error': return <IconAlertTriangle className="me-2" />;
default: return <IconInfoCircle className="me-2" />;
}
}
function NotifyViewport({ items, onClose }) {
return (
<div className="position-fixed top-0 end-0 p-3" style={{ zIndex: 1080, pointerEvents: 'none' }}>
<div className="d-flex flex-column gap-2 align-items-end">
{items.map((n) => (
<div key={n.id} className={`alert alert-${colorByType(n.type)} alert-dismissible shadow-sm`} role="alert" style={{ minWidth: 320, pointerEvents: 'auto' }}>
<div className="d-flex align-items-start">
<div className="me-1 mt-1">{iconByType(n.type)}</div>
<div className="flex-grow-1">
{n.message}
{n.details && (
<details className="small mt-1">
<summary>Подробнее</summary>
<pre className="mb-0 mt-1" style={{ whiteSpace: 'pre-wrap' }}>{typeof n.details === 'string' ? n.details : JSON.stringify(n.details, null, 2)}</pre>
</details>
)}
{n.count > 1 && (
<span className="badge bg-white text-body border ms-2">×{n.count}</span>
)}
</div>
<button type="button" className="btn-close" aria-label="Close" onClick={() => onClose(n.id)}>
<IconX size={16} />
</button>
</div>
</div>
))}
</div>
</div>
);
}