diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-12-28 08:32:32 +0000 |
|---|---|---|
| committer | Mohamed Bassem <me@mbassem.com> | 2025-12-28 08:32:32 +0000 |
| commit | 173fb99aed957fd8b9b47455a1cb5b6cf4115c34 (patch) | |
| tree | dec7ca534312c2863e32c004140484916635a2bb /apps/web/components/ui | |
| parent | af3010abaa37f7db4144820469422bdbb432adfc (diff) | |
| download | karakeep-173fb99aed957fd8b9b47455a1cb5b6cf4115c34.tar.zst | |
refactor: migrate toasts to sonner
Diffstat (limited to 'apps/web/components/ui')
| -rw-r--r-- | apps/web/components/ui/copy-button.tsx | 2 | ||||
| -rw-r--r-- | apps/web/components/ui/sonner.tsx | 71 | ||||
| -rw-r--r-- | apps/web/components/ui/toaster.tsx | 35 | ||||
| -rw-r--r-- | apps/web/components/ui/use-toast.ts | 188 |
4 files changed, 72 insertions, 224 deletions
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<typeof Sonner>; + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme(); + + return ( + <Sonner + theme={theme as ToasterProps["theme"]} + className="toaster group" + icons={{ + success: <CircleCheck className="h-4 w-4" />, + info: <Info className="h-4 w-4" />, + warning: <TriangleAlert className="h-4 w-4" />, + error: <OctagonX className="h-4 w-4" />, + loading: <LoaderCircle className="h-4 w-4 animate-spin" />, + }} + 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 ( - <ToastProvider> - {toasts.map(function ({ id, title, description, action, ...props }) { - return ( - <Toast key={id} {...props}> - <div className="grid gap-1"> - {title && <ToastTitle>{title}</ToastTitle>} - {description && ( - <ToastDescription>{description}</ToastDescription> - )} - </div> - {action} - <ToastClose /> - </Toast> - ); - })} - <ToastViewport /> - </ToastProvider> - ); -} 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<ToasterToast>; - } - | { - type: ActionType["DISMISS_TOAST"]; - toastId?: ToasterToast["id"]; - } - | { - type: ActionType["REMOVE_TOAST"]; - toastId?: ToasterToast["id"]; - }; - -interface State { - toasts: ToasterToast[]; -} - -const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>(); - -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<ToasterToast, "id">; - -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<State>(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 }; |
