added backup and email client
This commit is contained in:
+232
@@ -0,0 +1,232 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { DefaultToastOptions, Toast, ToastType } from './types';
|
||||
|
||||
export const TOAST_EXPIRE_DISMISS_DELAY = 1000;
|
||||
export const TOAST_LIMIT = 20;
|
||||
export const DEFAULT_TOASTER_ID = 'default';
|
||||
|
||||
interface ToasterSettings {
|
||||
toastLimit: number;
|
||||
}
|
||||
|
||||
export enum ActionType {
|
||||
ADD_TOAST,
|
||||
UPDATE_TOAST,
|
||||
UPSERT_TOAST,
|
||||
DISMISS_TOAST,
|
||||
REMOVE_TOAST,
|
||||
START_PAUSE,
|
||||
END_PAUSE,
|
||||
}
|
||||
|
||||
export 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 ToasterState {
|
||||
toasts: Toast[];
|
||||
settings: ToasterSettings;
|
||||
pausedAt: number | undefined;
|
||||
}
|
||||
|
||||
interface State {
|
||||
[toasterId: string]: ToasterState;
|
||||
}
|
||||
|
||||
export const reducer = (state: ToasterState, action: Action): ToasterState => {
|
||||
const { toastLimit } = state.settings;
|
||||
|
||||
switch (action.type) {
|
||||
case ActionType.ADD_TOAST:
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, toastLimit),
|
||||
};
|
||||
|
||||
case ActionType.UPDATE_TOAST:
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
};
|
||||
|
||||
case ActionType.UPSERT_TOAST:
|
||||
const { toast } = action;
|
||||
return reducer(state, {
|
||||
type: state.toasts.find((t) => t.id === toast.id)
|
||||
? ActionType.UPDATE_TOAST
|
||||
: ActionType.ADD_TOAST,
|
||||
toast,
|
||||
});
|
||||
|
||||
case ActionType.DISMISS_TOAST:
|
||||
const { toastId } = action;
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
dismissed: true,
|
||||
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<
|
||||
[toasterId: string, reducer: (state: ToasterState) => void]
|
||||
> = [];
|
||||
|
||||
const defaultToasterState: ToasterState = {
|
||||
toasts: [],
|
||||
pausedAt: undefined,
|
||||
settings: {
|
||||
toastLimit: TOAST_LIMIT,
|
||||
},
|
||||
};
|
||||
let memoryState: State = {};
|
||||
|
||||
export const dispatch = (action: Action, toasterId = DEFAULT_TOASTER_ID) => {
|
||||
memoryState[toasterId] = reducer(
|
||||
memoryState[toasterId] || defaultToasterState,
|
||||
action
|
||||
);
|
||||
listeners.forEach(([id, listener]) => {
|
||||
if (id === toasterId) {
|
||||
listener(memoryState[toasterId]);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const dispatchAll = (action: Action) =>
|
||||
Object.keys(memoryState).forEach((toasterId) => dispatch(action, toasterId));
|
||||
|
||||
export const getToasterIdFromToastId = (toastId: string) =>
|
||||
Object.keys(memoryState).find((toasterId) =>
|
||||
memoryState[toasterId].toasts.some((t) => t.id === toastId)
|
||||
);
|
||||
|
||||
export const createDispatch =
|
||||
(toasterId = DEFAULT_TOASTER_ID) =>
|
||||
(action: Action) => {
|
||||
dispatch(action, toasterId);
|
||||
};
|
||||
|
||||
export const defaultTimeouts: {
|
||||
[key in ToastType]: number;
|
||||
} = {
|
||||
blank: 4000,
|
||||
error: 4000,
|
||||
success: 2000,
|
||||
loading: Infinity,
|
||||
custom: 4000,
|
||||
};
|
||||
|
||||
export const useStore = (
|
||||
toastOptions: DefaultToastOptions = {},
|
||||
toasterId: string = DEFAULT_TOASTER_ID
|
||||
): ToasterState => {
|
||||
const [state, setState] = useState<ToasterState>(
|
||||
memoryState[toasterId] || defaultToasterState
|
||||
);
|
||||
const initial = useRef(memoryState[toasterId]);
|
||||
|
||||
// TODO: Switch to useSyncExternalStore when targeting React 18+
|
||||
useEffect(() => {
|
||||
if (initial.current !== memoryState[toasterId]) {
|
||||
setState(memoryState[toasterId]);
|
||||
}
|
||||
listeners.push([toasterId, setState]);
|
||||
return () => {
|
||||
const index = listeners.findIndex(([id]) => id === toasterId);
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}, [toasterId]);
|
||||
|
||||
const mergedToasts = state.toasts.map((t) => ({
|
||||
...toastOptions,
|
||||
...toastOptions[t.type],
|
||||
...t,
|
||||
removeDelay:
|
||||
t.removeDelay ||
|
||||
toastOptions[t.type]?.removeDelay ||
|
||||
toastOptions?.removeDelay,
|
||||
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,
|
||||
};
|
||||
};
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
Renderable,
|
||||
Toast,
|
||||
ToastOptions,
|
||||
ToastType,
|
||||
DefaultToastOptions,
|
||||
ValueOrFunction,
|
||||
resolveValue,
|
||||
} from './types';
|
||||
import { genId } from './utils';
|
||||
import {
|
||||
createDispatch,
|
||||
Action,
|
||||
ActionType,
|
||||
dispatchAll,
|
||||
getToasterIdFromToastId,
|
||||
} 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,
|
||||
dismissed: false,
|
||||
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);
|
||||
|
||||
const dispatch = createDispatch(
|
||||
toast.toasterId || getToasterIdFromToastId(toast.id)
|
||||
);
|
||||
|
||||
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');
|
||||
|
||||
/**
|
||||
* Dismisses the toast with the given id. If no id is given, dismisses all toasts.
|
||||
* The toast will transition out and then be removed from the DOM.
|
||||
* Applies to all toasters, except when a `toasterId` is given.
|
||||
*/
|
||||
toast.dismiss = (toastId?: string, toasterId?: string) => {
|
||||
const action: Action = {
|
||||
type: ActionType.DISMISS_TOAST,
|
||||
toastId,
|
||||
};
|
||||
|
||||
if (toasterId) {
|
||||
createDispatch(toasterId)(action);
|
||||
} else {
|
||||
dispatchAll(action);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Dismisses all toasts.
|
||||
*/
|
||||
toast.dismissAll = (toasterId?: string) => toast.dismiss(undefined, toasterId);
|
||||
|
||||
/**
|
||||
* Removes the toast with the given id.
|
||||
* The toast will be removed from the DOM without any transition.
|
||||
*/
|
||||
toast.remove = (toastId?: string, toasterId?: string) => {
|
||||
const action: Action = {
|
||||
type: ActionType.REMOVE_TOAST,
|
||||
toastId,
|
||||
};
|
||||
if (toasterId) {
|
||||
createDispatch(toasterId)(action);
|
||||
} else {
|
||||
dispatchAll(action);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes all toasts.
|
||||
*/
|
||||
toast.removeAll = (toasterId?: string) => toast.remove(undefined, toasterId);
|
||||
|
||||
/**
|
||||
* Create a loading toast that will automatically updates with the promise.
|
||||
*/
|
||||
toast.promise = <T>(
|
||||
promise: Promise<T> | (() => Promise<T>),
|
||||
msgs: {
|
||||
loading: Renderable;
|
||||
success?: ValueOrFunction<Renderable, T>;
|
||||
error?: ValueOrFunction<Renderable, any>;
|
||||
},
|
||||
opts?: DefaultToastOptions
|
||||
) => {
|
||||
const id = toast.loading(msgs.loading, { ...opts, ...opts?.loading });
|
||||
|
||||
if (typeof promise === 'function') {
|
||||
promise = promise();
|
||||
}
|
||||
|
||||
promise
|
||||
.then((p) => {
|
||||
const successMessage = msgs.success
|
||||
? resolveValue(msgs.success, p)
|
||||
: undefined;
|
||||
|
||||
if (successMessage) {
|
||||
toast.success(successMessage, {
|
||||
id,
|
||||
...opts,
|
||||
...opts?.success,
|
||||
});
|
||||
} else {
|
||||
toast.dismiss(id);
|
||||
}
|
||||
return p;
|
||||
})
|
||||
.catch((e) => {
|
||||
const errorMessage = msgs.error ? resolveValue(msgs.error, e) : undefined;
|
||||
|
||||
if (errorMessage) {
|
||||
toast.error(errorMessage, {
|
||||
id,
|
||||
...opts,
|
||||
...opts?.error,
|
||||
});
|
||||
} else {
|
||||
toast.dismiss(id);
|
||||
}
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
export { toast };
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
import { 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.ReactElement | 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;
|
||||
toasterId?: string;
|
||||
message: ValueOrFunction<Renderable, Toast>;
|
||||
icon?: Renderable;
|
||||
duration?: number;
|
||||
pauseDuration: number;
|
||||
position?: ToastPosition;
|
||||
removeDelay?: number;
|
||||
|
||||
ariaProps: {
|
||||
role: 'status' | 'alert';
|
||||
'aria-live': 'assertive' | 'off' | 'polite';
|
||||
};
|
||||
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
iconTheme?: IconTheme;
|
||||
|
||||
createdAt: number;
|
||||
visible: boolean;
|
||||
dismissed: boolean;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export type ToastOptions = Partial<
|
||||
Pick<
|
||||
Toast,
|
||||
| 'id'
|
||||
| 'icon'
|
||||
| 'duration'
|
||||
| 'ariaProps'
|
||||
| 'className'
|
||||
| 'style'
|
||||
| 'position'
|
||||
| 'iconTheme'
|
||||
| 'toasterId'
|
||||
| 'removeDelay'
|
||||
>
|
||||
>;
|
||||
|
||||
export type DefaultToastOptions = ToastOptions & {
|
||||
[key in ToastType]?: ToastOptions;
|
||||
};
|
||||
|
||||
export interface ToasterProps {
|
||||
position?: ToastPosition;
|
||||
toastOptions?: DefaultToastOptions;
|
||||
reverseOrder?: boolean;
|
||||
gutter?: number;
|
||||
containerStyle?: React.CSSProperties;
|
||||
containerClassName?: string;
|
||||
toasterId?: string;
|
||||
children?: (toast: Toast) => React.ReactElement;
|
||||
}
|
||||
|
||||
export interface ToastWrapperProps {
|
||||
id: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
onHeightUpdate: (id: string, height: number) => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
import { createDispatch, ActionType, useStore, dispatch } from './store';
|
||||
import { toast } from './toast';
|
||||
import { DefaultToastOptions, Toast, ToastPosition } from './types';
|
||||
|
||||
export const REMOVE_DELAY = 1000;
|
||||
|
||||
export const useToaster = (
|
||||
toastOptions?: DefaultToastOptions,
|
||||
toasterId: string = 'default'
|
||||
) => {
|
||||
const { toasts, pausedAt } = useStore(toastOptions, toasterId);
|
||||
const toastTimeouts = useRef(
|
||||
new Map<Toast['id'], ReturnType<typeof setTimeout>>()
|
||||
).current;
|
||||
|
||||
const addToRemoveQueue = useCallback(
|
||||
(toastId: string, removeDelay = REMOVE_DELAY) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId);
|
||||
dispatch({
|
||||
type: ActionType.REMOVE_TOAST,
|
||||
toastId: toastId,
|
||||
});
|
||||
}, removeDelay);
|
||||
|
||||
toastTimeouts.set(toastId, timeout);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
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, toasterId), durationLeft);
|
||||
});
|
||||
|
||||
return () => {
|
||||
timeouts.forEach((timeout) => timeout && clearTimeout(timeout));
|
||||
};
|
||||
}, [toasts, pausedAt, toasterId]);
|
||||
|
||||
const dispatch = useCallback(createDispatch(toasterId), [toasterId]);
|
||||
|
||||
const startPause = useCallback(() => {
|
||||
dispatch({
|
||||
type: ActionType.START_PAUSE,
|
||||
time: Date.now(),
|
||||
});
|
||||
}, [dispatch]);
|
||||
|
||||
const updateHeight = useCallback(
|
||||
(toastId: string, height: number) => {
|
||||
dispatch({
|
||||
type: ActionType.UPDATE_TOAST,
|
||||
toast: { id: toastId, height },
|
||||
});
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const endPause = useCallback(() => {
|
||||
if (pausedAt) {
|
||||
dispatch({ type: ActionType.END_PAUSE, time: Date.now() });
|
||||
}
|
||||
}, [pausedAt, dispatch]);
|
||||
|
||||
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]
|
||||
);
|
||||
|
||||
// Keep track of dismissed toasts and remove them after the delay
|
||||
useEffect(() => {
|
||||
toasts.forEach((toast) => {
|
||||
if (toast.dismissed) {
|
||||
addToRemoveQueue(toast.id, toast.removeDelay);
|
||||
} else {
|
||||
// If toast becomes visible again, remove it from the queue
|
||||
const timeout = toastTimeouts.get(toast.id);
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
toastTimeouts.delete(toast.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [toasts, addToRemoveQueue]);
|
||||
|
||||
return {
|
||||
toasts,
|
||||
handlers: {
|
||||
updateHeight,
|
||||
startPause,
|
||||
endPause,
|
||||
calculateOffset,
|
||||
},
|
||||
};
|
||||
};
|
||||
+19
@@ -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;
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user