added backup and email client

This commit is contained in:
2026-02-01 00:02:35 +01:00
parent ff857be01a
commit e4fdfbc95f
210 changed files with 24211 additions and 742 deletions
+232
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
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;
};
})();