Implement professional error handling and user feedback system
Major UX Improvements: - Replace all alert() dialogs with elegant toast notifications - Add comprehensive loading states for all async operations - Implement client-side form validation with visual feedback - Add React Error Boundary for graceful error recovery Components Added: - Toast notification system with success/error/warning/info types - useToast hook for easy notification management - ErrorBoundary component with development error details - Form validation with real-time feedback User Experience: - Professional toast notifications instead of browser alerts - Loading indicators prevent double-clicks and show progress - Immediate validation feedback prevents submission errors - Graceful error recovery with retry options - Enhanced accessibility with proper ARIA labels 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
dc1e3a62a1
commit
b6ff9a3cb6
5 changed files with 350 additions and 26 deletions
|
@ -1,6 +1,7 @@
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
import Header from '@/components/Header';
|
import Header from '@/components/Header';
|
||||||
import Footer from '@/components/Footer';
|
import Footer from '@/components/Footer';
|
||||||
|
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'OBS Source Switcher',
|
title: 'OBS Source Switcher',
|
||||||
|
@ -12,7 +13,11 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className="min-h-screen flex flex-col">
|
<body className="min-h-screen flex flex-col">
|
||||||
<Header />
|
<Header />
|
||||||
<main className="flex-1">{children}</main>
|
<main className="flex-1">
|
||||||
|
<ErrorBoundary>
|
||||||
|
{children}
|
||||||
|
</ErrorBoundary>
|
||||||
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Team } from '@/types';
|
import { Team } from '@/types';
|
||||||
|
import { useToast } from '@/lib/useToast';
|
||||||
|
import { ToastContainer } from '@/components/Toast';
|
||||||
|
|
||||||
export default function Teams() {
|
export default function Teams() {
|
||||||
const [teams, setTeams] = useState<Team[]>([]);
|
const [teams, setTeams] = useState<Team[]>([]);
|
||||||
|
@ -10,6 +12,10 @@ export default function Teams() {
|
||||||
const [editingTeam, setEditingTeam] = useState<Team | null>(null);
|
const [editingTeam, setEditingTeam] = useState<Team | null>(null);
|
||||||
const [editingName, setEditingName] = useState('');
|
const [editingName, setEditingName] = useState('');
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [updatingTeamId, setUpdatingTeamId] = useState<number | null>(null);
|
||||||
|
const [deletingTeamId, setDeletingTeamId] = useState<number | null>(null);
|
||||||
|
const [validationErrors, setValidationErrors] = useState<{[key: string]: string}>({});
|
||||||
|
const { toasts, removeToast, showSuccess, showError } = useToast();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTeams();
|
fetchTeams();
|
||||||
|
@ -30,7 +36,22 @@ export default function Teams() {
|
||||||
|
|
||||||
const handleAddTeam = async (e: React.FormEvent) => {
|
const handleAddTeam = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!newTeamName.trim()) return;
|
|
||||||
|
// Client-side validation
|
||||||
|
const errors: {[key: string]: string} = {};
|
||||||
|
if (!newTeamName.trim()) {
|
||||||
|
errors.newTeamName = 'Team name is required';
|
||||||
|
} else if (newTeamName.trim().length < 2) {
|
||||||
|
errors.newTeamName = 'Team name must be at least 2 characters';
|
||||||
|
} else if (newTeamName.trim().length > 50) {
|
||||||
|
errors.newTeamName = 'Team name must be less than 50 characters';
|
||||||
|
}
|
||||||
|
|
||||||
|
setValidationErrors(errors);
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
showError('Validation Error', 'Please fix the form errors');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
|
@ -43,21 +64,35 @@ export default function Teams() {
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setNewTeamName('');
|
setNewTeamName('');
|
||||||
fetchTeams();
|
fetchTeams();
|
||||||
|
showSuccess('Team Added', `"${newTeamName}" has been added successfully`);
|
||||||
} else {
|
} else {
|
||||||
const error = await res.json();
|
const error = await res.json();
|
||||||
alert(`Error adding team: ${error.error}`);
|
showError('Failed to Add Team', error.error || 'Unknown error occurred');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error adding team:', error);
|
console.error('Error adding team:', error);
|
||||||
alert('Failed to add team');
|
showError('Failed to Add Team', 'Network error or server unavailable');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateTeam = async (teamId: number) => {
|
const handleUpdateTeam = async (teamId: number) => {
|
||||||
if (!editingName.trim()) return;
|
// Client-side validation
|
||||||
|
if (!editingName.trim()) {
|
||||||
|
showError('Validation Error', 'Team name cannot be empty');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (editingName.trim().length < 2) {
|
||||||
|
showError('Validation Error', 'Team name must be at least 2 characters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (editingName.trim().length > 50) {
|
||||||
|
showError('Validation Error', 'Team name must be less than 50 characters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUpdatingTeamId(teamId);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/teams/${teamId}`, {
|
const res = await fetch(`/api/teams/${teamId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
@ -69,21 +104,26 @@ export default function Teams() {
|
||||||
setEditingTeam(null);
|
setEditingTeam(null);
|
||||||
setEditingName('');
|
setEditingName('');
|
||||||
fetchTeams();
|
fetchTeams();
|
||||||
|
showSuccess('Team Updated', `Team name changed to "${editingName}"`);
|
||||||
} else {
|
} else {
|
||||||
const error = await res.json();
|
const error = await res.json();
|
||||||
alert(`Error updating team: ${error.error}`);
|
showError('Failed to Update Team', error.error || 'Unknown error occurred');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating team:', error);
|
console.error('Error updating team:', error);
|
||||||
alert('Failed to update team');
|
showError('Failed to Update Team', 'Network error or server unavailable');
|
||||||
|
} finally {
|
||||||
|
setUpdatingTeamId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteTeam = async (teamId: number) => {
|
const handleDeleteTeam = async (teamId: number) => {
|
||||||
|
const teamToDelete = teams.find(t => t.team_id === teamId);
|
||||||
if (!confirm('Are you sure you want to delete this team? This will also delete all associated streams.')) {
|
if (!confirm('Are you sure you want to delete this team? This will also delete all associated streams.')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setDeletingTeamId(teamId);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/teams/${teamId}`, {
|
const res = await fetch(`/api/teams/${teamId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
@ -91,13 +131,16 @@ export default function Teams() {
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
fetchTeams();
|
fetchTeams();
|
||||||
|
showSuccess('Team Deleted', `"${teamToDelete?.team_name || 'Team'}" has been deleted`);
|
||||||
} else {
|
} else {
|
||||||
const error = await res.json();
|
const error = await res.json();
|
||||||
alert(`Error deleting team: ${error.error}`);
|
showError('Failed to Delete Team', error.error || 'Unknown error occurred');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting team:', error);
|
console.error('Error deleting team:', error);
|
||||||
alert('Failed to delete team');
|
showError('Failed to Delete Team', 'Network error or server unavailable');
|
||||||
|
} finally {
|
||||||
|
setDeletingTeamId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -125,13 +168,22 @@ export default function Teams() {
|
||||||
<div className="glass p-6 mb-6">
|
<div className="glass p-6 mb-6">
|
||||||
<h2 className="card-title">Add New Team</h2>
|
<h2 className="card-title">Add New Team</h2>
|
||||||
<form onSubmit={handleAddTeam} className="max-w-md mx-auto">
|
<form onSubmit={handleAddTeam} className="max-w-md mx-auto">
|
||||||
|
<div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newTeamName}
|
value={newTeamName}
|
||||||
onChange={(e) => setNewTeamName(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setNewTeamName(e.target.value);
|
||||||
|
// Clear validation error when user starts typing
|
||||||
|
if (validationErrors.newTeamName) {
|
||||||
|
setValidationErrors(prev => ({ ...prev, newTeamName: '' }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
placeholder="Enter team name"
|
placeholder="Enter team name"
|
||||||
className="input"
|
className={`input ${
|
||||||
|
validationErrors.newTeamName ? 'border-red-500/60 bg-red-500/10' : ''
|
||||||
|
}`}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
@ -144,6 +196,12 @@ export default function Teams() {
|
||||||
{isSubmitting ? 'Adding...' : 'Add Team'}
|
{isSubmitting ? 'Adding...' : 'Add Team'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{validationErrors.newTeamName && (
|
||||||
|
<div className="text-red-400 text-sm mt-2 text-center">
|
||||||
|
{validationErrors.newTeamName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -180,14 +238,16 @@ export default function Teams() {
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleUpdateTeam(team.team_id)}
|
onClick={() => handleUpdateTeam(team.team_id)}
|
||||||
|
disabled={updatingTeamId === team.team_id}
|
||||||
className="btn btn-success btn-sm"
|
className="btn btn-success btn-sm"
|
||||||
title="Save changes"
|
title="Save changes"
|
||||||
>
|
>
|
||||||
<span className="icon">✅</span>
|
<span className="icon">✅</span>
|
||||||
Save
|
{updatingTeamId === team.team_id ? 'Saving...' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={cancelEditing}
|
onClick={cancelEditing}
|
||||||
|
disabled={updatingTeamId === team.team_id}
|
||||||
className="btn-secondary btn-sm"
|
className="btn-secondary btn-sm"
|
||||||
title="Cancel editing"
|
title="Cancel editing"
|
||||||
>
|
>
|
||||||
|
@ -209,6 +269,7 @@ export default function Teams() {
|
||||||
<div className="button-group">
|
<div className="button-group">
|
||||||
<button
|
<button
|
||||||
onClick={() => startEditing(team)}
|
onClick={() => startEditing(team)}
|
||||||
|
disabled={deletingTeamId === team.team_id || updatingTeamId === team.team_id}
|
||||||
className="btn-secondary btn-sm"
|
className="btn-secondary btn-sm"
|
||||||
title="Edit team"
|
title="Edit team"
|
||||||
>
|
>
|
||||||
|
@ -217,11 +278,12 @@ export default function Teams() {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteTeam(team.team_id)}
|
onClick={() => handleDeleteTeam(team.team_id)}
|
||||||
|
disabled={deletingTeamId === team.team_id || updatingTeamId === team.team_id}
|
||||||
className="btn-danger btn-sm"
|
className="btn-danger btn-sm"
|
||||||
title="Delete team"
|
title="Delete team"
|
||||||
>
|
>
|
||||||
<span className="icon">🗑️</span>
|
<span className="icon">🗑️</span>
|
||||||
Delete
|
{deletingTeamId === team.team_id ? 'Deleting...' : 'Delete'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -231,6 +293,9 @@ export default function Teams() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Toast Notifications */}
|
||||||
|
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
76
components/ErrorBoundary.tsx
Normal file
76
components/ErrorBoundary.tsx
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
public state: State = {
|
||||||
|
hasError: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
if (this.props.fallback) {
|
||||||
|
return this.props.fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass p-8 text-center max-w-md mx-auto mt-8">
|
||||||
|
<div className="text-6xl mb-4">⚠️</div>
|
||||||
|
<h2 className="text-xl font-semibold mb-4 text-red-400">
|
||||||
|
Something went wrong
|
||||||
|
</h2>
|
||||||
|
<p className="text-white/80 mb-6">
|
||||||
|
An unexpected error occurred. Please refresh the page or try again later.
|
||||||
|
</p>
|
||||||
|
<div className="button-group" style={{ justifyContent: 'center' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="btn btn-success"
|
||||||
|
>
|
||||||
|
<span className="icon">🔄</span>
|
||||||
|
Refresh Page
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => this.setState({ hasError: false, error: undefined })}
|
||||||
|
className="btn-secondary"
|
||||||
|
>
|
||||||
|
<span className="icon">🔄</span>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||||
|
<details className="mt-6 text-left">
|
||||||
|
<summary className="text-red-400 cursor-pointer font-mono text-sm">
|
||||||
|
Error Details (Development)
|
||||||
|
</summary>
|
||||||
|
<pre className="text-red-300 text-xs mt-2 p-3 bg-red-500/10 rounded border border-red-500/20 overflow-auto">
|
||||||
|
{this.state.error.stack}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
115
components/Toast.tsx
Normal file
115
components/Toast.tsx
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export type ToastType = 'success' | 'error' | 'warning' | 'info';
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: string;
|
||||||
|
type: ToastType;
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastProps {
|
||||||
|
toast: Toast;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToastComponent({ toast, onRemove }: ToastProps) {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Animate in
|
||||||
|
setTimeout(() => setIsVisible(true), 10);
|
||||||
|
|
||||||
|
// Auto remove after duration
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsVisible(false);
|
||||||
|
setTimeout(() => onRemove(toast.id), 300);
|
||||||
|
}, toast.duration || 5000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [toast.id, toast.duration, onRemove]);
|
||||||
|
|
||||||
|
const getToastStyles = () => {
|
||||||
|
switch (toast.type) {
|
||||||
|
case 'success':
|
||||||
|
return 'bg-green-500/20 border-green-500/40 text-green-300';
|
||||||
|
case 'error':
|
||||||
|
return 'bg-red-500/20 border-red-500/40 text-red-300';
|
||||||
|
case 'warning':
|
||||||
|
return 'bg-yellow-500/20 border-yellow-500/40 text-yellow-300';
|
||||||
|
case 'info':
|
||||||
|
return 'bg-blue-500/20 border-blue-500/40 text-blue-300';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-500/20 border-gray-500/40 text-gray-300';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIcon = () => {
|
||||||
|
switch (toast.type) {
|
||||||
|
case 'success':
|
||||||
|
return '✅';
|
||||||
|
case 'error':
|
||||||
|
return '❌';
|
||||||
|
case 'warning':
|
||||||
|
return '⚠️';
|
||||||
|
case 'info':
|
||||||
|
return 'ℹ️';
|
||||||
|
default:
|
||||||
|
return '📢';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
glass p-4 border rounded-lg min-w-80 max-w-md
|
||||||
|
transform transition-all duration-300 ease-out
|
||||||
|
${isVisible ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'}
|
||||||
|
${getToastStyles()}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="text-lg flex-shrink-0">{getIcon()}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-semibold text-sm">{toast.title}</div>
|
||||||
|
{toast.message && (
|
||||||
|
<div className="text-sm opacity-90 mt-1">{toast.message}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsVisible(false);
|
||||||
|
setTimeout(() => onRemove(toast.id), 300);
|
||||||
|
}}
|
||||||
|
className="text-white/60 hover:text-white text-lg leading-none flex-shrink-0"
|
||||||
|
aria-label="Close notification"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastContainerProps {
|
||||||
|
toasts: Toast[];
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToastContainer({ toasts, onRemove }: ToastContainerProps) {
|
||||||
|
if (toasts.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed top-4 right-4 z-50 space-y-3 pointer-events-none">
|
||||||
|
<div className="space-y-3 pointer-events-auto">
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<ToastComponent key={toast.id} toast={toast} onRemove={onRemove} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
63
lib/useToast.ts
Normal file
63
lib/useToast.ts
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { Toast, ToastType } from '@/components/Toast';
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
|
|
||||||
|
const addToast = useCallback((
|
||||||
|
type: ToastType,
|
||||||
|
title: string,
|
||||||
|
message?: string,
|
||||||
|
duration?: number
|
||||||
|
) => {
|
||||||
|
const id = Math.random().toString(36).substr(2, 9);
|
||||||
|
const toast: Toast = {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
duration: duration ?? (type === 'error' ? 7000 : 5000), // Errors stay longer
|
||||||
|
};
|
||||||
|
|
||||||
|
setToasts((prev) => [...prev, toast]);
|
||||||
|
return id;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeToast = useCallback((id: string) => {
|
||||||
|
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearAllToasts = useCallback(() => {
|
||||||
|
setToasts([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Convenience methods
|
||||||
|
const showSuccess = useCallback((title: string, message?: string) => {
|
||||||
|
return addToast('success', title, message);
|
||||||
|
}, [addToast]);
|
||||||
|
|
||||||
|
const showError = useCallback((title: string, message?: string) => {
|
||||||
|
return addToast('error', title, message);
|
||||||
|
}, [addToast]);
|
||||||
|
|
||||||
|
const showWarning = useCallback((title: string, message?: string) => {
|
||||||
|
return addToast('warning', title, message);
|
||||||
|
}, [addToast]);
|
||||||
|
|
||||||
|
const showInfo = useCallback((title: string, message?: string) => {
|
||||||
|
return addToast('info', title, message);
|
||||||
|
}, [addToast]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
toasts,
|
||||||
|
addToast,
|
||||||
|
removeToast,
|
||||||
|
clearAllToasts,
|
||||||
|
showSuccess,
|
||||||
|
showError,
|
||||||
|
showWarning,
|
||||||
|
showInfo,
|
||||||
|
};
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue