全局消息配置

This commit is contained in:
北枳 2025-09-06 16:58:21 +08:00
parent 8590196ff8
commit 0d31522709
22 changed files with 172 additions and 531 deletions

View File

@ -1,4 +1,3 @@
import { message } from "antd";
import { debounce } from "lodash";
/**
@ -52,11 +51,7 @@ export const errorHandle = debounce(
customMessage || HTTP_ERROR_MESSAGES[code] || DEFAULT_ERROR_MESSAGE;
// 显示错误提示
message.error({
content: errorMessage,
duration: 3,
className: 'custom-error-message'
});
window.msg.error(errorMessage);
// 执行特殊错误码的处理函数
const handler = ERROR_HANDLERS[code];

View File

@ -1,5 +1,4 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig, AxiosHeaders } from 'axios';
import { message } from "antd";
import { BASE_URL } from './constants'
import { errorHandle } from './errorHandle';

View File

@ -1,13 +1,11 @@
import { TopBar } from "@/components/layout/top-bar";
import { HomePage2 } from "@/components/pages/home-page2";
import OAuthCallbackHandler from "@/components/ui/oauth-callback-handler";
export default function Home() {
return (
<>
<TopBar collapsed={true} />
<OAuthCallbackHandler />
<HomePage2 />
</>
);

View File

@ -1,7 +1,6 @@
import { CreateMovieProjectV2Request, CreateMovieProjectV3Request } from "@/api/DTO/movie_start_dto";
import { createMovieProject, createMovieProjectV2, createMovieProjectV3 } from "@/api/create_movie";
import { QueueResponse, QueueStatus, withQueuePolling, QueueResponseData } from "@/api/movie_queue";
import { message } from "antd";
import { QueueResponse, withQueuePolling, QueueResponseData } from "@/api/movie_queue";
/**
*
@ -56,13 +55,13 @@ export class MovieProjectService {
},
onError: (error: Error) => {
if (error.message === '操作已取消') {
message.info('Queue cancelled');
window.msg.info('Queue cancelled');
} else {
message.error(error instanceof Error ? error.message : "Failed to create project");
window.msg.error(error instanceof Error ? error.message : "Failed to create project");
}
},
onCancel: () => {
message.info('Queue cancelled');
window.msg.info('Queue cancelled');
}
});
@ -80,7 +79,7 @@ export class MovieProjectService {
if (error instanceof Error && error.message === '操作已取消') {
throw error;
}
message.error(error instanceof Error ? error.message : "Failed to create project");
window.msg.error(error instanceof Error ? error.message : "Failed to create project");
throw error;
}
}

View File

@ -1,4 +1,3 @@
import { message } from "antd";
import { StoryTemplateEntity } from "../domain/Entities";
import { useUploadFile } from "../domain/service";
import { debounce } from "lodash";

16
app/types/global.d.ts vendored Normal file
View File

@ -0,0 +1,16 @@
import { GlobalMessage } from '@/components/common/GlobalMessage';
import { toast } from 'sonner';
declare global {
interface Window {
msg: GlobalMessage;
$message: {
success: (message: string, duration?: number) => void;
error: (message: string, duration?: number) => void;
warning: (message: string, duration?: number) => void;
info: (message: string, duration?: number) => void;
loading: (message: string) => ReturnType<typeof toast.promise>;
dismiss: () => void;
};
}
}

View File

@ -0,0 +1,92 @@
'use client';
import { toast, type ToastT } from 'sonner';
import {
CheckCircle2,
XCircle,
AlertCircle,
Info,
Loader2
} from 'lucide-react';
/**
*
* sonner toast API
*/
class GlobalMessage {
success(message: string, duration = 3000, options?: Partial<ToastT>) {
toast.success(message, {
icon: <CheckCircle2 className="h-4 w-4" />,
duration,
...options,
});
}
error(message: string, duration = 3000, options?: Partial<ToastT>) {
toast.error(message, {
icon: <XCircle className="h-4 w-4" />,
duration,
...options,
});
}
warning(message: string, duration = 3000, options?: Partial<ToastT>) {
toast.warning(message, {
icon: <AlertCircle className="h-4 w-4" />,
duration,
...options,
});
}
info(message: string, duration = 3000, options?: Partial<ToastT>) {
toast(message, {
icon: <Info className="h-4 w-4" />,
duration,
...options,
});
}
loading(message: string, options?: Partial<ToastT>) {
return toast.promise(
new Promise(() => {}),
{
loading: message,
icon: <Loader2 className="h-4 w-4 animate-spin" />,
...options,
}
);
}
dismiss() {
toast.dismiss();
}
}
// 导出单例实例
export const msg = new GlobalMessage();
// 为了方便使用,也导出单独的方法
export const { success, error, warning, info, loading, dismiss } = msg;
// 在文件末尾添加全局注册方法
export function registerGlobalMessage() {
if (typeof window !== 'undefined') {
// 注册完整实例
window.msg = msg;
// 注册便捷方法
window.$message = {
success: msg.success.bind(msg),
error: msg.error.bind(msg),
warning: msg.warning.bind(msg),
info: msg.info.bind(msg),
loading: msg.loading.bind(msg),
dismiss: msg.dismiss.bind(msg),
};
// 添加调试信息
console.log('GlobalMessage registered:', {
msg: window.msg,
$message: window.$message
});
}
}

