diff --git a/src/components/AddTransactionButton.tsx b/src/components/AddTransactionButton.tsx index 3925a3e..03dc537 100644 --- a/src/components/AddTransactionButton.tsx +++ b/src/components/AddTransactionButton.tsx @@ -1,3 +1,4 @@ + import React, { useState } from 'react'; import { PlusIcon } from 'lucide-react'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog'; diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx index 2367807..5b7ef65 100644 --- a/src/components/ui/toaster.tsx +++ b/src/components/ui/toaster.tsx @@ -1,5 +1,5 @@ -import { useToast } from "@/hooks/use-toast" +import { useToast } from "@/hooks/toast" import { Toast, ToastClose, diff --git a/src/components/ui/use-toast.ts b/src/components/ui/use-toast.ts index cb982bd..f25c9a3 100644 --- a/src/components/ui/use-toast.ts +++ b/src/components/ui/use-toast.ts @@ -1,7 +1,6 @@ // Shadcn UI의 최신 버전에서는 use-toast가 hooks 폴더로 이동했습니다. // 이 파일은 기존 import 경로가 작동하도록 리디렉션합니다. -import { useToast, toast } from "@/hooks/use-toast"; - +import { useToast, toast } from "@/hooks/toast"; export { useToast, toast }; -export type { ToasterToast } from "@/hooks/use-toast"; +export type { ToasterToast } from "@/hooks/toast/types"; diff --git a/src/hooks/toast/constants.ts b/src/hooks/toast/constants.ts new file mode 100644 index 0000000..3120789 --- /dev/null +++ b/src/hooks/toast/constants.ts @@ -0,0 +1,3 @@ + +export const TOAST_LIMIT = 5 // 최대 5개로 제한 +export const TOAST_REMOVE_DELAY = 5000 // 5초 후 DOM에서 제거 diff --git a/src/hooks/toast/index.ts b/src/hooks/toast/index.ts new file mode 100644 index 0000000..e889307 --- /dev/null +++ b/src/hooks/toast/index.ts @@ -0,0 +1,61 @@ + +import * as React from "react" +import { Toast, ToasterToast } from "./types" +import { actionTypes } from "./types" +import { listeners, memoryState } from "./store" +import { genId, dispatch } from "./toastManager" + +export { TOAST_LIMIT, TOAST_REMOVE_DELAY } from "./constants" +export type { ToasterToast } from "./types" + +function toast({ ...props }: Toast) { + const id = genId() + + const update = (props: ToasterToast) => + dispatch({ + type: actionTypes.UPDATE_TOAST, + toast: { ...props, id }, + }) + const dismiss = () => dispatch({ type: actionTypes.DISMISS_TOAST, toastId: id }) + + dispatch({ + type: actionTypes.ADD_TOAST, + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss() + }, + duration: props.duration || 3000, // 기본 지속 시간 3초로 단축 + }, + }) + + 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: actionTypes.DISMISS_TOAST, toastId }), + } +} + +export { useToast, toast } diff --git a/src/hooks/toast/reducer.ts b/src/hooks/toast/reducer.ts new file mode 100644 index 0000000..f62c7df --- /dev/null +++ b/src/hooks/toast/reducer.ts @@ -0,0 +1,78 @@ + +import { TOAST_REMOVE_DELAY } from './constants' +import { Action, State, actionTypes } from './types' + +// 토스트 타임아웃 맵 +export const toastTimeouts = new Map>() + +// 토스트 자동 제거 함수 +export const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: actionTypes.REMOVE_TOAST, + toastId: toastId, + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case actionTypes.ADD_TOAST: + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + } + + case actionTypes.UPDATE_TOAST: + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + } + + case actionTypes.DISMISS_TOAST: { + const { toastId } = action + + 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 actionTypes.REMOVE_TOAST: + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + } + default: + return state + } +} diff --git a/src/hooks/toast/store.ts b/src/hooks/toast/store.ts new file mode 100644 index 0000000..1112e29 --- /dev/null +++ b/src/hooks/toast/store.ts @@ -0,0 +1,9 @@ + +import { State } from './types' + +// 전역 상태 및 리스너 +export const listeners: Array<(state: State) => void> = [] +export let memoryState: State = { toasts: [] } + +// 마지막 액션 추적 (중복 방지용) +export let lastAction: { type: string; id?: string; time: number } | null = null diff --git a/src/hooks/toast/toastManager.ts b/src/hooks/toast/toastManager.ts new file mode 100644 index 0000000..5bebe86 --- /dev/null +++ b/src/hooks/toast/toastManager.ts @@ -0,0 +1,51 @@ + +import { Action, actionTypes } from './types' +import { TOAST_LIMIT } from './constants' +import { reducer } from './reducer' +import { listeners, memoryState, lastAction } from './store' + +// ID 생성기 +let count = 0 + +export function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString() +} + +export function dispatch(action: Action) { + // 마지막 액션 정보 추출 + let actionId: string | undefined = undefined; + if ('toast' in action && action.toast) { + actionId = action.toast.id; + } else if ('toastId' in action) { + actionId = action.toastId; + } + + // 동일한 토스트에 대한 중복 액션 방지 + const now = Date.now(); + const isSameAction = lastAction && + lastAction.type === action.type && + ((action.type === actionTypes.ADD_TOAST && + lastAction.time > now - 1000) || // ADD 액션은 1초 내 중복 방지 + (action.type !== actionTypes.ADD_TOAST && + actionId === lastAction.id && + lastAction.time > now - 300)); // 다른 액션은 300ms 내 중복 방지 + + if (isSameAction) { + console.log('중복 토스트 액션 무시:', action.type); + return; + } + + // 액션 추적 업데이트 + lastAction = { + type: action.type, + id: actionId, + time: now + }; + + // 실제 상태 업데이트 및 리스너 호출 + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} diff --git a/src/hooks/toast/types.ts b/src/hooks/toast/types.ts new file mode 100644 index 0000000..878a99f --- /dev/null +++ b/src/hooks/toast/types.ts @@ -0,0 +1,46 @@ + +import * as React from "react" + +export type ToasterToast = { + id: string + title?: React.ReactNode + description?: React.ReactNode + action?: React.ReactNode + variant?: "default" | "destructive" + duration?: number + open?: boolean + onOpenChange?: (open: boolean) => void +} + +export const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const + +export type ActionType = typeof actionTypes + +export type Action = + | { + type: ActionType["ADD_TOAST"] + toast: ToasterToast + } + | { + type: ActionType["UPDATE_TOAST"] + toast: Partial & { id: string } + } + | { + type: ActionType["DISMISS_TOAST"] + toastId?: string + } + | { + type: ActionType["REMOVE_TOAST"] + toastId?: string + } + +export interface State { + toasts: ToasterToast[] +} + +export type Toast = Omit diff --git a/src/hooks/use-toast.ts b/src/hooks/use-toast.ts index 6c277ef..ff8eb0c 100644 --- a/src/hooks/use-toast.ts +++ b/src/hooks/use-toast.ts @@ -1,227 +1,4 @@ -import * as React from "react" - -const TOAST_LIMIT = 5 // 최대 5개로 제한 -export const TOAST_REMOVE_DELAY = 5000 // 5초 후 DOM에서 제거 - -export type ToasterToast = { - id: string - title?: React.ReactNode - description?: React.ReactNode - action?: React.ReactNode - variant?: "default" | "destructive" - duration?: number - open?: boolean - onOpenChange?: (open: boolean) => void -} - -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 & { id: string } - } - | { - type: ActionType["DISMISS_TOAST"] - toastId?: string - } - | { - type: ActionType["REMOVE_TOAST"] - toastId?: string - } - -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: actionTypes.REMOVE_TOAST, - toastId: toastId, - }) - }, TOAST_REMOVE_DELAY) - - toastTimeouts.set(toastId, timeout) -} - -export const reducer = (state: State, action: Action): State => { - switch (action.type) { - case actionTypes.ADD_TOAST: - return { - ...state, - toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), - } - - case actionTypes.UPDATE_TOAST: - return { - ...state, - toasts: state.toasts.map((t) => - t.id === action.toast.id ? { ...t, ...action.toast } : t - ), - } - - case actionTypes.DISMISS_TOAST: { - const { toastId } = action - - 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 actionTypes.REMOVE_TOAST: - if (action.toastId === undefined) { - return { - ...state, - toasts: [], - } - } - return { - ...state, - toasts: state.toasts.filter((t) => t.id !== action.toastId), - } - default: - return state - } -} - -// 전역 상태 및 리스너 -const listeners: Array<(state: State) => void> = [] -let memoryState: State = { toasts: [] } - -// 마지막 액션 추적 (중복 방지용) -let lastAction: { type: string; id?: string; time: number } | null = null - -function dispatch(action: Action) { - // 마지막 액션 정보 추출 - let actionId: string | undefined = undefined; - if ('toast' in action && action.toast) { - actionId = action.toast.id; - } else if ('toastId' in action) { - actionId = action.toastId; - } - - // 동일한 토스트에 대한 중복 액션 방지 - const now = Date.now(); - const isSameAction = lastAction && - lastAction.type === action.type && - ((action.type === actionTypes.ADD_TOAST && - lastAction.time > now - 1000) || // ADD 액션은 1초 내 중복 방지 - (action.type !== actionTypes.ADD_TOAST && - actionId === lastAction.id && - lastAction.time > now - 300)); // 다른 액션은 300ms 내 중복 방지 - - if (isSameAction) { - console.log('중복 토스트 액션 무시:', action.type); - return; - } - - // 액션 추적 업데이트 - lastAction = { - type: action.type, - id: actionId, - time: now - }; - - // 실제 상태 업데이트 및 리스너 호출 - 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: actionTypes.UPDATE_TOAST, - toast: { ...props, id }, - }) - const dismiss = () => dispatch({ type: actionTypes.DISMISS_TOAST, toastId: id }) - - dispatch({ - type: actionTypes.ADD_TOAST, - toast: { - ...props, - id, - open: true, - onOpenChange: (open) => { - if (!open) dismiss() - }, - duration: props.duration || 3000, // 기본 지속 시간 3초로 단축 - }, - }) - - 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: actionTypes.DISMISS_TOAST, toastId }), - } -} - -export { useToast, toast } +// 이 파일은 기존 import 경로 호환성을 위한 리디렉션입니다 +export { useToast, toast, TOAST_LIMIT, TOAST_REMOVE_DELAY } from "./toast"; +export type { ToasterToast } from "./toast/types"; diff --git a/src/hooks/useToast.wrapper.ts b/src/hooks/useToast.wrapper.ts index 1ef301f..8b74b79 100644 --- a/src/hooks/useToast.wrapper.ts +++ b/src/hooks/useToast.wrapper.ts @@ -1,5 +1,6 @@ -import { useToast as useOriginalToast, toast as originalToast, ToasterToast } from '@/hooks/use-toast'; +import { useToast as useOriginalToast, toast as originalToast } from '@/hooks/toast'; +import type { ToasterToast } from '@/hooks/toast/types'; /** * 토스트 중복 방지를 위한 설정값