From d8f578b5ead099f487b24c9c3f69d6d5b044ea79 Mon Sep 17 00:00:00 2001 From: ze0s <43699394+zze0s@users.noreply.github.com> Date: Thu, 19 Dec 2024 16:12:29 +0100 Subject: [PATCH] feat(web): vendor react-hot-toast (#1883) * feat(web): vendor react-hot-toast * vendor react-hot-toast to be react 19 compatible * feat: remove react-hot-toast and add goober * chore: fix lint warnings --- web/package.json | 2 +- web/pnpm-lock.yaml | 30 +-- web/src/App.tsx | 2 +- web/src/api/QueryClient.tsx | 2 +- web/src/components/data-table/Cells.tsx | 2 +- web/src/components/fields/text.tsx | 2 +- web/src/components/header/Header.tsx | 2 +- .../hot-toast/components/checkmark.tsx | 61 +++++ .../components/hot-toast/components/error.tsx | 71 ++++++ .../hot-toast/components/loader.tsx | 26 +++ .../hot-toast/components/toast-bar.tsx | 111 +++++++++ .../hot-toast/components/toast-icon.tsx | 77 +++++++ .../hot-toast/components/toaster.tsx | 141 ++++++++++++ web/src/components/hot-toast/core/store.ts | 217 ++++++++++++++++++ web/src/components/hot-toast/core/toast.ts | 92 ++++++++ web/src/components/hot-toast/core/types.ts | 91 ++++++++ .../components/hot-toast/core/use-toaster.ts | 96 ++++++++ web/src/components/hot-toast/core/utils.ts | 19 ++ .../components/hot-toast/headless/index.ts | 21 ++ web/src/components/hot-toast/index.ts | 13 ++ web/src/components/notifications/Toast.tsx | 2 +- web/src/forms/filters/FilterAddForm.tsx | 2 +- web/src/forms/settings/APIKeyAddForm.tsx | 2 +- .../forms/settings/DownloadClientForms.tsx | 2 +- web/src/forms/settings/FeedForms.tsx | 2 +- web/src/forms/settings/IndexerForms.tsx | 2 +- web/src/forms/settings/IrcForms.tsx | 2 +- web/src/forms/settings/NotificationForms.tsx | 2 +- web/src/forms/settings/ProxyForms.tsx | 2 +- web/src/screens/Logs.tsx | 2 +- web/src/screens/auth/Login.tsx | 2 +- web/src/screens/filters/Details.tsx | 2 +- web/src/screens/filters/Importer.tsx | 2 +- web/src/screens/filters/List.tsx | 2 +- web/src/screens/filters/sections/Actions.tsx | 2 +- web/src/screens/settings/Account.tsx | 2 +- web/src/screens/settings/Api.tsx | 2 +- web/src/screens/settings/Application.tsx | 2 +- web/src/screens/settings/DownloadClient.tsx | 2 +- web/src/screens/settings/Feed.tsx | 2 +- web/src/screens/settings/Indexer.tsx | 2 +- web/src/screens/settings/Irc.tsx | 2 +- web/src/screens/settings/Logs.tsx | 2 +- web/src/screens/settings/Notifications.tsx | 2 +- web/src/screens/settings/Proxy.tsx | 2 +- web/src/screens/settings/Releases.tsx | 2 +- 46 files changed, 1071 insertions(+), 59 deletions(-) create mode 100644 web/src/components/hot-toast/components/checkmark.tsx create mode 100644 web/src/components/hot-toast/components/error.tsx create mode 100644 web/src/components/hot-toast/components/loader.tsx create mode 100644 web/src/components/hot-toast/components/toast-bar.tsx create mode 100644 web/src/components/hot-toast/components/toast-icon.tsx create mode 100644 web/src/components/hot-toast/components/toaster.tsx create mode 100644 web/src/components/hot-toast/core/store.ts create mode 100644 web/src/components/hot-toast/core/toast.ts create mode 100644 web/src/components/hot-toast/core/types.ts create mode 100644 web/src/components/hot-toast/core/use-toaster.ts create mode 100644 web/src/components/hot-toast/core/utils.ts create mode 100644 web/src/components/hot-toast/headless/index.ts create mode 100644 web/src/components/hot-toast/index.ts diff --git a/web/package.json b/web/package.json index 4b471f9..c0c25c6 100644 --- a/web/package.json +++ b/web/package.json @@ -55,13 +55,13 @@ "buffer": "^6.0.3", "date-fns": "^4.1.0", "formik": "^2.4.6", + "goober": "^2.1.16", "http-proxy-middleware": "^3.0.3", "postcss": "^8.4.49", "react": "^18.3.1", "react-debounce-input": "^3.3.0", "react-dom": "^18.3.1", "react-hook-form": "^7.53.2", - "react-hot-toast": "^2.4.1", "react-multi-select-component": "^4.3.4", "react-popper-tooltip": "^4.4.2", "react-ridge-state": "4.2.9", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index a718b49..c7e9567 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -79,6 +79,9 @@ importers: formik: specifier: ^2.4.6 version: 2.4.6(react@18.3.1) + goober: + specifier: ^2.1.16 + version: 2.1.16(csstype@3.1.2) http-proxy-middleware: specifier: ^3.0.3 version: 3.0.3 @@ -97,9 +100,6 @@ importers: react-hook-form: specifier: ^7.53.2 version: 7.53.2(react@18.3.1) - react-hot-toast: - specifier: ^2.4.1 - version: 2.4.1(csstype@3.1.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-multi-select-component: specifier: ^4.3.4 version: 4.3.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2165,11 +2165,6 @@ packages: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} - goober@2.1.13: - resolution: {integrity: sha512-jFj3BQeleOoy7t93E9rZ2de+ScC4lQICLwiAQmKMg9F6roKGaLSHoCDYKkWlSafg138jejvq/mTdvmnwDQgqoQ==} - peerDependencies: - csstype: 3.1.2 - goober@2.1.16: resolution: {integrity: sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==} peerDependencies: @@ -2826,13 +2821,6 @@ packages: peerDependencies: react: ^18.3.1 - react-hot-toast@2.4.1: - resolution: {integrity: sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==} - engines: {node: '>=10'} - peerDependencies: - react: ^18.3.1 - react-dom: '>=16' - react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -5812,10 +5800,6 @@ snapshots: define-properties: 1.2.1 gopd: 1.0.1 - goober@2.1.13(csstype@3.1.2): - dependencies: - csstype: 3.1.2 - goober@2.1.16(csstype@3.1.2): dependencies: csstype: 3.1.2 @@ -6402,14 +6386,6 @@ snapshots: dependencies: react: 18.3.1 - react-hot-toast@2.4.1(csstype@3.1.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - goober: 2.1.13(csstype@3.1.2) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - transitivePeerDependencies: - - csstype - react-is@16.13.1: {} react-multi-select-component@4.3.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): diff --git a/web/src/App.tsx b/web/src/App.tsx index d080921..1c737ce 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -6,7 +6,7 @@ import { useEffect } from "react"; import { RouterProvider } from "@tanstack/react-router" import { QueryClientProvider } from "@tanstack/react-query"; -import { Toaster } from "react-hot-toast"; +import { Toaster } from "@components/hot-toast"; import { Router } from "@app/routes"; import { routerBasePath } from "@utils"; import { queryClient } from "@api/QueryClient"; diff --git a/web/src/api/QueryClient.tsx b/web/src/api/QueryClient.tsx index fa0e85b..70e8e00 100644 --- a/web/src/api/QueryClient.tsx +++ b/web/src/api/QueryClient.tsx @@ -4,7 +4,7 @@ */ import { QueryCache, QueryClient } from "@tanstack/react-query"; -import { toast } from "react-hot-toast"; +import { toast } from "@components/hot-toast"; import Toast from "@components/notifications/Toast"; import { AuthContext } from "@utils/Context"; import { getRouteApi, redirect } from "@tanstack/react-router"; diff --git a/web/src/components/data-table/Cells.tsx b/web/src/components/data-table/Cells.tsx index 951ac58..aa2d709 100644 --- a/web/src/components/data-table/Cells.tsx +++ b/web/src/components/data-table/Cells.tsx @@ -4,7 +4,6 @@ */ import * as React from "react"; -import { toast } from "react-hot-toast"; import { formatDistanceToNowStrict } from "date-fns"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { CellContext } from "@tanstack/react-table"; @@ -21,6 +20,7 @@ import { APIClient } from "@api/APIClient"; import { FilterKeys } from "@api/query_keys"; import { classNames, humanFileSize, simplifyDate } from "@utils"; import { ExternalLink } from "../ExternalLink"; +import { toast } from "@components/hot-toast"; import Toast from "@components/notifications/Toast"; import { RingResizeSpinner } from "@components/Icons"; import { Tooltip } from "@components/tooltips/Tooltip"; diff --git a/web/src/components/fields/text.tsx b/web/src/components/fields/text.tsx index 4c4612b..e1582e3 100644 --- a/web/src/components/fields/text.tsx +++ b/web/src/components/fields/text.tsx @@ -6,7 +6,7 @@ import { useToggle } from "@hooks/hooks"; import { CheckIcon, DocumentDuplicateIcon, EyeIcon, EyeSlashIcon } from "@heroicons/react/24/outline"; import { useState } from "react"; -import { toast } from "react-hot-toast"; +import { toast } from "@components/hot-toast"; import Toast from "@components/notifications/Toast"; interface KeyFieldProps { diff --git a/web/src/components/header/Header.tsx b/web/src/components/header/Header.tsx index c45e35a..08633c2 100644 --- a/web/src/components/header/Header.tsx +++ b/web/src/components/header/Header.tsx @@ -3,13 +3,13 @@ * SPDX-License-Identifier: GPL-2.0-or-later */ -import toast from "react-hot-toast"; import { useMutation, useQuery } from "@tanstack/react-query"; import { getRouteApi, redirect } from "@tanstack/react-router"; import { Disclosure, DisclosureButton } from "@headlessui/react"; import { Bars3Icon, XMarkIcon, MegaphoneIcon } from "@heroicons/react/24/outline"; import { APIClient } from "@api/APIClient"; +import toast from "@components/hot-toast"; import Toast from "@components/notifications/Toast"; import { LeftNav } from "./LeftNav"; diff --git a/web/src/components/hot-toast/components/checkmark.tsx b/web/src/components/hot-toast/components/checkmark.tsx new file mode 100644 index 0000000..bffaa5c --- /dev/null +++ b/web/src/components/hot-toast/components/checkmark.tsx @@ -0,0 +1,61 @@ +import { styled, keyframes } from 'goober'; + +const circleAnimation = keyframes` +from { + transform: scale(0) rotate(45deg); + opacity: 0; +} +to { + transform: scale(1) rotate(45deg); + opacity: 1; +}`; + +const checkmarkAnimation = keyframes` +0% { + height: 0; + width: 0; + opacity: 0; +} +40% { + height: 0; + width: 6px; + opacity: 1; +} +100% { + opacity: 1; + height: 10px; +}`; + +export interface CheckmarkTheme { + primary?: string; + secondary?: string; +} + +export const CheckmarkIcon = styled('div')` + width: 20px; + opacity: 0; + height: 20px; + border-radius: 10px; + background: ${(p) => p.primary || '#61d345'}; + position: relative; + transform: rotate(45deg); + + animation: ${circleAnimation} 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) + forwards; + animation-delay: 100ms; + &:after { + content: ''; + box-sizing: border-box; + animation: ${checkmarkAnimation} 0.2s ease-out forwards; + opacity: 0; + animation-delay: 200ms; + position: absolute; + border-right: 2px solid; + border-bottom: 2px solid; + border-color: ${(p) => p.secondary || '#fff'}; + bottom: 6px; + left: 6px; + height: 10px; + width: 6px; + } +`; diff --git a/web/src/components/hot-toast/components/error.tsx b/web/src/components/hot-toast/components/error.tsx new file mode 100644 index 0000000..96da602 --- /dev/null +++ b/web/src/components/hot-toast/components/error.tsx @@ -0,0 +1,71 @@ +import { styled, keyframes } from 'goober'; + +const circleAnimation = keyframes` +from { + transform: scale(0) rotate(45deg); + opacity: 0; +} +to { + transform: scale(1) rotate(45deg); + opacity: 1; +}`; + +const firstLineAnimation = keyframes` +from { + transform: scale(0); + opacity: 0; +} +to { + transform: scale(1); + opacity: 1; +}`; + +const secondLineAnimation = keyframes` +from { + transform: scale(0) rotate(90deg); + opacity: 0; +} +to { + transform: scale(1) rotate(90deg); + opacity: 1; +}`; + +export interface ErrorTheme { + primary?: string; + secondary?: string; +} + +export const ErrorIcon = styled('div')` + width: 20px; + opacity: 0; + height: 20px; + border-radius: 10px; + background: ${(p) => p.primary || '#ff4b4b'}; + position: relative; + transform: rotate(45deg); + + animation: ${circleAnimation} 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) + forwards; + animation-delay: 100ms; + + &:after, + &:before { + content: ''; + animation: ${firstLineAnimation} 0.15s ease-out forwards; + animation-delay: 150ms; + position: absolute; + border-radius: 3px; + opacity: 0; + background: ${(p) => p.secondary || '#fff'}; + bottom: 9px; + left: 4px; + height: 2px; + width: 12px; + } + + &:before { + animation: ${secondLineAnimation} 0.15s ease-out forwards; + animation-delay: 180ms; + transform: rotate(90deg); + } +`; diff --git a/web/src/components/hot-toast/components/loader.tsx b/web/src/components/hot-toast/components/loader.tsx new file mode 100644 index 0000000..49af0e7 --- /dev/null +++ b/web/src/components/hot-toast/components/loader.tsx @@ -0,0 +1,26 @@ +import { styled, keyframes } from 'goober'; + +const rotate = keyframes` + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +`; + +export interface LoaderTheme { + primary?: string; + secondary?: string; +} + +export const LoaderIcon = styled('div')` + width: 12px; + height: 12px; + box-sizing: border-box; + border: 2px solid; + border-radius: 100%; + border-color: ${(p) => p.secondary || '#e0e0e0'}; + border-right-color: ${(p) => p.primary || '#616161'}; + animation: ${rotate} 1s linear infinite; +`; diff --git a/web/src/components/hot-toast/components/toast-bar.tsx b/web/src/components/hot-toast/components/toast-bar.tsx new file mode 100644 index 0000000..9c61374 --- /dev/null +++ b/web/src/components/hot-toast/components/toast-bar.tsx @@ -0,0 +1,111 @@ +import * as React from 'react'; +import { styled, keyframes } from 'goober'; + +import { Toast, ToastPosition, resolveValue, Renderable } from '../core/types'; +import { ToastIcon } from './toast-icon'; +import { prefersReducedMotion } from '../core/utils'; + +const enterAnimation = (factor: number) => ` +0% {transform: translate3d(0,${factor * -200}%,0) scale(.6); opacity:.5;} +100% {transform: translate3d(0,0,0) scale(1); opacity:1;} +`; + +const exitAnimation = (factor: number) => ` +0% {transform: translate3d(0,0,-1px) scale(1); opacity:1;} +100% {transform: translate3d(0,${factor * -150}%,-1px) scale(.6); opacity:0;} +`; + +const fadeInAnimation = `0%{opacity:0;} 100%{opacity:1;}`; +const fadeOutAnimation = `0%{opacity:1;} 100%{opacity:0;}`; + +const ToastBarBase = styled('div')` + display: flex; + align-items: center; + background: #fff; + color: #363636; + line-height: 1.3; + will-change: transform; + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.1), 0 3px 3px rgba(0, 0, 0, 0.05); + max-width: 350px; + pointer-events: auto; + padding: 8px 10px; + border-radius: 8px; +`; + +const Message = styled('div')` + display: flex; + justify-content: center; + margin: 4px 10px; + color: inherit; + flex: 1 1 auto; + white-space: pre-line; +`; + +interface ToastBarProps { + toast: Toast; + position?: ToastPosition; + style?: React.CSSProperties; + children?: (components: { + icon: Renderable; + message: Renderable; + }) => Renderable; +} + +const getAnimationStyle = ( + position: ToastPosition, + visible: boolean +): React.CSSProperties => { + const top = position.includes('top'); + const factor = top ? 1 : -1; + + const [enter, exit] = prefersReducedMotion() + ? [fadeInAnimation, fadeOutAnimation] + : [enterAnimation(factor), exitAnimation(factor)]; + + return { + animation: visible + ? `${keyframes(enter)} 0.35s cubic-bezier(.21,1.02,.73,1) forwards` + : `${keyframes(exit)} 0.4s forwards cubic-bezier(.06,.71,.55,1)`, + }; +}; + +export const ToastBar: React.FC = React.memo( + ({ toast, position, style, children }) => { + const animationStyle: React.CSSProperties = toast.height + ? getAnimationStyle( + toast.position || position || 'top-center', + toast.visible + ) + : { opacity: 0 }; + + const icon = ; + const message = ( + + {resolveValue(toast.message, toast)} + + ); + + return ( + + {typeof children === 'function' ? ( + children({ + icon, + message, + }) + ) : ( + <> + {icon} + {message} + + )} + + ); + } +); diff --git a/web/src/components/hot-toast/components/toast-icon.tsx b/web/src/components/hot-toast/components/toast-icon.tsx new file mode 100644 index 0000000..d7e3677 --- /dev/null +++ b/web/src/components/hot-toast/components/toast-icon.tsx @@ -0,0 +1,77 @@ +import * as React from 'react'; +import { styled, keyframes } from 'goober'; + +import { Toast } from '../core/types'; +import { ErrorIcon, ErrorTheme } from './error'; +import { LoaderIcon, LoaderTheme } from './loader'; +import { CheckmarkIcon, CheckmarkTheme } from './checkmark'; + +const StatusWrapper = styled('div')` + position: absolute; +`; + +const IndicatorWrapper = styled('div')` + position: relative; + display: flex; + justify-content: center; + align-items: center; + min-width: 20px; + min-height: 20px; +`; + +const enter = keyframes` +from { + transform: scale(0.6); + opacity: 0.4; +} +to { + transform: scale(1); + opacity: 1; +}`; + +export const AnimatedIconWrapper = styled('div')` + position: relative; + transform: scale(0.6); + opacity: 0.4; + min-width: 20px; + animation: ${enter} 0.3s 0.12s cubic-bezier(0.175, 0.885, 0.32, 1.275) + forwards; +`; + +export type IconThemes = Partial<{ + success: CheckmarkTheme; + error: ErrorTheme; + loading: LoaderTheme; +}>; + +export const ToastIcon: React.FC<{ + toast: Toast; +}> = ({ toast }) => { + const { icon, type, iconTheme } = toast; + if (icon !== undefined) { + if (typeof icon === 'string') { + return {icon}; + } else { + return icon; + } + } + + if (type === 'blank') { + return null; + } + + return ( + + + {type !== 'loading' && ( + + {type === 'error' ? ( + + ) : ( + + )} + + )} + + ); +}; diff --git a/web/src/components/hot-toast/components/toaster.tsx b/web/src/components/hot-toast/components/toaster.tsx new file mode 100644 index 0000000..9b44433 --- /dev/null +++ b/web/src/components/hot-toast/components/toaster.tsx @@ -0,0 +1,141 @@ +import { css, setup } from 'goober'; +import * as React from 'react'; +import { + resolveValue, + ToasterProps, + ToastPosition, + ToastWrapperProps, +} from '../core/types'; +import { useToaster } from '../core/use-toaster'; +import { prefersReducedMotion } from '../core/utils'; +import { ToastBar } from './toast-bar'; + +setup(React.createElement); + +const ToastWrapper = ({ + id, + className, + style, + onHeightUpdate, + children, + }: ToastWrapperProps) => { + const ref = React.useCallback( + (el: HTMLElement | null) => { + if (el) { + const updateHeight = () => { + const height = el.getBoundingClientRect().height; + onHeightUpdate(id, height); + }; + updateHeight(); + new MutationObserver(updateHeight).observe(el, { + subtree: true, + childList: true, + characterData: true, + }); + } + }, + [id, onHeightUpdate] + ); + + return ( +
+ {children} +
+ ); +}; + +const getPositionStyle = ( + position: ToastPosition, + offset: number +): React.CSSProperties => { + const top = position.includes('top'); + const verticalStyle: React.CSSProperties = top ? { top: 0 } : { bottom: 0 }; + const horizontalStyle: React.CSSProperties = position.includes('center') + ? { + justifyContent: 'center', + } + : position.includes('right') + ? { + justifyContent: 'flex-end', + } + : {}; + return { + left: 0, + right: 0, + display: 'flex', + position: 'absolute', + transition: prefersReducedMotion() + ? undefined + : `all 230ms cubic-bezier(.21,1.02,.73,1)`, + transform: `translateY(${offset * (top ? 1 : -1)}px)`, + ...verticalStyle, + ...horizontalStyle, + }; +}; + +const activeClass = css` + z-index: 9999; + > * { + pointer-events: auto; + } +`; + +const DEFAULT_OFFSET = 16; + +export const Toaster: React.FC = ({ + reverseOrder, + position = 'top-center', + toastOptions, + gutter, + children, + containerStyle, + containerClassName, + }) => { + const { toasts, handlers } = useToaster(toastOptions); + + return ( +
+ {toasts.map((t) => { + const toastPosition = t.position || position; + const offset = handlers.calculateOffset(t, { + reverseOrder, + gutter, + defaultPosition: position, + }); + const positionStyle = getPositionStyle(toastPosition, offset); + + return ( + + {t.type === 'custom' ? ( + resolveValue(t.message, t) + ) : children ? ( + children(t) + ) : ( + + )} + + ); + })} +
+ ); +}; diff --git a/web/src/components/hot-toast/core/store.ts b/web/src/components/hot-toast/core/store.ts new file mode 100644 index 0000000..c10f086 --- /dev/null +++ b/web/src/components/hot-toast/core/store.ts @@ -0,0 +1,217 @@ +import { useEffect, useState } from 'react'; +import { DefaultToastOptions, Toast, ToastType } from './types'; + +const TOAST_LIMIT = 20; + +export enum ActionType { + ADD_TOAST, + UPDATE_TOAST, + UPSERT_TOAST, + DISMISS_TOAST, + REMOVE_TOAST, + START_PAUSE, + END_PAUSE, +} + +type Action = + | { + type: ActionType.ADD_TOAST; + toast: Toast; +} + | { + type: ActionType.UPSERT_TOAST; + toast: Toast; +} + | { + type: ActionType.UPDATE_TOAST; + toast: Partial; +} + | { + type: ActionType.DISMISS_TOAST; + toastId?: string; +} + | { + type: ActionType.REMOVE_TOAST; + toastId?: string; +} + | { + type: ActionType.START_PAUSE; + time: number; +} + | { + type: ActionType.END_PAUSE; + time: number; +}; + +interface State { + toasts: Toast[]; + pausedAt: number | undefined; +} + +const toastTimeouts = new Map>(); + +export const TOAST_EXPIRE_DISMISS_DELAY = 1000; + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return; + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId); + dispatch({ + type: ActionType.REMOVE_TOAST, + toastId: toastId, + }); + }, TOAST_EXPIRE_DISMISS_DELAY); + + toastTimeouts.set(toastId, timeout); +}; + +const clearFromRemoveQueue = (toastId: string) => { + const timeout = toastTimeouts.get(toastId); + if (timeout) { + clearTimeout(timeout); + } +}; + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case ActionType.ADD_TOAST: + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + }; + + case ActionType.UPDATE_TOAST: + // ! Side effects ! + if (action.toast.id) { + clearFromRemoveQueue(action.toast.id); + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + }; + + case ActionType.UPSERT_TOAST: { + const { toast } = action; + return state.toasts.find((t) => t.id === toast.id) + ? reducer(state, { type: ActionType.UPDATE_TOAST, toast }) + : reducer(state, { type: ActionType.ADD_TOAST, toast }); + } + + case ActionType.DISMISS_TOAST: { + const { toastId } = action; + + // ! Side effects ! - This could be execrated into a dismissToast() action, but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId); + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id); + }); + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + visible: false, + } + : t + ), + }; + } + + case ActionType.REMOVE_TOAST: + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + }; + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + }; + + case ActionType.START_PAUSE: + return { + ...state, + pausedAt: action.time, + }; + + case ActionType.END_PAUSE: { + const diff = action.time - (state.pausedAt || 0); + + return { + ...state, + pausedAt: undefined, + toasts: state.toasts.map((t) => ({ + ...t, + pauseDuration: t.pauseDuration + diff, + })), + }; + } + } +}; + +const listeners: Array<(state: State) => void> = []; + +let memoryState: State = { toasts: [], pausedAt: undefined }; + +export const dispatch = (action: Action) => { + memoryState = reducer(memoryState, action); + listeners.forEach((listener) => { + listener(memoryState); + }); +}; + +export const defaultTimeouts: { + [key in ToastType]: number; +} = { + blank: 4000, + error: 4000, + success: 2000, + loading: Infinity, + custom: 4000, +}; + +export const useStore = (toastOptions: DefaultToastOptions = {}): State => { + const [state, setState] = useState(memoryState); + useEffect(() => { + listeners.push(setState); + return () => { + const index = listeners.indexOf(setState); + if (index > -1) { + listeners.splice(index, 1); + } + }; + }, [state]); + + const mergedToasts = state.toasts.map((t) => ({ + ...toastOptions, + ...toastOptions[t.type], + ...t, + duration: + t.duration || + toastOptions[t.type]?.duration || + toastOptions?.duration || + defaultTimeouts[t.type], + style: { + ...toastOptions.style, + ...toastOptions[t.type]?.style, + ...t.style, + }, + })); + + return { + ...state, + toasts: mergedToasts, + }; +}; diff --git a/web/src/components/hot-toast/core/toast.ts b/web/src/components/hot-toast/core/toast.ts new file mode 100644 index 0000000..127b1cd --- /dev/null +++ b/web/src/components/hot-toast/core/toast.ts @@ -0,0 +1,92 @@ +import { + Renderable, + Toast, + ToastOptions, + ToastType, + DefaultToastOptions, + ValueOrFunction, + resolveValue, +} from './types'; +import { genId } from './utils'; +import { dispatch, ActionType } from './store'; + +type Message = ValueOrFunction; + +type ToastHandler = (message: Message, options?: ToastOptions) => string; + +const createToast = ( + message: Message, + type: ToastType = 'blank', + opts?: ToastOptions +): Toast => ({ + createdAt: Date.now(), + visible: true, + type, + ariaProps: { + role: 'status', + 'aria-live': 'polite', + }, + message, + pauseDuration: 0, + ...opts, + id: opts?.id || genId(), +}); + +const createHandler = + (type?: ToastType): ToastHandler => + (message, options) => { + const toast = createToast(message, type, options); + dispatch({ type: ActionType.UPSERT_TOAST, toast }); + return toast.id; + }; + +const toast = (message: Message, opts?: ToastOptions) => + createHandler('blank')(message, opts); + +toast.error = createHandler('error'); +toast.success = createHandler('success'); +toast.loading = createHandler('loading'); +toast.custom = createHandler('custom'); + +toast.dismiss = (toastId?: string) => { + dispatch({ + type: ActionType.DISMISS_TOAST, + toastId, + }); +}; + +toast.remove = (toastId?: string) => + dispatch({ type: ActionType.REMOVE_TOAST, toastId }); + +toast.promise = ( + promise: Promise, + msgs: { + loading: Renderable; + success: ValueOrFunction; + error: ValueOrFunction; + }, + opts?: DefaultToastOptions +) => { + const id = toast.loading(msgs.loading, { ...opts, ...opts?.loading }); + + promise + .then((p) => { + toast.success(resolveValue(msgs.success, p), { + id, + ...opts, + ...opts?.success, + }); + return p; + }) + .catch((e) => { + toast.error(resolveValue(msgs.error, e), { + id, + ...opts, + ...opts?.error, + }); + }); + + return promise; +}; + +export { toast }; diff --git a/web/src/components/hot-toast/core/types.ts b/web/src/components/hot-toast/core/types.ts new file mode 100644 index 0000000..ebbbfb5 --- /dev/null +++ b/web/src/components/hot-toast/core/types.ts @@ -0,0 +1,91 @@ +import React, { CSSProperties } from 'react'; + +export type ToastType = 'success' | 'error' | 'loading' | 'blank' | 'custom'; +export type ToastPosition = + | 'top-left' + | 'top-center' + | 'top-right' + | 'bottom-left' + | 'bottom-center' + | 'bottom-right'; + +export type Renderable = React.JSX.Element | string | null; + +export interface IconTheme { + primary: string; + secondary: string; +} + +export type ValueFunction = (arg: TArg) => TValue; +export type ValueOrFunction = + | TValue + | ValueFunction; + +const isFunction = ( + valOrFunction: ValueOrFunction +): valOrFunction is ValueFunction => + typeof valOrFunction === 'function'; + +export const resolveValue = ( + valOrFunction: ValueOrFunction, + arg: TArg +): TValue => (isFunction(valOrFunction) ? valOrFunction(arg) : valOrFunction); + +export interface Toast { + type: ToastType; + id: string; + message: ValueOrFunction; + icon?: Renderable; + duration?: number; + pauseDuration: number; + position?: ToastPosition; + + ariaProps: { + role: 'status' | 'alert'; + 'aria-live': 'assertive' | 'off' | 'polite'; + }; + + style?: CSSProperties; + className?: string; + iconTheme?: IconTheme; + + createdAt: number; + visible: boolean; + height?: number; +} + +export type ToastOptions = Partial< + Pick< + Toast, + | 'id' + | 'icon' + | 'duration' + | 'ariaProps' + | 'className' + | 'style' + | 'position' + | 'iconTheme' + > +>; + +export type DefaultToastOptions = ToastOptions & { + [key in ToastType]?: ToastOptions; +}; + +export interface ToasterProps { + position?: ToastPosition; + toastOptions?: DefaultToastOptions; + reverseOrder?: boolean; + gutter?: number; + containerStyle?: React.CSSProperties; + containerClassName?: string; + children?: (toast: Toast) => React.JSX.Element; +} + +export interface ToastWrapperProps { + id: string; + className?: string; + style?: React.CSSProperties; + onHeightUpdate: (id: string, height: number) => void; + children?: React.ReactNode; +} diff --git a/web/src/components/hot-toast/core/use-toaster.ts b/web/src/components/hot-toast/core/use-toaster.ts new file mode 100644 index 0000000..d086b05 --- /dev/null +++ b/web/src/components/hot-toast/core/use-toaster.ts @@ -0,0 +1,96 @@ +import { useEffect, useCallback } from 'react'; +import { dispatch, ActionType, useStore } from './store'; +import { toast } from './toast'; +import { DefaultToastOptions, Toast, ToastPosition } from './types'; + +const updateHeight = (toastId: string, height: number) => { + dispatch({ + type: ActionType.UPDATE_TOAST, + toast: { id: toastId, height }, + }); +}; +const startPause = () => { + dispatch({ + type: ActionType.START_PAUSE, + time: Date.now(), + }); +}; + +export const useToaster = (toastOptions?: DefaultToastOptions) => { + const { toasts, pausedAt } = useStore(toastOptions); + + useEffect(() => { + if (pausedAt) { + return; + } + + const now = Date.now(); + const timeouts = toasts.map((t) => { + if (t.duration === Infinity) { + return; + } + + const durationLeft = + (t.duration || 0) + t.pauseDuration - (now - t.createdAt); + + if (durationLeft < 0) { + if (t.visible) { + toast.dismiss(t.id); + } + return; + } + return setTimeout(() => toast.dismiss(t.id), durationLeft); + }); + + return () => { + timeouts.forEach((timeout) => timeout && clearTimeout(timeout)); + }; + }, [toasts, pausedAt]); + + const endPause = useCallback(() => { + if (pausedAt) { + dispatch({ type: ActionType.END_PAUSE, time: Date.now() }); + } + }, [pausedAt]); + + const calculateOffset = useCallback( + ( + toast: Toast, + opts?: { + reverseOrder?: boolean; + gutter?: number; + defaultPosition?: ToastPosition; + } + ) => { + const { reverseOrder = false, gutter = 8, defaultPosition } = opts || {}; + + const relevantToasts = toasts.filter( + (t) => + (t.position || defaultPosition) === + (toast.position || defaultPosition) && t.height + ); + const toastIndex = relevantToasts.findIndex((t) => t.id === toast.id); + const toastsBefore = relevantToasts.filter( + (toast, i) => i < toastIndex && toast.visible + ).length; + + const offset = relevantToasts + .filter((t) => t.visible) + .slice(...(reverseOrder ? [toastsBefore + 1] : [0, toastsBefore])) + .reduce((acc, t) => acc + (t.height || 0) + gutter, 0); + + return offset; + }, + [toasts] + ); + + return { + toasts, + handlers: { + updateHeight, + startPause, + endPause, + calculateOffset, + }, + }; +}; diff --git a/web/src/components/hot-toast/core/utils.ts b/web/src/components/hot-toast/core/utils.ts new file mode 100644 index 0000000..f57160f --- /dev/null +++ b/web/src/components/hot-toast/core/utils.ts @@ -0,0 +1,19 @@ +export const genId = (() => { + let count = 0; + return () => { + return (++count).toString(); + }; +})(); + +export const prefersReducedMotion = (() => { + // Cache result + let shouldReduceMotion: boolean | undefined = undefined; + + return () => { + if (shouldReduceMotion === undefined && typeof window !== 'undefined') { + const mediaQuery = matchMedia('(prefers-reduced-motion: reduce)'); + shouldReduceMotion = !mediaQuery || mediaQuery.matches; + } + return shouldReduceMotion; + }; +})(); diff --git a/web/src/components/hot-toast/headless/index.ts b/web/src/components/hot-toast/headless/index.ts new file mode 100644 index 0000000..2de75ca --- /dev/null +++ b/web/src/components/hot-toast/headless/index.ts @@ -0,0 +1,21 @@ +import { toast } from '../core/toast'; + +export type { + DefaultToastOptions, + IconTheme, + Renderable, + Toast, + ToasterProps, + ToastOptions, + ToastPosition, + ToastType, + ValueFunction, + ValueOrFunction, +} from '../core/types'; + +export { resolveValue } from '../core/types'; +export { useToaster } from '../core/use-toaster'; +export { useStore as useToasterStore } from '../core/store'; + +export { toast }; +export default toast; diff --git a/web/src/components/hot-toast/index.ts b/web/src/components/hot-toast/index.ts new file mode 100644 index 0000000..1968353 --- /dev/null +++ b/web/src/components/hot-toast/index.ts @@ -0,0 +1,13 @@ +import { toast } from './core/toast'; + +export * from './headless'; + +export { ToastBar } from './components/toast-bar'; +export { ToastIcon } from './components/toast-icon'; +export { Toaster } from './components/toaster'; +export { CheckmarkIcon } from './components/checkmark'; +export { ErrorIcon } from './components/error'; +export { LoaderIcon } from './components/loader'; + +export { toast }; +export default toast; diff --git a/web/src/components/notifications/Toast.tsx b/web/src/components/notifications/Toast.tsx index 11081fb..25d2824 100644 --- a/web/src/components/notifications/Toast.tsx +++ b/web/src/components/notifications/Toast.tsx @@ -5,7 +5,7 @@ import { FC } from "react"; import { CheckCircleIcon, ExclamationCircleIcon, ExclamationTriangleIcon, InformationCircleIcon, XMarkIcon } from "@heroicons/react/24/solid"; -import { toast, Toast as Tooast } from "react-hot-toast"; +import { toast, Toast as Tooast } from "@components/hot-toast"; import { classNames } from "@utils"; type Props = { diff --git a/web/src/forms/filters/FilterAddForm.tsx b/web/src/forms/filters/FilterAddForm.tsx index cf88479..9f24e0d 100644 --- a/web/src/forms/filters/FilterAddForm.tsx +++ b/web/src/forms/filters/FilterAddForm.tsx @@ -6,7 +6,6 @@ import { Fragment, useRef } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; -import { toast } from "react-hot-toast"; import { XMarkIcon } from "@heroicons/react/24/solid"; import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from "@headlessui/react"; import type { FieldProps } from "formik"; @@ -15,6 +14,7 @@ import { Field, Form, Formik, FormikErrors, FormikValues } from "formik"; import { APIClient } from "@api/APIClient"; import { FilterKeys } from "@api/query_keys"; import { DEBUG } from "@components/debug"; +import { toast } from "@components/hot-toast"; import Toast from "@components/notifications/Toast"; diff --git a/web/src/forms/settings/APIKeyAddForm.tsx b/web/src/forms/settings/APIKeyAddForm.tsx index bb4bad5..7c2a975 100644 --- a/web/src/forms/settings/APIKeyAddForm.tsx +++ b/web/src/forms/settings/APIKeyAddForm.tsx @@ -5,7 +5,6 @@ import { Fragment } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { toast } from "react-hot-toast"; import { XMarkIcon } from "@heroicons/react/24/solid"; import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from "@headlessui/react"; import type { FieldProps } from "formik"; @@ -14,6 +13,7 @@ import { Field, Form, Formik, FormikErrors, FormikValues } from "formik"; import { APIClient } from "@api/APIClient"; import { ApiKeys } from "@api/query_keys"; import { DEBUG } from "@components/debug"; +import { toast } from "@components/hot-toast"; import Toast from "@components/notifications/Toast"; interface apiKeyAddFormProps { diff --git a/web/src/forms/settings/DownloadClientForms.tsx b/web/src/forms/settings/DownloadClientForms.tsx index 88e38a8..87cfdb5 100644 --- a/web/src/forms/settings/DownloadClientForms.tsx +++ b/web/src/forms/settings/DownloadClientForms.tsx @@ -8,13 +8,13 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from "@headlessui/react"; import { XMarkIcon } from "@heroicons/react/24/solid"; import { Form, Formik, useFormikContext } from "formik"; -import { toast } from "react-hot-toast"; import { classNames, sleep } from "@utils"; import { DEBUG } from "@components/debug"; import { APIClient } from "@api/APIClient"; import { DownloadClientKeys } from "@api/query_keys"; import { DownloadClientAuthType, DownloadClientTypeOptions, DownloadRuleConditionOptions } from "@domain/constants"; +import { toast } from "@components/hot-toast"; import Toast from "@components/notifications/Toast"; import { useToggle } from "@hooks/hooks"; import { DeleteModal } from "@components/modals"; diff --git a/web/src/forms/settings/FeedForms.tsx b/web/src/forms/settings/FeedForms.tsx index ffbde37..bed1f0e 100644 --- a/web/src/forms/settings/FeedForms.tsx +++ b/web/src/forms/settings/FeedForms.tsx @@ -5,11 +5,11 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { toast } from "react-hot-toast"; import { useFormikContext } from "formik"; import { APIClient } from "@api/APIClient"; import { FeedKeys } from "@api/query_keys"; +import { toast } from "@components/hot-toast"; import Toast from "@components/notifications/Toast"; import { SlideOver } from "@components/panels"; import { NumberFieldWide, PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "@components/inputs"; diff --git a/web/src/forms/settings/IndexerForms.tsx b/web/src/forms/settings/IndexerForms.tsx index dc59339..bc894de 100644 --- a/web/src/forms/settings/IndexerForms.tsx +++ b/web/src/forms/settings/IndexerForms.tsx @@ -4,7 +4,6 @@ */ import { Fragment, useState } from "react"; -import { toast } from "react-hot-toast"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import Select from "react-select"; import type { FieldProps } from "formik"; @@ -18,6 +17,7 @@ import { APIClient } from "@api/APIClient"; import { FeedKeys, IndexerKeys, ReleaseKeys } from "@api/query_keys"; import { IndexersSchemaQueryOptions, ProxiesQueryOptions } from "@api/queries"; import { SlideOver } from "@components/panels"; +import { toast } from "@components/hot-toast"; import Toast from "@components/notifications/Toast"; import { PasswordFieldWide, SwitchButton, SwitchGroupWide, TextFieldWide } from "@components/inputs"; import { SelectFieldBasic, SelectFieldCreatable } from "@components/inputs/select_wide"; diff --git a/web/src/forms/settings/IrcForms.tsx b/web/src/forms/settings/IrcForms.tsx index ff1a065..d00a2bf 100644 --- a/web/src/forms/settings/IrcForms.tsx +++ b/web/src/forms/settings/IrcForms.tsx @@ -4,7 +4,6 @@ */ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { toast } from "react-hot-toast"; import { XMarkIcon } from "@heroicons/react/24/solid"; import type { FieldProps } from "formik"; import type { FieldArrayRenderProps } from "formik"; @@ -18,6 +17,7 @@ import { APIClient } from "@api/APIClient"; import { IrcKeys } from "@api/query_keys"; import { NumberFieldWide, PasswordFieldWide, SwitchButton, SwitchGroupWide, TextFieldWide } from "@components/inputs"; import { SlideOver } from "@components/panels"; +import { toast } from "@components/hot-toast"; import Toast from "@components/notifications/Toast"; import * as common from "@components/inputs/common"; import { classNames } from "@utils"; diff --git a/web/src/forms/settings/NotificationForms.tsx b/web/src/forms/settings/NotificationForms.tsx index 7ca2d34..9eaca02 100644 --- a/web/src/forms/settings/NotificationForms.tsx +++ b/web/src/forms/settings/NotificationForms.tsx @@ -10,7 +10,6 @@ import { Field, Form, Formik, FormikErrors, FormikValues } from "formik"; import { XMarkIcon } from "@heroicons/react/24/solid"; import Select from "react-select"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { toast } from "react-hot-toast"; import { APIClient } from "@api/APIClient"; import { NotificationKeys } from "@api/query_keys"; @@ -18,6 +17,7 @@ import { EventOptions, NotificationTypeOptions, SelectOption } from "@domain/con import { DEBUG } from "@components/debug"; import { SlideOver } from "@components/panels"; import { ExternalLink } from "@components/ExternalLink"; +import { toast } from "@components/hot-toast"; import Toast from "@components/notifications/Toast"; import * as common from "@components/inputs/common"; import { NumberFieldWide, PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "@components/inputs"; diff --git a/web/src/forms/settings/ProxyForms.tsx b/web/src/forms/settings/ProxyForms.tsx index 724564b..06882d7 100644 --- a/web/src/forms/settings/ProxyForms.tsx +++ b/web/src/forms/settings/ProxyForms.tsx @@ -8,7 +8,6 @@ import { Form, Formik, FormikValues } from "formik"; import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from "@headlessui/react"; import { XMarkIcon } from "@heroicons/react/24/solid"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { toast } from "react-hot-toast"; import { AddProps } from "@forms/settings/IndexerForms"; import { DEBUG } from "@components/debug.tsx"; @@ -17,6 +16,7 @@ import { SelectFieldBasic } from "@components/inputs/select_wide"; import { ProxyTypeOptions } from "@domain/constants"; import { APIClient } from "@api/APIClient"; import { ProxyKeys } from "@api/query_keys"; +import { toast } from "@components/hot-toast"; import Toast from "@components/notifications/Toast"; import { SlideOver } from "@components/panels"; diff --git a/web/src/screens/Logs.tsx b/web/src/screens/Logs.tsx index 7e76cbd..a311b22 100644 --- a/web/src/screens/Logs.tsx +++ b/web/src/screens/Logs.tsx @@ -13,7 +13,6 @@ import { } from "@heroicons/react/24/outline"; import { ExclamationCircleIcon } from "@heroicons/react/24/solid"; import { format } from "date-fns/format"; -import { toast } from "react-hot-toast"; import { APIClient } from "@api/APIClient"; import { Checkbox } from "@components/Checkbox"; @@ -21,6 +20,7 @@ import { baseUrl, classNames, simplifyDate } from "@utils"; import { SettingsContext } from "@utils/Context"; import { EmptySimple } from "@components/emptystates"; import { RingResizeSpinner } from "@components/Icons"; +import { toast } from "@components/hot-toast"; import Toast from "@components/notifications/Toast"; type LogEvent = { diff --git a/web/src/screens/auth/Login.tsx b/web/src/screens/auth/Login.tsx index b297914..c1c68c9 100644 --- a/web/src/screens/auth/Login.tsx +++ b/web/src/screens/auth/Login.tsx @@ -7,13 +7,13 @@ import React, { useEffect } from "react"; import { useForm } from "react-hook-form"; import { useMutation, useQuery, useQueryErrorResetBoundary } from "@tanstack/react-query"; import { getRouteApi, useRouter } from "@tanstack/react-router"; -import toast from "react-hot-toast"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faOpenid } from "@fortawesome/free-brands-svg-icons"; import { RocketLaunchIcon } from "@heroicons/react/24/outline"; import { APIClient } from "@api/APIClient"; +import toast from "@components/hot-toast"; import Toast from "@components/notifications/Toast"; import { Tooltip } from "@components/tooltips/Tooltip"; import { PasswordInput, TextInput } from "@components/inputs/text"; diff --git a/web/src/screens/filters/Details.tsx b/web/src/screens/filters/Details.tsx index 111a9aa..0c21ede 100644 --- a/web/src/screens/filters/Details.tsx +++ b/web/src/screens/filters/Details.tsx @@ -9,7 +9,6 @@ import { getRouteApi, Link, Outlet, useNavigate } from "@tanstack/react-router"; import { Form, Formik, useFormikContext } from "formik"; import type { FormikErrors, FormikValues } from "formik"; import { z } from "zod"; -import { toast } from "react-hot-toast"; import { toFormikValidationSchema } from "zod-formik-adapter"; import { ChevronRightIcon } from "@heroicons/react/24/solid"; @@ -21,6 +20,7 @@ import { classNames } from "@utils"; import { DOWNLOAD_CLIENTS } from "@domain/constants"; import { DEBUG } from "@components/debug"; +import { toast } from "@components/hot-toast"; import Toast from "@components/notifications/Toast"; import { DeleteModal } from "@components/modals"; diff --git a/web/src/screens/filters/Importer.tsx b/web/src/screens/filters/Importer.tsx index 7f60654..b6c0710 100644 --- a/web/src/screens/filters/Importer.tsx +++ b/web/src/screens/filters/Importer.tsx @@ -6,10 +6,10 @@ import { Fragment, useRef, useState } from "react"; import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from "@headlessui/react"; import { useQueryClient } from "@tanstack/react-query"; -import toast from "react-hot-toast"; import { APIClient } from "@api/APIClient"; import { FilterKeys } from "@api/query_keys"; +import toast from "@components/hot-toast"; import Toast from "@components/notifications/Toast"; import { AutodlIrssiConfigParser } from "./_configParser"; diff --git a/web/src/screens/filters/List.tsx b/web/src/screens/filters/List.tsx index 2d3eeea..2ce7e8c 100644 --- a/web/src/screens/filters/List.tsx +++ b/web/src/screens/filters/List.tsx @@ -5,7 +5,6 @@ import { Dispatch, FC, Fragment, MouseEventHandler, useCallback, useEffect, useReducer, useRef, useState } from "react"; import { Link } from '@tanstack/react-router' -import { toast } from "react-hot-toast"; import { Listbox, ListboxButton, @@ -38,6 +37,7 @@ import { useToggle } from "@hooks/hooks"; import { APIClient } from "@api/APIClient"; import { FilterKeys } from "@api/query_keys"; import { FiltersQueryOptions, IndexersOptionsQueryOptions } from "@api/queries"; +import { toast } from "@components/hot-toast"; import Toast from "@components/notifications/Toast"; import { EmptyListState } from "@components/emptystates"; import { DeleteModal } from "@components/modals"; diff --git a/web/src/screens/filters/sections/Actions.tsx b/web/src/screens/filters/sections/Actions.tsx index 89623ab..ac0bbe4 100644 --- a/web/src/screens/filters/sections/Actions.tsx +++ b/web/src/screens/filters/sections/Actions.tsx @@ -4,7 +4,6 @@ */ import { useEffect, useRef, useState } from "react"; -import { toast } from "react-hot-toast"; import { useMutation, useQuery } from "@tanstack/react-query"; import { Field, FieldArray, useFormikContext } from "formik"; import type { FieldProps, FieldArrayRenderProps } from "formik"; @@ -18,6 +17,7 @@ import { ActionTypeNameMap, ActionTypeOptions, DOWNLOAD_CLIENTS } from "@domain/ import { Select, TextField } from "@components/inputs"; import { DeleteModal } from "@components/modals"; import { EmptyListState } from "@components/emptystates"; +import { toast } from "@components/hot-toast"; import Toast from "@components/notifications/Toast"; import { Checkbox } from "@components/Checkbox"; diff --git a/web/src/screens/settings/Account.tsx b/web/src/screens/settings/Account.tsx index 12392d3..7413b2b 100644 --- a/web/src/screens/settings/Account.tsx +++ b/web/src/screens/settings/Account.tsx @@ -5,7 +5,6 @@ import { useMutation } from "@tanstack/react-query"; import { Form, Formik } from "formik"; -import toast from "react-hot-toast"; import { UserIcon } from "@heroicons/react/24/solid"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faOpenid } from "@fortawesome/free-brands-svg-icons"; @@ -13,6 +12,7 @@ import { faOpenid } from "@fortawesome/free-brands-svg-icons"; import { APIClient } from "@api/APIClient"; import { Section } from "./_components"; import { PasswordField, TextField } from "@components/inputs"; +import toast from "@components/hot-toast"; import Toast from "@components/notifications/Toast"; import { AuthContext } from "@utils/Context"; diff --git a/web/src/screens/settings/Api.tsx b/web/src/screens/settings/Api.tsx index 1b576fe..bd0aaf2 100644 --- a/web/src/screens/settings/Api.tsx +++ b/web/src/screens/settings/Api.tsx @@ -5,12 +5,12 @@ import { useRef } from "react"; import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; -import { toast } from "react-hot-toast"; import { TrashIcon } from "@heroicons/react/24/outline"; import { KeyField } from "@components/fields/text"; import { DeleteModal } from "@components/modals"; import { APIKeyAddForm } from "@forms/settings/APIKeyAddForm"; +import { toast } from "@components/hot-toast"; import Toast from "@components/notifications/Toast"; import { APIClient } from "@api/APIClient"; import { ApikeysQueryOptions } from "@api/queries"; diff --git a/web/src/screens/settings/Application.tsx b/web/src/screens/settings/Application.tsx index 8baebb9..738691c 100644 --- a/web/src/screens/settings/Application.tsx +++ b/web/src/screens/settings/Application.tsx @@ -5,13 +5,13 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { getRouteApi } from "@tanstack/react-router"; -import { toast } from "react-hot-toast"; import { APIClient } from "@api/APIClient"; import { ConfigQueryOptions, UpdatesQueryOptions } from "@api/queries"; import { SettingsKeys } from "@api/query_keys"; import { SettingsContext } from "@utils/Context"; import { Checkbox } from "@components/Checkbox"; +import { toast } from "@components/hot-toast"; import Toast from "@components/notifications/Toast"; import { ExternalLink } from "@components/ExternalLink"; diff --git a/web/src/screens/settings/DownloadClient.tsx b/web/src/screens/settings/DownloadClient.tsx index 033ee41..4dedf5f 100644 --- a/web/src/screens/settings/DownloadClient.tsx +++ b/web/src/screens/settings/DownloadClient.tsx @@ -6,7 +6,6 @@ import { useMemo, useState } from "react"; import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { PlusIcon } from "@heroicons/react/24/solid"; -import toast from "react-hot-toast"; import { useToggle } from "@hooks/hooks"; import { DownloadClientAddForm, DownloadClientUpdateForm } from "@forms"; @@ -15,6 +14,7 @@ import { APIClient } from "@api/APIClient"; import { DownloadClientKeys } from "@api/query_keys"; import { DownloadClientsQueryOptions } from "@api/queries"; import { ActionTypeNameMap } from "@domain/constants"; +import toast from "@components/hot-toast"; import Toast from "@components/notifications/Toast"; import { Checkbox } from "@components/Checkbox"; diff --git a/web/src/screens/settings/Feed.tsx b/web/src/screens/settings/Feed.tsx index 4e7e6a3..f4be6df 100644 --- a/web/src/screens/settings/Feed.tsx +++ b/web/src/screens/settings/Feed.tsx @@ -6,7 +6,6 @@ import { Fragment, useMemo, useRef, useState } from "react"; import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { Menu, MenuButton, MenuItem, MenuItems, Transition } from "@headlessui/react"; -import { toast } from "react-hot-toast"; import { ArrowsRightLeftIcon, DocumentTextIcon, @@ -21,6 +20,7 @@ import { FeedsQueryOptions } from "@api/queries"; import { FeedKeys } from "@api/query_keys"; import { useToggle } from "@hooks/hooks"; import { baseUrl, classNames, IsEmptyDate, simplifyDate } from "@utils"; +import { toast } from "@components/hot-toast"; import Toast from "@components/notifications/Toast"; import { DeleteModal, ForceRunModal } from "@components/modals"; import { FeedUpdateForm } from "@forms/settings/FeedForms"; diff --git a/web/src/screens/settings/Indexer.tsx b/web/src/screens/settings/Indexer.tsx index 53c6207..8a5756f 100644 --- a/web/src/screens/settings/Indexer.tsx +++ b/web/src/screens/settings/Indexer.tsx @@ -4,7 +4,6 @@ */ import { useMemo, useState } from "react"; -import toast from "react-hot-toast"; import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { PlusIcon } from "@heroicons/react/24/solid"; @@ -13,6 +12,7 @@ import { APIClient } from "@api/APIClient"; import { IndexerKeys } from "@api/query_keys"; import { IndexersQueryOptions } from "@api/queries"; import { Checkbox } from "@components/Checkbox"; +import toast from "@components/hot-toast"; import Toast from "@components/notifications/Toast"; import { EmptySimple } from "@components/emptystates"; import { IndexerAddForm, IndexerUpdateForm } from "@forms"; diff --git a/web/src/screens/settings/Irc.tsx b/web/src/screens/settings/Irc.tsx index 58e1e29..eb7bace 100644 --- a/web/src/screens/settings/Irc.tsx +++ b/web/src/screens/settings/Irc.tsx @@ -7,7 +7,6 @@ import { Fragment, MouseEvent, useEffect, useMemo, useRef, useState } from "reac import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { ArrowPathIcon, LockClosedIcon, LockOpenIcon, PlusIcon } from "@heroicons/react/24/solid"; import { Menu, MenuButton, MenuItem, MenuItems, Transition } from "@headlessui/react"; -import { toast } from "react-hot-toast"; import { ArrowsPointingInIcon, ArrowsPointingOutIcon, @@ -26,6 +25,7 @@ import { IrcKeys } from "@api/query_keys"; import { IrcQueryOptions } from "@api/queries"; import { EmptySimple } from "@components/emptystates"; import { DeleteModal } from "@components/modals"; +import { toast } from "@components/hot-toast"; import Toast from "@components/notifications/Toast"; import { SettingsContext } from "@utils/Context"; import { Checkbox } from "@components/Checkbox"; diff --git a/web/src/screens/settings/Logs.tsx b/web/src/screens/settings/Logs.tsx index 53b1006..b74fdea 100644 --- a/web/src/screens/settings/Logs.tsx +++ b/web/src/screens/settings/Logs.tsx @@ -5,12 +5,12 @@ import { useMutation, useSuspenseQuery } from "@tanstack/react-query"; import { getRouteApi } from "@tanstack/react-router"; -import { toast } from "react-hot-toast"; import Select from "react-select"; import { APIClient } from "@api/APIClient"; import { ConfigQueryOptions } from "@api/queries"; import { SettingsKeys } from "@api/query_keys"; +import { toast } from "@components/hot-toast"; import Toast from "@components/notifications/Toast"; import { LogLevelOptions, SelectOption } from "@domain/constants"; diff --git a/web/src/screens/settings/Notifications.tsx b/web/src/screens/settings/Notifications.tsx index ac8edd8..e4b1e5c 100644 --- a/web/src/screens/settings/Notifications.tsx +++ b/web/src/screens/settings/Notifications.tsx @@ -5,7 +5,6 @@ import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { PlusIcon } from "@heroicons/react/24/solid"; -import toast from "react-hot-toast"; import { APIClient } from "@api/APIClient"; import { NotificationKeys } from "@api/query_keys"; @@ -14,6 +13,7 @@ import { EmptySimple } from "@components/emptystates"; import { useToggle } from "@hooks/hooks"; import { NotificationAddForm, NotificationUpdateForm } from "@forms/settings/NotificationForms"; import { componentMapType } from "@forms/settings/DownloadClientForms"; +import toast from "@components/hot-toast"; import Toast from "@components/notifications/Toast"; import { DiscordIcon, diff --git a/web/src/screens/settings/Proxy.tsx b/web/src/screens/settings/Proxy.tsx index 757eb2e..06c49f4 100644 --- a/web/src/screens/settings/Proxy.tsx +++ b/web/src/screens/settings/Proxy.tsx @@ -6,7 +6,6 @@ import { useToggle } from "@hooks/hooks.ts"; import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { PlusIcon } from "@heroicons/react/24/solid"; -import { toast } from "react-hot-toast"; import { APIClient } from "@api/APIClient"; import { ProxyKeys } from "@api/query_keys"; @@ -15,6 +14,7 @@ import { Section } from "./_components"; import { EmptySimple } from "@components/emptystates"; import { Checkbox } from "@components/Checkbox"; import { ProxyAddForm, ProxyUpdateForm } from "@forms/settings/ProxyForms"; +import { toast } from "@components/hot-toast"; import Toast from "@components/notifications/Toast"; interface ListItemProps { diff --git a/web/src/screens/settings/Releases.tsx b/web/src/screens/settings/Releases.tsx index 09b6a1e..19669b9 100644 --- a/web/src/screens/settings/Releases.tsx +++ b/web/src/screens/settings/Releases.tsx @@ -5,12 +5,12 @@ import { useRef, useState } from "react"; import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query"; -import { toast } from "react-hot-toast"; import { MultiSelect as RMSC } from "react-multi-select-component"; import { AgeSelect } from "@components/inputs" import { APIClient } from "@api/APIClient"; import { ReleaseKeys } from "@api/query_keys"; +import { toast } from "@components/hot-toast"; import Toast from "@components/notifications/Toast"; import { useToggle } from "@hooks/hooks"; import { DeleteModal } from "@components/modals";