diff options
Diffstat (limited to 'frontend/src/components/shared')
-rw-r--r-- | frontend/src/components/shared/Notification.tsx | 82 | ||||
-rw-r--r-- | frontend/src/components/shared/NotificationContext.tsx | 55 | ||||
-rw-r--r-- | frontend/src/components/shared/ThemeProvider.tsx | 56 | ||||
-rw-r--r-- | frontend/src/components/shared/ThemeToggle.tsx | 36 |
4 files changed, 229 insertions, 0 deletions
diff --git a/frontend/src/components/shared/Notification.tsx b/frontend/src/components/shared/Notification.tsx new file mode 100644 index 0000000..bcc11c4 --- /dev/null +++ b/frontend/src/components/shared/Notification.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { CheckCircle, AlertCircle, X } from 'lucide-react'; + +export type NotificationType = 'success' | 'error' | 'info'; + +interface NotificationProps { + type: NotificationType; + message: string; + duration?: number; + onClose?: () => void; +} + +export function Notification({ + type, + message, + duration = 5000, // Default duration of 5 seconds + onClose +}: NotificationProps) { + const [isVisible, setIsVisible] = useState(true); + + useEffect(() => { + if (duration > 0) { + const timer = setTimeout(() => { + setIsVisible(false); + if (onClose) onClose(); + }, duration); + + return () => clearTimeout(timer); + } + }, [duration, onClose]); + + const handleClose = () => { + setIsVisible(false); + if (onClose) onClose(); + }; + + if (!isVisible) return null; + + const getIcon = () => { + switch (type) { + case 'success': + return <CheckCircle className="h-5 w-5 text-green-500" />; + case 'error': + return <AlertCircle className="h-5 w-5 text-red-500" />; + case 'info': + return <AlertCircle className="h-5 w-5 text-blue-500" />; + default: + return null; + } + }; + + const getContainerClasses = () => { + const baseClasses = 'fixed top-4 right-4 z-50 flex items-center gap-3 rounded-lg p-4 shadow-md max-w-sm'; + + switch (type) { + case 'success': + return `${baseClasses} bg-green-50 text-green-800 border border-green-200`; + case 'error': + return `${baseClasses} bg-red-50 text-red-800 border border-red-200`; + case 'info': + return `${baseClasses} bg-blue-50 text-blue-800 border border-blue-200`; + default: + return baseClasses; + } + }; + + return ( + <div className={getContainerClasses()}> + {getIcon()} + <div className="flex-1">{message}</div> + <button + onClick={handleClose} + className="text-gray-500 hover:text-gray-700 focus:outline-none" + aria-label="Close notification" + > + <X className="h-4 w-4" /> + </button> + </div> + ); +}
\ No newline at end of file diff --git a/frontend/src/components/shared/NotificationContext.tsx b/frontend/src/components/shared/NotificationContext.tsx new file mode 100644 index 0000000..a8f6454 --- /dev/null +++ b/frontend/src/components/shared/NotificationContext.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { createContext, useContext, useState, ReactNode } from 'react'; +import { Notification, NotificationType } from './Notification'; + +type NotificationData = { + id: string; + type: NotificationType; + message: string; + duration?: number; +}; + +interface NotificationContextType { + showNotification: (type: NotificationType, message: string, duration?: number) => void; + hideNotification: (id: string) => void; +} + +const NotificationContext = createContext<NotificationContextType | undefined>(undefined); + +export function NotificationProvider({ children }: { children: ReactNode }) { + const [notifications, setNotifications] = useState<NotificationData[]>([]); + + const showNotification = (type: NotificationType, message: string, duration?: number) => { + const id = Math.random().toString(36).substring(2, 9); + setNotifications(prev => [...prev, { id, type, message, duration }]); + return id; + }; + + const hideNotification = (id: string) => { + setNotifications(prev => prev.filter(notification => notification.id !== id)); + }; + + return ( + <NotificationContext.Provider value={{ showNotification, hideNotification }}> + {children} + {notifications.map(notification => ( + <Notification + key={notification.id} + type={notification.type} + message={notification.message} + duration={notification.duration} + onClose={() => hideNotification(notification.id)} + /> + ))} + </NotificationContext.Provider> + ); +} + +export function useNotification() { + const context = useContext(NotificationContext); + if (context === undefined) { + throw new Error('useNotification must be used within a NotificationProvider'); + } + return context; +}
\ No newline at end of file diff --git a/frontend/src/components/shared/ThemeProvider.tsx b/frontend/src/components/shared/ThemeProvider.tsx new file mode 100644 index 0000000..2fc67d9 --- /dev/null +++ b/frontend/src/components/shared/ThemeProvider.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { createContext, useContext, useEffect, useState, ReactNode } from 'react'; + +type Theme = 'light' | 'dark' | 'system'; + +interface ThemeContextType { + theme: Theme; + setTheme: (theme: Theme) => void; +} + +const ThemeContext = createContext<ThemeContextType | undefined>(undefined); + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setTheme] = useState<Theme>('system'); + + useEffect(() => { + // Get the theme preference from localStorage if available + const storedTheme = localStorage.getItem('theme') as Theme | null; + if (storedTheme) { + setTheme(storedTheme); + } + }, []); + + useEffect(() => { + const root = window.document.documentElement; + + // Remove all theme classes + root.classList.remove('light', 'dark'); + + // Add the appropriate class based on theme + if (theme === 'system') { + const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + root.classList.add(systemTheme); + } else { + root.classList.add(theme); + } + + // Store the preference + localStorage.setItem('theme', theme); + }, [theme]); + + return ( + <ThemeContext.Provider value={{ theme, setTheme }}> + {children} + </ThemeContext.Provider> + ); +} + +export function useTheme() { + const context = useContext(ThemeContext); + if (context === undefined) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +}
\ No newline at end of file diff --git a/frontend/src/components/shared/ThemeToggle.tsx b/frontend/src/components/shared/ThemeToggle.tsx new file mode 100644 index 0000000..679bbc5 --- /dev/null +++ b/frontend/src/components/shared/ThemeToggle.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { Moon, Sun } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useTheme } from './ThemeProvider'; + +export function ThemeToggle() { + const { theme, setTheme } = useTheme(); + + const toggleTheme = () => { + if (theme === 'dark') { + setTheme('light'); + } else { + setTheme('dark'); + } + }; + + return ( + <Button + variant="ghost" + size="icon" + onClick={toggleTheme} + aria-label={ + theme === 'dark' + ? 'Switch to light theme' + : 'Switch to dark theme' + } + > + {theme === 'dark' ? ( + <Sun className="h-[1.2rem] w-[1.2rem]" /> + ) : ( + <Moon className="h-[1.2rem] w-[1.2rem]" /> + )} + </Button> + ); +}
\ No newline at end of file |