From 173fb99aed957fd8b9b47455a1cb5b6cf4115c34 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Sun, 28 Dec 2025 08:32:32 +0000 Subject: refactor: migrate toasts to sonner --- apps/web/components/ui/copy-button.tsx | 2 +- apps/web/components/ui/sonner.tsx | 71 +++++++++++++ apps/web/components/ui/toaster.tsx | 35 ------ apps/web/components/ui/use-toast.ts | 188 --------------------------------- 4 files changed, 72 insertions(+), 224 deletions(-) create mode 100644 apps/web/components/ui/sonner.tsx delete mode 100644 apps/web/components/ui/toaster.tsx delete mode 100644 apps/web/components/ui/use-toast.ts (limited to 'apps/web/components/ui') diff --git a/apps/web/components/ui/copy-button.tsx b/apps/web/components/ui/copy-button.tsx index 8d8699f8..fb1f943f 100644 --- a/apps/web/components/ui/copy-button.tsx +++ b/apps/web/components/ui/copy-button.tsx @@ -1,9 +1,9 @@ import React, { useEffect, useState } from "react"; +import { toast } from "@/components/ui/sonner"; import { cn } from "@/lib/utils"; import { Check, Copy } from "lucide-react"; import { Button } from "./button"; -import { toast } from "./use-toast"; export default function CopyBtn({ className, diff --git a/apps/web/components/ui/sonner.tsx b/apps/web/components/ui/sonner.tsx new file mode 100644 index 00000000..d281f4ae --- /dev/null +++ b/apps/web/components/ui/sonner.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { + CircleCheck, + Info, + LoaderCircle, + OctagonX, + TriangleAlert, +} from "lucide-react"; +import { useTheme } from "next-themes"; +import { Toaster as Sonner, toast } from "sonner"; + +type ToasterProps = React.ComponentProps; + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme(); + + return ( + , + info: , + warning: , + error: , + loading: , + }} + toastOptions={{ + classNames: { + toast: + "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg", + description: "group-[.toast]:text-muted-foreground", + actionButton: + "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground", + cancelButton: + "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground", + }, + }} + {...props} + /> + ); +}; + +/** + * Compat layer for migrating from old toaster to sonner + * @deprecated Use sonner's natie toast instead + */ +const legacyToast = ({ + title, + description, + variant, +}: { + title?: React.ReactNode; + description?: React.ReactNode; + variant?: "destructive" | "default"; +}) => { + let toastTitle = title; + let toastDescription: React.ReactNode | undefined = description; + if (!title) { + toastTitle = description; + toastDescription = undefined; + } + if (variant === "destructive") { + toast.error(toastTitle, { description: toastDescription }); + } else { + toast(toastTitle, { description: toastDescription }); + } +}; + +export { Toaster, legacyToast as toast }; diff --git a/apps/web/components/ui/toaster.tsx b/apps/web/components/ui/toaster.tsx deleted file mode 100644 index 7d82ed55..00000000 --- a/apps/web/components/ui/toaster.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client"; - -import { - Toast, - ToastClose, - ToastDescription, - ToastProvider, - ToastTitle, - ToastViewport, -} from "@/components/ui/toast"; -import { useToast } from "@/components/ui/use-toast"; - -export function Toaster() { - const { toasts } = useToast(); - - return ( - - {toasts.map(function ({ id, title, description, action, ...props }) { - return ( - -
- {title && {title}} - {description && ( - {description} - )} -
- {action} - -
- ); - })} - -
- ); -} diff --git a/apps/web/components/ui/use-toast.ts b/apps/web/components/ui/use-toast.ts deleted file mode 100644 index c3e7e884..00000000 --- a/apps/web/components/ui/use-toast.ts +++ /dev/null @@ -1,188 +0,0 @@ -// Inspired by react-hot-toast library -import type { ToastActionElement, ToastProps } from "@/components/ui/toast"; -import * as React from "react"; - -const TOAST_LIMIT = 10; -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: 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: ((_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: 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 }; -- cgit v1.2.3-70-g09d2