diff --git a/package.json b/package.json index f1dcc5e..063eefd 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,6 @@ "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-switch": "^1.0.3", - "@radix-ui/react-toast": "^1.1.5", "@tailwindcss/typography": "^0.5.10", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", @@ -75,6 +74,7 @@ "remesh-logger": "^4.1.0", "remesh-react": "^4.1.0", "rxjs": "^7.8.1", + "sonner": "^1.2.4", "tailwind-merge": "^2.0.0", "type-fest": "^4.8.2", "valibot": "^0.21.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index deed720..ebf5c87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,9 +38,6 @@ dependencies: '@radix-ui/react-switch': specifier: ^1.0.3 version: 1.0.3(@types/react-dom@18.2.17)(@types/react@18.2.39)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-toast': - specifier: ^1.1.5 - version: 1.1.5(@types/react-dom@18.2.17)(@types/react@18.2.39)(react-dom@18.2.0)(react@18.2.0) '@tailwindcss/typography': specifier: ^0.5.10 version: 0.5.10(tailwindcss@3.3.5) @@ -101,6 +98,9 @@ dependencies: rxjs: specifier: ^7.8.1 version: 7.8.1 + sonner: + specifier: ^1.2.4 + version: 1.2.4(react-dom@18.2.0)(react@18.2.0) tailwind-merge: specifier: ^2.0.0 version: 2.0.0 @@ -1782,38 +1782,6 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false - /@radix-ui/react-toast@1.1.5(@types/react-dom@18.2.17)(@types/react@18.2.39)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-fRLn227WHIBRSzuRzGJ8W+5YALxofH23y0MlPLddaIpLpCDqdE0NZlS2NRQDRiptfxDeeCjgFIpexB1/zkxDlw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@babel/runtime': 7.23.5 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.17)(@types/react@18.2.39)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.39)(react@18.2.0) - '@radix-ui/react-context': 1.0.1(@types/react@18.2.39)(react@18.2.0) - '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.2.17)(@types/react@18.2.39)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-portal': 1.0.4(@types/react-dom@18.2.17)(@types/react@18.2.39)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.17)(@types/react@18.2.39)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.17)(@types/react@18.2.39)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.39)(react@18.2.0) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.39)(react@18.2.0) - '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.39)(react@18.2.0) - '@radix-ui/react-visually-hidden': 1.0.3(@types/react-dom@18.2.17)(@types/react@18.2.39)(react-dom@18.2.0)(react@18.2.0) - '@types/react': 18.2.39 - '@types/react-dom': 18.2.17 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false - /@radix-ui/react-use-callback-ref@1.0.1(@types/react@18.2.39)(react@18.2.0): resolution: {integrity: sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==} peerDependencies: @@ -1916,27 +1884,6 @@ packages: react: 18.2.0 dev: false - /@radix-ui/react-visually-hidden@1.0.3(@types/react-dom@18.2.17)(@types/react@18.2.39)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-D4w41yN5YRKtu464TLnByKzMDG/JlMPHtfZgQAu9v6mNakUqGUI9vUrfQKz8NK41VMm/xbZbh76NUTVtIYqOMA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@babel/runtime': 7.23.5 - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.17)(@types/react@18.2.39)(react-dom@18.2.0)(react@18.2.0) - '@types/react': 18.2.39 - '@types/react-dom': 18.2.17 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false - /@radix-ui/rect@1.0.1: resolution: {integrity: sha512-fyrgCaedtvMg9NK3en0pnOYJdtfwxUcNolezkNPUsoX57X8oQk+NkqcvzHXD2uKNij6GXmWU9NDru2IWjrO4BQ==} dependencies: @@ -7507,6 +7454,16 @@ packages: is-fullwidth-code-point: 4.0.0 dev: true + /sonner@1.2.4(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-WGLP2QQnomgewaCTsK7YWiLcy5n1Yj83vsL5cP4zHMmpSkmFsCYTpQKhlXJrPE5kzjwbqCkCFXcOpbKc4vaUaA==} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /source-map-js@1.0.2: resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} engines: {node: '>=0.10.0'} diff --git a/src/app/options/App.tsx b/src/app/options/App.tsx index 33011db..6e78011 100644 --- a/src/app/options/App.tsx +++ b/src/app/options/App.tsx @@ -1,12 +1,12 @@ +import { Toaster } from 'sonner' import Layout from './components/Layout' import ProfileForm from './components/ProfileForm' -import { Toaster } from '@/components/ui/Toaster' function App() { return ( - + ) } diff --git a/src/app/options/components/AvatarSelect.tsx b/src/app/options/components/AvatarSelect.tsx index c0b2d44..700686a 100644 --- a/src/app/options/components/AvatarSelect.tsx +++ b/src/app/options/components/AvatarSelect.tsx @@ -1,47 +1,43 @@ import { type ChangeEvent } from 'react' import { ImagePlusIcon } from 'lucide-react' import React from 'react' - import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar' import { Label } from '@/components/ui/Label' import { cn, compressImage } from '@/utils' -import { useToast } from '@/components/ui/useToast' export interface AvatarSelectProps { value?: string className?: string disabled?: boolean - onload?: ((this: FileReader, ev: ProgressEvent) => any) | null - onerror?: ((this: FileReader, ev: ProgressEvent) => any) | null + onSuccess?: (blob: Blob) => void + onWarning?: (error: Error) => void + onError?: (error: Error) => void onChange?: (src: string) => void } const AvatarSelect = React.forwardRef( - ({ onChange, value, onerror, onload, className, disabled }, ref) => { - const { toast } = useToast() - + ({ onChange, value, onError, onWarning, onSuccess, className, disabled }, ref) => { const handleChange = async (e: ChangeEvent) => { const file = e.target.files?.[0] if (file) { if (!/image\/(png|jpeg)/.test(file.type)) { - toast({ - variant: 'destructive', - title: 'Invalid file type', - description: 'Only PNG and JPEG files are supported' - }) + onWarning?.(new Error('Only PNG and JPEG image are supported.')) return } - // Compress to 10kb - const blob = await compressImage(file, 10 * 1024) - const reader = new FileReader() - reader.onload = (e) => { - onload?.call(reader, e) - const src = e.target?.result as string - onChange?.(src) + try { + // Compress to 10kb + const blob = await compressImage(file, 10 * 1024) + const reader = new FileReader() + reader.onload = (e) => { + onSuccess?.(blob) + onChange?.(e.target?.result as string) + } + reader.onerror = () => onError?.(new Error('Failed to read image file.')) + reader.readAsDataURL(blob) + } catch (error) { + onError?.(error as Error) } - reader.onerror = (e) => onerror?.call(reader, e) - reader.readAsDataURL(blob) } } return ( diff --git a/src/app/options/components/ProfileForm.tsx b/src/app/options/components/ProfileForm.tsx index 7f67a3b..d64eb95 100644 --- a/src/app/options/components/ProfileForm.tsx +++ b/src/app/options/components/ProfileForm.tsx @@ -2,6 +2,7 @@ import { object, string, type Output, minBytes, maxBytes, toTrimmed, boolean, no import { useForm } from 'react-hook-form' import { valibotResolver } from '@hookform/resolvers/valibot' +import { toast } from 'sonner' import AvatarSelect from './AvatarSelect' import { Button } from '@/components/ui/Button' import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/Form' @@ -31,6 +32,15 @@ const ProfileForm = () => { const handleSubmit = (data: Output) => { console.log(data) console.log(data.avatar.length * 0.001) + toast.success('Saved successfully!') + } + + const handleWarning = (error: Error) => { + toast.warning(error.message) + } + + const handleError = (error: Error) => { + toast.error(error.message) } return ( @@ -40,9 +50,14 @@ const ProfileForm = () => { control={form.control} name="avatar" render={({ field }) => ( - + - + diff --git a/src/components/ui/Toast.tsx b/src/components/ui/Toast.tsx deleted file mode 100644 index 7f40a14..0000000 --- a/src/components/ui/Toast.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import * as React from 'react' -import { Cross2Icon } from '@radix-ui/react-icons' -import * as ToastPrimitives from '@radix-ui/react-toast' -import { cva, type VariantProps } from 'class-variance-authority' - -import { cn } from '@/utils/index' - -const ToastProvider = ToastPrimitives.Provider - -const ToastViewport = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -ToastViewport.displayName = ToastPrimitives.Viewport.displayName - -const toastVariants = cva( - 'group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full', - { - variants: { - variant: { - default: 'border bg-background text-foreground', - destructive: 'destructive group border-destructive bg-destructive text-destructive-foreground' - } - }, - defaultVariants: { - variant: 'default' - } - } -) - -const Toast = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & VariantProps ->(({ className, variant, ...props }, ref) => { - return -}) -Toast.displayName = ToastPrimitives.Root.displayName - -const ToastAction = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -ToastAction.displayName = ToastPrimitives.Action.displayName - -const ToastClose = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - -)) -ToastClose.displayName = ToastPrimitives.Close.displayName - -const ToastTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -ToastTitle.displayName = ToastPrimitives.Title.displayName - -const ToastDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -ToastDescription.displayName = ToastPrimitives.Description.displayName - -type ToastProps = React.ComponentPropsWithoutRef - -type ToastActionElement = React.ReactElement - -export { - type ToastProps, - type ToastActionElement, - ToastProvider, - ToastViewport, - Toast, - ToastTitle, - ToastDescription, - ToastClose, - ToastAction -} diff --git a/src/components/ui/Toaster.tsx b/src/components/ui/Toaster.tsx deleted file mode 100644 index 3d7b82a..0000000 --- a/src/components/ui/Toaster.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from '@/components/ui/Toast' -import { useToast } from '@/components/ui/useToast' - -export function Toaster() { - const { toasts } = useToast() - - return ( - - {toasts.map(function ({ id, title, description, action, ...props }) { - return ( - -
- {title && {title}} - {description && {description}} -
- {action} - -
- ) - })} - -
- ) -} diff --git a/src/components/ui/useToast.ts b/src/components/ui/useToast.ts deleted file mode 100644 index 57d5e4a..0000000 --- a/src/components/ui/useToast.ts +++ /dev/null @@ -1,187 +0,0 @@ -// Inspired by react-hot-toast library -import * as React from 'react' - -import type { ToastActionElement, ToastProps } from '@/components/ui/Toast' - -const TOAST_LIMIT = 1 -const TOAST_REMOVE_DELAY = 1000000 - -type ToasterToast = ToastProps & { - id: string - title?: React.ReactNode - description?: React.ReactNode - action?: ToastActionElement -} - -const actionTypes = { - ADD_TOAST: 'ADD_TOAST', - UPDATE_TOAST: 'UPDATE_TOAST', - DISMISS_TOAST: 'DISMISS_TOAST', - REMOVE_TOAST: 'REMOVE_TOAST' -} as const - -let count = 0 - -function genId() { - count = (count + 1) % Number.MAX_SAFE_INTEGER - return count.toString() -} - -type ActionType = typeof actionTypes - -type Action = - | { - type: ActionType['ADD_TOAST'] - toast: ToasterToast - } - | { - type: ActionType['UPDATE_TOAST'] - toast: Partial - } - | { - type: ActionType['DISMISS_TOAST'] - toastId?: ToasterToast['id'] - } - | { - type: ActionType['REMOVE_TOAST'] - toastId?: ToasterToast['id'] - } - -interface State { - toasts: ToasterToast[] -} - -const toastTimeouts = new Map>() - -const addToRemoveQueue = (toastId: string) => { - if (toastTimeouts.has(toastId)) { - return - } - - const timeout = setTimeout(() => { - toastTimeouts.delete(toastId) - dispatch({ - type: 'REMOVE_TOAST', - toastId - }) - }, TOAST_REMOVE_DELAY) - - toastTimeouts.set(toastId, timeout) -} - -export const reducer = (state: State, action: Action): State => { - switch (action.type) { - case 'ADD_TOAST': - return { - ...state, - toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT) - } - - case 'UPDATE_TOAST': - return { - ...state, - toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)) - } - - case 'DISMISS_TOAST': { - const { toastId } = action - - // ! Side effects ! - This could be extracted 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, - open: false - } - : t - ) - } - } - case 'REMOVE_TOAST': - if (action.toastId === undefined) { - return { - ...state, - toasts: [] - } - } - return { - ...state, - toasts: state.toasts.filter((t) => t.id !== action.toastId) - } - } -} - -const listeners: Array<(state: State) => void> = [] - -let memoryState: State = { toasts: [] } - -function dispatch(action: Action) { - memoryState = reducer(memoryState, action) - listeners.forEach((listener) => { - listener(memoryState) - }) -} - -type Toast = Omit - -function toast({ ...props }: Toast) { - const id = genId() - - const update = (props: ToasterToast) => - dispatch({ - type: 'UPDATE_TOAST', - toast: { ...props, id } - }) - const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id }) - - dispatch({ - type: 'ADD_TOAST', - toast: { - ...props, - id, - open: true, - onOpenChange: (open) => { - if (!open) dismiss() - } - } - }) - - return { - id, - dismiss, - update - } -} - -function useToast() { - const [state, setState] = React.useState(memoryState) - - React.useEffect(() => { - listeners.push(setState) - return () => { - const index = listeners.indexOf(setState) - if (index > -1) { - listeners.splice(index, 1) - } - } - }, [state]) - - return { - ...state, - toast, - dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }) - } -} - -export { useToast, toast }