View File

@ -239,9 +239,9 @@ export function TopBar({ collapsed }: { collapsed: boolean }) {
)}
{/* Notifications */}
{/* <Button variant="ghost" size="sm" onClick={() => showQueueNotification(3, 10)}>
<Button variant="ghost" size="sm" onClick={() => window.msg.error('Loading...')}>
<Bell className="h-4 w-4" />
</Button> */}
</Button>
{/* Theme Toggle */}
{/* <Button

View File

@ -287,14 +287,21 @@ export default function CreateToVideo2() {
{/* 加载更多指示器 */}
{isLoadingMore && (
<div className="flex justify-center py-12">
<div className="flex items-center gap-3 px-6 py-3 bg-black/30 backdrop-blur-xl border border-white/10 rounded-full">
<div className="flex items-center">
<Loader2 className="w-5 h-5 animate-spin text-purple-400" />
<span className="text-white/90 font-medium">Loading more projects...</span>
</div>
</div>
)}
</div>
)}
{episodeList.length === 0 && isLoading && (
<div className="flex justify-center py-12 h-full">
<div className="flex items-center">
<Loader2 className="w-8 h-8 animate-spin text-purple-400" />
</div>
</div>
)}
</div>
{/* 视频工具组件 - 使用独立组件 */}

View File

