own version of toast

This commit is contained in:
proddy
2026-06-19 11:52:33 +02:00
parent 88a777ca5f
commit 392812d489
30 changed files with 177 additions and 62 deletions

View File

@@ -39,7 +39,6 @@
"react-dom": "^19.2.7",
"react-icons": "^5.6.0",
"react-router": "^8.0.1",
"react-toastify": "^11.1.0",
"typesafe-i18n": "^5.27.1",
"typescript": "^6.0.3"
},

View File

@@ -56,9 +56,6 @@ importers:
react-router:
specifier: ^8.0.1
version: 8.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react-toastify:
specifier: ^11.1.0
version: 11.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
typesafe-i18n:
specifier: ^5.27.1
version: 5.27.1(typescript@6.0.3)
@@ -2447,12 +2444,6 @@ packages:
react-dom:
optional: true
react-toastify@11.1.0:
resolution: {integrity: sha512-e9h23x3phN0wbFeB6yovmWp7lobzV4CaCH0LO8nVP6H7Y+3GbcLpIzMm9dJhcp1RXbpyfvjgpfXqO80QAmn7sg==}
peerDependencies:
react: ^18 || ^19
react-dom: ^18 || ^19
react-transition-group@4.4.5:
resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==}
peerDependencies:
@@ -5381,12 +5372,6 @@ snapshots:
optionalDependencies:
react-dom: 19.2.7(react@19.2.7)
react-toastify@11.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
clsx: 2.1.1
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
react-transition-group@4.4.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
'@babel/runtime': 7.29.7

View File

