Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m0s
154 lines
5.2 KiB
React
154 lines
5.2 KiB
React
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>
|
||
);
|
||
}
|
||
|
||
|