diff --git a/interface/app/$libraryId/Layout/Toasts.tsx b/interface/app/$libraryId/Layout/Toasts.tsx deleted file mode 100644 index 8b41707b3..000000000 --- a/interface/app/$libraryId/Layout/Toasts.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import * as ToastPrimitive from '@radix-ui/react-toast'; -import clsx from 'clsx'; -import { useToasts } from '~/hooks/useToasts'; - -export default () => { - const { toasts, removeToast } = useToasts(); - return ( -
- - <> - {toasts.map((toast) => ( - removeToast(toast)} - duration={toast.duration || 3000} - className={clsx( - 'm-4 w-80 rounded-lg', - 'bg-app-box/20 backdrop-blur', - 'radix-state-open:animate-toast-slide-in-bottom md:radix-state-open:animate-toast-slide-in-right', - 'radix-state-closed:animate-toast-hide', - 'radix-swipe-end:animate-toast-swipe-out', - 'translate-x-radix-toast-swipe-move-x', - 'radix-swipe-cancel:ease-[ease] radix-swipe-cancel:translate-x-0 radix-swipe-cancel:duration-200', - 'border-2 border-white/10 shadow-2xl focus:outline-none focus-visible:ring focus-visible:ring-accent/75' - )} - > -
-
-
- - {toast.title} - - {toast.subtitle && ( - - {toast.subtitle} - - )} -
-
-
-
-
- {toast.actionButton && ( - { - e.preventDefault(); - toast.actionButton?.onClick(); - removeToast(toast); - }} - > - {toast.actionButton.text || 'Open'} - - )} -
-
- - Dismiss - -
-
-
-
-
- ))} - - - -
-
- ); -}; diff --git a/interface/app/$libraryId/Layout/index.tsx b/interface/app/$libraryId/Layout/index.tsx index eef0411d9..1f13eab58 100644 --- a/interface/app/$libraryId/Layout/index.tsx +++ b/interface/app/$libraryId/Layout/index.tsx @@ -17,7 +17,6 @@ import { usePlatform } from '~/util/Platform'; import { QuickPreviewContextProvider } from '../Explorer/QuickPreview/Context'; import { LayoutContext } from './Context'; import Sidebar from './Sidebar'; -import Toasts from './Toasts'; const Layout = () => { const { libraries, library } = useClientContext(); @@ -89,7 +88,6 @@ const Layout = () => { )} - ); diff --git a/interface/app/index.tsx b/interface/app/index.tsx index db5100a1f..1943568d0 100644 --- a/interface/app/index.tsx +++ b/interface/app/index.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { Navigate, Outlet, RouteObject, useMatches } from 'react-router-dom'; import { currentLibraryCache, useCachedLibraries, useInvalidateQuery } from '@sd/client'; -import { Dialogs } from '@sd/ui'; +import { Dialogs, Toaster } from '@sd/ui'; import { RouterErrorBoundary } from '~/ErrorFallback'; import { useKeybindHandler, useTheme } from '~/hooks'; import libraryRoutes from './$libraryId'; @@ -34,6 +34,7 @@ const Wrapper = () => { + ); }; @@ -75,9 +76,9 @@ const useRawRoutePath = () => { // we grab the last one as it contains all previous route segments. const lastMatchId = useMatches().slice(-1)[0]?.id; - const rawPath = useMemo( - () => { - const [rawPath] = lastMatchId + const rawPath = useMemo(() => { + const [rawPath] = + lastMatchId // Gets a list of the index of each route segment ?.split('-') ?.map((s) => parseInt(s)) @@ -96,12 +97,10 @@ const useRawRoutePath = () => { return [`${rawPath}/${item.path}`, item]; }, ['' as string, { children: routes }] as const - ) ?? [] + ) ?? []; - return rawPath ?? "/" - }, - [lastMatchId] - ); + return rawPath ?? '/'; + }, [lastMatchId]); return rawPath; }; diff --git a/interface/hooks/index.ts b/interface/hooks/index.ts index b5ffe5ac6..5a6b9df84 100644 --- a/interface/hooks/index.ts +++ b/interface/hooks/index.ts @@ -15,7 +15,6 @@ export * from './useScrolled'; export * from './useSearchStore'; export * from './useSpacedropState'; export * from './useTheme'; -export * from './useToasts'; export * from './useZodRouteParams'; export * from './useZodSearchParams'; export * from './useIsTextTruncated'; diff --git a/interface/hooks/useToasts.ts b/interface/hooks/useToasts.ts deleted file mode 100644 index f0b624fd6..000000000 --- a/interface/hooks/useToasts.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { proxy, useSnapshot } from 'valtio'; - -interface Toast { - id: string; - title: string; - subtitle?: string; - duration?: number; - actionButton?: { - text: string; - onClick: () => void; - }; -} - -const state = proxy({ - toasts: [] as Toast[] -}); - -const randomId = () => Math.random().toString(36).slice(2); - -export function useToasts() { - return { - toasts: useSnapshot(state).toasts, - addToast: (toast: Omit) => { - state.toasts.push({ - id: randomId(), - ...toast - }); - }, - removeToast: (toast: Toast | string) => { - const id = typeof toast === 'string' ? toast : toast.id; - state.toasts = state.toasts.filter((t) => t.id !== id); - } - }; -} diff --git a/packages/ui/package.json b/packages/ui/package.json index 8428f06f4..57f23e98e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -42,6 +42,7 @@ "react-dom": "^18.2.0", "react-loading-icons": "^1.1.0", "react-router-dom": "6.9.0", + "sonner": "^0.6.2", "tailwindcss-radix": "^2.6.0", "ts-pattern": "^5.0.1", "use-debounce": "^9.0.4", diff --git a/packages/ui/src/Toast.tsx b/packages/ui/src/Toast.tsx new file mode 100644 index 000000000..acc0e2627 --- /dev/null +++ b/packages/ui/src/Toast.tsx @@ -0,0 +1,270 @@ +import clsx from 'clsx'; +import { CheckCircle, Icon, Info, WarningCircle, X } from 'phosphor-react'; +import { ReactNode, useEffect, useState } from 'react'; +import { toast as SonnerToast, ToastT } from 'sonner'; +import { Button } from './Button'; +import { Loader } from './Loader'; + +type ToastId = ToastT['id']; +type ToastType = 'info' | 'success' | 'error'; +type ToastMessage = ReactNode | { title: string; description?: string }; +type ToastPromiseData = unknown; +type ToastPromise = Promise | (() => Promise); +type ToastAction = { label: string; onClick: () => void; className?: string }; + +interface ToastOptions + extends Omit< + ToastT, + | 'id' + | 'type' + | 'action' + | 'cancel' + | 'delete' + | 'promise' + | 'jsx' + | 'title' + | 'description' + | 'descriptionClassName' + > { + id?: ToastId; + type?: ToastType; + action?: ToastAction; + cancel?: Omit & { onClick?: ToastAction['onClick'] }; +} + +interface ToastPromiseOptions { + loading: ReactNode; + success: ReactNode | ((data: T) => ReactNode); + error: ReactNode | ((error: unknown) => ReactNode); +} + +const toastClassName = + 'w-full cursor-default select-none overflow-hidden rounded-md border border-app-line bg-app-darkBox/90 shadow-lg p-3 text-sm text-ink-faint backdrop-blur'; + +const actionButtonClassName = '!rounded !px-1.5 !py-0.5 !font-normal'; + +interface ToastProps { + id: ToastId; + type?: ToastType; + message: ToastMessage; + icon?: ReactNode; + action?: ToastAction; + cancel?: ToastOptions['cancel']; + closable?: boolean; +} + +const icons: Record = { + success: CheckCircle, + error: WarningCircle, + info: Info +}; + +const Toast = ({ id, type, message, icon, action, cancel, closable = true }: ToastProps) => { + const title = + message && typeof message === 'object' && 'title' in message ? message.title : message; + + const description = + message && typeof message === 'object' && 'description' in message + ? message.description + : undefined; + + const typeIcon = (type: ToastType) => { + const Icon = icons[type]; + return ( + + ); + }; + + return ( +
+ {(icon || type) && ( +
+ {icon || (type && typeIcon(type))} +
+ )} + +
+ {title && {title}} + + {description && {description}} + + {(action || cancel) && ( +
+ {action && ( + + )} + + {cancel && ( + + )} +
+ )} +
+ + {closable && ( + + )} +
+ ); +}; + +interface PromiseToastProps extends ToastPromiseOptions { + id: ToastId; + promise: ToastPromise; + duration?: number; +} + +const PromiseToast = ({ + id, + promise, + loading, + success, + error, + duration +}: PromiseToastProps) => { + const [type, setType] = useState(); + const [message, setMessage] = useState(loading); + + useEffect(() => { + const resolve = async () => { + try { + const res = await (promise instanceof Promise ? promise : promise()); + const message = typeof success === 'function' ? success(res) : success; + setMessage(message); + setType('success'); + } catch (err) { + const message = typeof error === 'function' ? error(err) : error; + setMessage(message); + setType('error'); + } + + setTimeout(() => toast.dismiss(id), duration || 4000); + }; + + resolve(); + }, [id, promise, success, error, duration]); + + return ( + } + closable={!!type} + /> + ); +}; + +const renderToast = ( + message: ToastMessage, + { className, type, icon, action, cancel, ...options }: ToastOptions = {} +) => { + return SonnerToast.custom( + (id) => ( + + ), + { + className: clsx(toastClassName, className), + ...options + } + ); +}; + +const renderCustomToast = ( + jsx: Parameters[0], + { className, ...options }: Omit = {} +) => { + return SonnerToast.custom(jsx, { + className: clsx(toastClassName, className), + ...options + }); +}; + +const renderPromiseToast = ( + promise: ToastPromise, + { + className, + loading, + success, + error, + duration, + ...options + }: Omit & ToastPromiseOptions +) => { + return SonnerToast.custom( + (id) => ( + + ), + { + className: clsx(toastClassName, className), + duration: Infinity, + ...options + } + ); +}; + +export const toast = Object.assign(renderToast, { + info: (message: ToastMessage, options?: Omit) => { + return renderToast(message, { ...options, type: 'info' }); + }, + success: (message: ToastMessage, options?: Omit) => { + return renderToast(message, { ...options, type: 'success' }); + }, + error: (message: ToastMessage, options?: Omit) => { + return renderToast(message, { ...options, type: 'error' }); + }, + custom: renderCustomToast, + promise: renderPromiseToast, + dismiss: SonnerToast.dismiss +}); + +export { Toaster } from 'sonner'; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 770f2fcd3..fc21b557f 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -22,3 +22,4 @@ export * from './Divider'; export * from './Shortcut'; export * from './ProgressBar'; export * from './keys'; +export * from './Toast'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 78d5715d8..9ef63d241 100644 Binary files a/pnpm-lock.yaml and b/pnpm-lock.yaml differ