forked from 77media/video-flow
全局消息配置
This commit is contained in:
parent
8590196ff8
commit
0d31522709
@ -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];
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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 />
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
16
app/types/global.d.ts
vendored
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
92
components/common/GlobalMessage.tsx
Normal file
92
components/common/GlobalMessage.tsx
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
{/* 视频工具组件 - 使用独立组件 */}
|
||||
|
||||
@ -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]);
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
};
|
||||
|
||||
@ -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修改的数据
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
}}
|
||||
|
||||
@ -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 };
|
||||
@ -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,
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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 };
|
||||
@ -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"]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user