@@ -1,8 +1,8 @@
import { memo, useEffect, useState } from 'react';
import { ToastContainer, Zoom } from 'react-toastify';
import AppRouting from 'AppRouting';
import CustomTheme from 'CustomTheme';
import { Toaster } from 'components/toast';
import TypesafeI18n from 'i18n/i18n-react';
import type { Locales } from 'i18n/i18n-types';
import { loadLocaleAsync } from 'i18n/i18n-util.async';
@@ -22,26 +22,6 @@ const AVAILABLE_LOCALES = [
'cz'
] as Locales[];
// Static toast configuration - no need to recreate on every render
const TOAST_CONTAINER_PROPS = {
position: 'bottom-left' as const,
autoClose: 3000,
hideProgressBar: false,
newestOnTop: false,
closeOnClick: true,
rtl: false,
pauseOnFocusLoss: true,
draggable: false,
pauseOnHover: false,
transition: Zoom,
closeButton: false,
theme: 'dark' as const,
toastStyle: {
border: '1px solid #177ac9',
width: 'fit-content'
}
};
const App = memo(() => {
const [wasLoaded, setWasLoaded] = useState(false);
const [locale, setLocale] = useState<Locales>('en');
@@ -64,7 +44,7 @@ const App = memo(() => {
<TypesafeI18n locale={locale}>
<CustomTheme>
<AppRouting />
<ToastContainer {...TOAST_CONTAINER_PROPS} />
<Toaster />
</CustomTheme>
</TypesafeI18n>
);

View File

@@ -1,10 +1,10 @@
import { type FC, memo, useContext, useEffect, useRef } from 'react';
import { Navigate, Route, Routes } from 'react-router';
import { toast } from 'react-toastify';
import AuthenticatedRouting from 'AuthenticatedRouting';
import SignIn from 'SignIn';
import { RequireAuthenticated, RequireUnauthenticated } from 'components';
import { toast } from 'components/toast';
import { Authentication, AuthenticationContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';

View File

@@ -1,5 +1,4 @@
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import ForwardIcon from '@mui/icons-material/Forward';
import { Box, Button, Paper, Typography } from '@mui/material';
@@ -13,6 +12,7 @@ import {
ValidatedPasswordField,
ValidatedTextField
} from 'components';
import { toast } from 'components/toast';
import { AuthenticationContext } from 'contexts/authentication';
import { PROJECT_NAME } from 'env';
import { useI18nContext } from 'i18n/i18n-react';

View File

@@ -1,6 +1,5 @@
import { useState } from 'react';
import { useBlocker } from 'react-router';
import { toast } from 'react-toastify';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -27,6 +26,7 @@ import {
SectionContent,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils';

View File

@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -22,6 +21,7 @@ import { useRequest } from 'alova/client';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { ValidatedTextField } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import { updateValue } from 'utils';
import { ValidationError, validate } from 'validators';

View File

@@ -1,6 +1,5 @@
import { useState } from 'react';
import { useBlocker } from 'react-router';
import { toast } from 'react-toastify';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -26,6 +25,7 @@ import {
SectionContent,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils';

View File

@@ -1,6 +1,5 @@
import { useEffect, useState } from 'react';
import { useBlocker, useLocation } from 'react-router';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import EditIcon from '@mui/icons-material/Edit';
@@ -46,6 +45,7 @@ import {
SectionContent,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import {

View File

@@ -1,7 +1,6 @@
import { memo, useContext, useEffect, useState } from 'react';
import { IconContext } from 'react-icons/lib';
import { Link } from 'react-router';
import { toast } from 'react-toastify';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import EditIcon from '@mui/icons-material/Edit';
@@ -30,6 +29,7 @@ import {
SectionContent,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import { useInterval, usePersistState } from 'utils';

View File

@@ -8,7 +8,6 @@ import {
} from 'react';
import { IconContext } from 'react-icons';
import { Link, useNavigate } from 'react-router';
import { toast } from 'react-toastify';
import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined';
import ConstructionIcon from '@mui/icons-material/Construction';
@@ -64,6 +63,7 @@ import {
SectionContent,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils';

View File

@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
@@ -26,6 +25,7 @@ import { useRequest } from 'alova/client';
import type Schema from 'async-validator';
import type { ValidateFieldsError } from 'async-validator';
import { ValidatedTextField } from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValue } from 'utils';
import { ValidationError, validate } from 'validators';

View File

@@ -1,6 +1,5 @@
import { memo, useContext, useState } from 'react';
import type { ReactElement } from 'react';
import { toast } from 'react-toastify';
import CommentIcon from '@mui/icons-material/CommentTwoTone';
import DownloadIcon from '@mui/icons-material/GetApp';
@@ -25,6 +24,7 @@ import type { SxProps, Theme } from '@mui/material/styles';
import { useRequest } from 'alova/client';
import { SectionContent, useLayoutTitle } from 'components';
import { toast } from 'components/toast';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import { saveFile } from 'utils';

View File

@@ -1,6 +1,5 @@
import { memo, useState } from 'react';
import { useBlocker } from 'react-router';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import CircleIcon from '@mui/icons-material/Circle';
@@ -25,6 +24,7 @@ import {
SectionContent,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import { readModules, writeModules } from '../../api/app';

View File

@@ -1,6 +1,5 @@
import { useEffect, useState } from 'react';
import { useBlocker } from 'react-router';
import { toast } from 'react-toastify';
import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -26,6 +25,7 @@ import {
SectionContent,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils';

View File

@@ -1,5 +1,4 @@
import { useContext, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined';
import KeyboardArrowDownOutlinedIcon from '@mui/icons-material/KeyboardArrowDownOutlined';
@@ -21,6 +20,7 @@ import { useTheme } from '@table-library/react-table-library/theme';
import type { State } from '@table-library/react-table-library/types/common';
import { useRequest } from 'alova/client';
import { SectionContent, useLayoutTitle } from 'components';
import { toast } from 'components/toast';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import { useInterval } from 'utils';

View File

@@ -1,5 +1,4 @@
import { useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
@@ -32,6 +31,7 @@ import {
ValidatedTextField,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import { numberValue, updateValueDirty, useRest } from 'utils';
import { ValidationError, validate } from 'validators';

View File

@@ -1,5 +1,4 @@
import { useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import DownloadIcon from '@mui/icons-material/GetApp';
@@ -27,6 +26,7 @@ import {
SingleUpload,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import { saveFile } from 'utils';

View File

@@ -1,5 +1,4 @@
import { useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
@@ -28,6 +27,7 @@ import {
ValidatedTextField,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import type { MqttSettingsType } from 'types';
import { numberValue, updateValueDirty, useRest } from 'utils';

View File

@@ -1,5 +1,4 @@
import { useState } from 'react';
import { toast } from 'react-toastify';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CancelIcon from '@mui/icons-material/Cancel';
@@ -33,6 +32,7 @@ import {
ValidatedTextField,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import type { NTPSettingsType, Time } from 'types';
import { formatLocalDateTime, updateValueDirty, useRest } from 'utils';

View File

@@ -1,6 +1,5 @@
import { memo, useContext, useMemo, useState } from 'react';
import { Link } from 'react-router';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import CloseIcon from '@mui/icons-material/Close';
@@ -39,6 +38,7 @@ import {
SingleUpload,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import type { TranslationFunctions } from 'i18n/i18n-types';

View File

@@ -1,5 +1,4 @@
import { memo, useCallback, useContext, useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import DeleteIcon from '@mui/icons-material/Delete';
@@ -37,6 +36,7 @@ import {
ValidatedPasswordField,
ValidatedTextField
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import type { NetworkSettingsType } from 'types';
import { updateValueDirty, useRest } from 'utils';

View File

@@ -1,5 +1,4 @@
import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import DownloadIcon from '@mui/icons-material/GetApp';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
@@ -26,6 +25,7 @@ import {
SectionContent,
useLayoutTitle
} from 'components';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import type { LogEntry, LogSettings } from 'types';
import { LogLevel } from 'types';

View File

@@ -0,0 +1,101 @@
import { memo, useEffect, useRef, useState, useSyncExternalStore } from 'react';
import Alert from '@mui/material/Alert';
import Grow from '@mui/material/Grow';
import LinearProgress from '@mui/material/LinearProgress';
import Stack from '@mui/material/Stack';
import { type ToastItem, getSnapshot, removeToast, subscribe } from './toastStore';
const AUTO_CLOSE_MS = 3000;
const TICK_MS = 50;
// Single toast row: owns its auto-dismiss timer + countdown progress bar, pauses
// while the window is unfocused (matching react-toastify's pauseOnFocusLoss).
const ToastRow = memo(({ item }: { item: ToastItem }) => {
const [open, setOpen] = useState(true);
const [remaining, setRemaining] = useState(AUTO_CLOSE_MS);
const remainingRef = useRef(AUTO_CLOSE_MS);
useEffect(() => {
let paused = document.hidden;
const onVisibility = () => {
paused = document.hidden;
};
document.addEventListener('visibilitychange', onVisibility);
const timer = setInterval(() => {
if (paused) return;
remainingRef.current = Math.max(0, remainingRef.current - TICK_MS);
setRemaining(remainingRef.current);
if (remainingRef.current === 0) setOpen(false);
}, TICK_MS);
return () => {
clearInterval(timer);
document.removeEventListener('visibilitychange', onVisibility);
};
}, []);
return (
<Grow in={open} onExited={() => removeToast(item.id)}>
<Alert
severity={item.severity}
variant="filled"
onClick={() => setOpen(false)}
sx={{
width: 'fit-content',
maxWidth: 360,
minHeight: 64,
cursor: 'pointer',
border: '1px solid #177ac9',
boxShadow: 6,
overflow: 'hidden',
alignItems: 'center',
'& .MuiAlert-icon': { py: 0 },
'& .MuiAlert-message': { py: 0, textAlign: 'center', fontSize: '1rem' }
}}
>
{item.message}
<LinearProgress
variant="determinate"
value={(remaining / AUTO_CLOSE_MS) * 100}
color="inherit"
sx={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: 3,
opacity: 0.7,
backgroundColor: 'transparent'
}}
/>
</Alert>
</Grow>
);
});
const Toaster = memo(() => {
const toasts = useSyncExternalStore(subscribe, getSnapshot);
return (
<Stack
spacing={1}
sx={{
position: 'fixed',
bottom: 16,
left: 16,
zIndex: (theme) => theme.zIndex.snackbar,
pointerEvents: 'none',
'& > *': { pointerEvents: 'auto' }
}}
>
{toasts.map((item) => (
<ToastRow key={item.id} item={item} />
))}
</Stack>
);
});
export default Toaster;

View File

@@ -0,0 +1,3 @@
export { default as Toaster } from './Toaster';
export { toast } from './toastStore';
export type { ToastSeverity } from './toastStore';

View File

@@ -0,0 +1,47 @@
export type ToastSeverity = 'success' | 'error' | 'info' | 'warning';
export interface ToastItem {
id: number;
severity: ToastSeverity;
message: string;
}
let toasts: ToastItem[] = [];
let nextId = 1;
const listeners = new Set<() => void>();
const emit = () => {
for (const listener of listeners) listener();
};
export const subscribe = (listener: () => void): (() => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
export const getSnapshot = (): ToastItem[] => toasts;
const add = (severity: ToastSeverity, message: string): number => {
const id = nextId++;
toasts = [...toasts, { id, severity, message }];
emit();
return id;
};
export const removeToast = (id: number): void => {
const next = toasts.filter((t) => t.id !== id);
if (next.length !== toasts.length) {
toasts = next;
emit();
}
};
// Imperative API mirroring the subset of react-toastify used across the app.
export const toast = {
success: (message: string) => add('success', message),
error: (message: string) => add('error', message),
info: (message: string) => add('info', message),
warning: (message: string) => add('warning', message)
};

View File

@@ -7,7 +7,6 @@ import {
useState
} from 'react';
import { Link } from 'react-router';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
@@ -28,6 +27,7 @@ import { callAction } from 'api/app';
import { dialogStyle } from '@/CustomTheme';
import { useRequest } from 'alova/client';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
const DocumentUploader = styled(Box)<{ active?: boolean }>(({ theme, active }) => ({

View File

@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel';
import { Box, Button, Typography } from '@mui/material';
@@ -7,6 +6,7 @@ import { Box, Button, Typography } from '@mui/material';
import * as SystemApi from 'api/system';
import { useRequest } from 'alova/client';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import DragNdrop from './DragNdrop';

View File

@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { FC } from 'react';
import { redirect } from 'react-router';
import { toast } from 'react-toastify';
import { callAction } from 'api/app';
import { ACCESS_TOKEN } from 'api/endpoints';
@@ -10,6 +9,7 @@ import * as AuthenticationApi from 'components/routing/authentication';
import { useRequest } from 'alova/client';
import { LoadingSpinner } from 'components';
import { verifyAuthorization } from 'components/routing/authentication';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
import type { Me, VersionsResponse } from 'types';
import type { RequiredChildrenProps } from 'utils';

View File

@@ -1,9 +1,9 @@
import { useCallback, useState } from 'react';
import { useBlocker } from 'react-router';
import { toast } from 'react-toastify';
import type { AlovaGenerics, Method } from 'alova';
import { useRequest } from 'alova/client';
import { toast } from 'components/toast';
import { useI18nContext } from 'i18n/i18n-react';
export interface RestRequestOptions<D> {