@ -8,7 +8,6 @@ import { detailScriptEpisodeNew, getScriptTitle, getRunningStreamData, pauseMovi
import { useScriptService } from "@/app/service/Interaction/ScriptService";
import { useUpdateEffect } from '@/app/hooks/useUpdateEffect';
import { LOADING_TEXT_MAP, TaskObject, Status, Stage } from '@/api/DTO/movieEdit';
import { msg } from '@/utils/message';
interface UseWorkflowDataProps {
onEditPlanGenerated?: () => void;
@ -209,7 +208,7 @@ export function useWorkflowData({ onEditPlanGenerated }: UseWorkflowDataProps =
useEffect(() => {
if (isShowError) {
msg.error('Too many failed storyboards, unable to execute automatic editing.', 3000);
window.msg.error('Too many failed storyboards, unable to execute automatic editing.', 3000);
}
}, [isShowError]);

View File

@ -3,15 +3,11 @@
import { Provider } from 'react-redux';
import { store } from '@/lib/store/store';
import { ThemeProvider } from 'next-themes';
import { Toaster } from '@/components/ui/sonner';
import { Toaster } from 'sonner';
import AuthGuard from './auth/auth-guard';
import dynamic from 'next/dynamic';
// 动态导入 OAuthCallbackHandler 和 DevHelper
const OAuthCallbackHandler = dynamic(
() => import('./ui/oauth-callback-handler').then(mod => mod.default),
{ ssr: false }
);
import { registerGlobalMessage } from '@/components/common/GlobalMessage';
import { useEffect } from 'react';
const DevHelper = dynamic(
() => import('@/utils/dev-helper').then(mod => (mod as any).default),
@ -19,6 +15,11 @@ const DevHelper = dynamic(
);
export function Providers({ children }: { children: React.ReactNode }) {
// 注册全局消息提醒
useEffect(() => {
registerGlobalMessage();
}, []);
return (
<Provider store={store}>
<ThemeProvider
@ -30,8 +31,23 @@ export function Providers({ children }: { children: React.ReactNode }) {
<AuthGuard>
{children}
</AuthGuard>
<Toaster />
<OAuthCallbackHandler />
<Toaster
position="bottom-left"
theme="dark"
richColors
toastOptions={{
// 统一配置所有 toast 的样式
classNames: {
toast: "dark:bg-zinc-900 dark:text-zinc-100 dark:border-zinc-800",
title: "dark:text-zinc-100 font-medium",
description: "dark:text-zinc-400",
actionButton: "dark:bg-zinc-700 dark:text-zinc-100",
cancelButton: "dark:bg-zinc-600 dark:text-zinc-100",
closeButton: "dark:text-zinc-300 hover:text-zinc-100"
},
duration: 3000,
}}
/>
{process.env.NODE_ENV === 'development' && <DevHelper />}
</ThemeProvider>
</Provider>

View File

@ -5,7 +5,6 @@ import { ScriptData, ScriptBlock, ScriptContent, ThemeTagBgColor, ThemeType } fr
import ContentEditable, { ContentEditableEvent } from 'react-contenteditable';
import { SelectDropdown } from '@/components/ui/select-dropdown';
import { TypewriterText } from '@/components/workflow/work-office/common/TypewriterText';
import { msg } from '@/utils/message';
interface ScriptRendererProps {
data: any[];
@ -126,7 +125,7 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data, setIsPause
const handleThemeTagChange = (value: string[]) => {
console.log('主题标签更改', value);
if (value.length > 5) {
msg.error('最多可选择5个主题标签', 3000);
window.msg.error('max 5 theme tags', 3000);
return;
}
setAddThemeTag(value);
@ -217,7 +216,7 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data, setIsPause
className="w-6 h-6 p-1 cursor-pointer text-gray-600 hover:text-blue-500 transition-colors"
onClick={() => {
// 提示权限不够
msg.error('No permission!');
window.msg.error('No permission!');
return;
handleEditBlock(block);
}}
@ -226,7 +225,7 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data, setIsPause
className="w-6 h-6 p-1 cursor-pointer text-gray-600 hover:text-blue-500 transition-colors"
onClick={() => {
navigator.clipboard.writeText(block.content.map(item => item.text).join('\n'));
msg.success('Copied!');
window.msg.success('Copied!');
}}
/>
</motion.div>

View File

@ -12,7 +12,6 @@ import { useEditData } from '@/components/pages/work-flow/use-edit-data';
import { useSearchParams } from 'next/navigation';
import { RoleEntity } from '@/app/service/domain/Entities';
import { Role } from '@/api/DTO/movieEdit';
import { msg } from '@/utils/message';
interface CharacterTabContentProps {
originalRoles: Role[];
@ -113,7 +112,7 @@ CharacterTabContentProps
const handleSmartPolish = (text: string) => {
// 然后调用优化角色文本
msg.error('No permission!');
window.msg.error('No permission!');
return;
optimizeRoleText(text);
};
@ -212,7 +211,7 @@ CharacterTabContentProps
};
const handleOpenReplaceLibrary = async () => {
msg.error('No permission!');
window.msg.error('No permission!');
return;
setIsLoadingLibrary(true);
setIsReplaceLibraryOpen(true);
@ -222,7 +221,7 @@ CharacterTabContentProps
};
const handleRegenerate = async () => {
msg.error('No permission!');
window.msg.error('No permission!');
return;
console.log('Regenerate');
setIsRegenerate(true);
@ -237,7 +236,7 @@ CharacterTabContentProps
};
const handleUploadClick = () => {
msg.error('No permission!');
window.msg.error('No permission!');
return;
fileInputRef.current?.click();
};

View File

@ -13,7 +13,6 @@ import { MusicTabContent } from './music-tab-content';
import FloatingGlassPanel from './FloatingGlassPanel';
import { SaveEditUseCase } from '@/app/service/usecase/SaveEditUseCase';
import { TaskObject } from '@/api/DTO/movieEdit';
import { msg } from '@/utils/message';
interface EditModalProps {
isOpen: boolean;
@ -124,7 +123,7 @@ export function EditModal({
}
const handleSave = () => {
msg.error('No permission!');
window.msg.error('No permission!');
return;
console.log('handleSave');
// setIsRemindFallbackOpen(true);
@ -143,7 +142,7 @@ export function EditModal({
}
const handleConfirmGotoFallback = () => {
msg.error('No permission!');
window.msg.error('No permission!');
return;
setDisabledBtn(true);
console.log('handleConfirmGotoFallback');
@ -170,7 +169,7 @@ export function EditModal({
}
const handleReset = () => {
msg.error('No permission!');
window.msg.error('No permission!');
return;
console.log('handleReset');
// 重置当前tab修改的数据

View File

@ -1,90 +0,0 @@
'use client';
import { useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { validateOAuthState } from '@/lib/auth';
import { toast } from '@/hooks/use-toast';
export default function OAuthCallbackHandler() {
const searchParams = useSearchParams();
const router = useRouter();
useEffect(() => {
// Check if this is an OAuth callback
const state = searchParams.get('state');
const session = searchParams.get('session');
const userJson = searchParams.get('user');
const error = searchParams.get('error');
// Log debug information
console.log('OAuth callback handler running with params:', {
state: state || 'not present',
session: session ? 'present' : 'not present',
userJson: userJson ? 'present' : 'not present',
error: error || 'none'
});
// Handle error case
if (error) {
console.error('OAuth error:', error);
toast({
title: 'Authentication Error',
description: `Google sign-in failed: ${error}`,
variant: 'destructive',
});
router.push(`/login?error=${error}`);
return;
}
// If we have state and session, this might be an OAuth callback
if (state && session) {
// Validate the state parameter to prevent CSRF
const isValid = validateOAuthState(state);
if (!isValid) {
// State validation failed, possible CSRF attack
console.error('OAuth state validation failed');
toast({
title: 'Authentication Error',
description: 'Security validation failed. Please try signing in again.',
variant: 'destructive',
});
router.push('/login?error=invalid_state');
return;
}
console.log('OAuth state validation successful');
// State is valid, process the login
if (userJson) {
try {
const user = JSON.parse(decodeURIComponent(userJson));
console.log('OAuth user data parsed successfully');
// Store the user in session
sessionStorage.setItem('currentUser', JSON.stringify(user));
// Show success message
toast({
title: 'Signed in successfully',
description: `Welcome ${user.name}!`,
});
// Remove the query parameters from the URL
router.replace('/');
} catch (error) {
console.error('Failed to parse user data', error);
toast({
title: 'Authentication Error',
description: 'Failed to process authentication data',
variant: 'destructive',
});
router.push('/login?error=invalid_user_data');
}
}
}
}, [searchParams, router]);
// This is a utility component that doesn't render anything
return null;
}

View File

@ -2,7 +2,6 @@ import React, { forwardRef, useRef, useState } from "react";
import { useDeepCompareEffect } from "@/hooks/useDeepCompareEffect";
import { Plus, X, UserRoundPlus, MessageCirclePlus, MessageCircleMore, ClipboardType } from "lucide-react";
import ShotEditor from "./ShotEditor";
import { toast } from "sonner";
import { TextToShotAdapter } from "@/app/service/adapter/textToShot";
@ -90,11 +89,7 @@ export const ShotsEditor = forwardRef<any, ShotsEditorProps>(function ShotsEdito
const addShot = () => {
if (shots.length > 3) {
toast.error('No more than 4 shots', {
duration: 3000,
position: 'top-center',
richColors: true,
});
window.msg.error('max 4 shots');
return;
}
const newShot = createEmptyShot();

View File

@ -13,7 +13,6 @@ import HorizontalScroller from './HorizontalScroller';
import { useEditData } from '@/components/pages/work-flow/use-edit-data';
import { RoleEntity, VideoSegmentEntity } from '@/app/service/domain/Entities';
import { ShotVideo } from '@/api/DTO/movieEdit';
import { msg } from '@/utils/message';
interface ShotTabContentProps {
currentSketchIndex: number;
@ -448,7 +447,7 @@ export const ShotTabContent = forwardRef<
{/* 人物替换按钮 */}
<motion.button
onClick={() => {
msg.error('No permission!');
window.msg.error('No permission!');
return;
handleScan()
}}
@ -504,7 +503,7 @@ export const ShotTabContent = forwardRef<
</motion.button> */}
<motion.button
onClick={() => {
msg.error('No permission!');
window.msg.error('No permission!');
return;
handleRegenerate();
}}

View File

@ -1,31 +0,0 @@
'use client';
import { useTheme } from 'next-themes';
import { Toaster as Sonner } 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"
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}
/>
);
};
export { Toaster };

View File

@ -1,129 +0,0 @@
'use client';
import * as React from 'react';
import * as ToastPrimitives from '@radix-ui/react-toast';
import { cva, type VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react';
import { cn } from '@/public/lib/utils';
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
className
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 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<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
className
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn('text-sm font-semibold', className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn('text-sm opacity-90', className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

View File

@ -1,35 +0,0 @@
'use client';
import { useToast } from '@/hooks/use-toast';
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from '@/components/ui/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>
);
}

View File

@ -1,191 +0,0 @@
'use client';
// 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<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: Array<(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 };

View File

@ -25,6 +25,12 @@
"useDefineForClassFields": true,
"forceConsistentCasingInFileNames": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"app/types/global.d.ts" //
],
"exclude": ["node_modules", "**/*.test.ts", "**/*.spec.ts", ".next"]
}