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
This commit is contained in:
ze0s 2024-12-19 16:12:29 +01:00 committed by GitHub
parent 43c28fc0c6
commit d8f578b5ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 1071 additions and 59 deletions

View file

@ -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",

30
web/pnpm-lock.yaml generated
View file

@ -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):

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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 {

View file

@ -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";

View file

@ -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')<CheckmarkTheme>`
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;
}
`;

View file

@ -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')<ErrorTheme>`
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);
}
`;

View file

@ -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')<LoaderTheme>`
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;
`;

View file

@ -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<ToastBarProps> = React.memo(
({ toast, position, style, children }) => {
const animationStyle: React.CSSProperties = toast.height
? getAnimationStyle(
toast.position || position || 'top-center',
toast.visible
)
: { opacity: 0 };
const icon = <ToastIcon toast={toast} />;
const message = (
<Message {...toast.ariaProps}>
{resolveValue(toast.message, toast)}
</Message>
);
return (
<ToastBarBase
className={toast.className}
style={{
...animationStyle,
...style,
...toast.style,
}}
>
{typeof children === 'function' ? (
children({
icon,
message,
})
) : (
<>
{icon}
{message}
</>
)}
</ToastBarBase>
);
}
);

View file

@ -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 <AnimatedIconWrapper>{icon}</AnimatedIconWrapper>;
} else {
return icon;
}
}
if (type === 'blank') {
return null;
}
return (
<IndicatorWrapper>
<LoaderIcon {...iconTheme} />
{type !== 'loading' && (
<StatusWrapper>
{type === 'error' ? (
<ErrorIcon {...iconTheme} />
) : (
<CheckmarkIcon {...iconTheme} />
)}
</StatusWrapper>
)}
</IndicatorWrapper>
);
};

View file

@ -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 (
<div ref={ref} className={className} style={style}>
{children}
</div>
);
};
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<ToasterProps> = ({
reverseOrder,
position = 'top-center',
toastOptions,
gutter,
children,
containerStyle,
containerClassName,
}) => {
const { toasts, handlers } = useToaster(toastOptions);
return (
<div
style={{
position: 'fixed',
zIndex: 9999,
top: DEFAULT_OFFSET,
left: DEFAULT_OFFSET,
right: DEFAULT_OFFSET,
bottom: DEFAULT_OFFSET,
pointerEvents: 'none',
...containerStyle,
}}
className={containerClassName}
onMouseEnter={handlers.startPause}
onMouseLeave={handlers.endPause}
>
{toasts.map((t) => {
const toastPosition = t.position || position;
const offset = handlers.calculateOffset(t, {
reverseOrder,
gutter,
defaultPosition: position,
});
const positionStyle = getPositionStyle(toastPosition, offset);
return (
<ToastWrapper
id={t.id}
key={t.id}
onHeightUpdate={handlers.updateHeight}
className={t.visible ? activeClass : ''}
style={positionStyle}
>
{t.type === 'custom' ? (
resolveValue(t.message, t)
) : children ? (
children(t)
) : (
<ToastBar toast={t} position={toastPosition} />
)}
</ToastWrapper>
);
})}
</div>
);
};

View file

@ -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<Toast>;
}
| {
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<Toast['id'], ReturnType<typeof setTimeout>>();
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<State>(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,
};
};

View file

@ -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<Renderable, Toast>;
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 = <T>(
promise: Promise<T>,
msgs: {
loading: Renderable;
success: ValueOrFunction<Renderable, T>;
error: ValueOrFunction<Renderable, unknown>;
},
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 };

View file

@ -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<TValue, TArg> = (arg: TArg) => TValue;
export type ValueOrFunction<TValue, TArg> =
| TValue
| ValueFunction<TValue, TArg>;
const isFunction = <TValue, TArg>(
valOrFunction: ValueOrFunction<TValue, TArg>
): valOrFunction is ValueFunction<TValue, TArg> =>
typeof valOrFunction === 'function';
export const resolveValue = <TValue, TArg>(
valOrFunction: ValueOrFunction<TValue, TArg>,
arg: TArg
): TValue => (isFunction(valOrFunction) ? valOrFunction(arg) : valOrFunction);
export interface Toast {
type: ToastType;
id: string;
message: ValueOrFunction<Renderable, Toast>;
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;
}

View file

@ -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,
},
};
};

View file

@ -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;
};
})();

View file

@ -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;

View file

@ -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;

View file

@ -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 = {

View file

@ -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";

View file

@ -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 {

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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 = {

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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,

View file

@ -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 {

View file

@ -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";