because I got bored of customising my CV for every job
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(toast): add configurable toast notification system

- Add Toast component with CVA styling variants
- Add ToastContainer for managing multiple toasts
- Add ToastContext with configurable timing
- Add ToastIcon and CloseIcon components
- Support success/warning/info/error levels
- Configurable duration, animation, and fade-in timing
- Add ToastProvider to app root

+349 -2
+1 -1
apps/client/package.json
··· 16 16 "@cv/utils": "^0.0.0", 17 17 "@tanstack/react-query": "^5.59.0", 18 18 "@types/react-router-dom": "^5.3.3", 19 - "class-variance-authority": "^0.7.0", 19 + "class-variance-authority": "^0.7.1", 20 20 "graphql": "^16.8.1", 21 21 "react": "^18.3.1", 22 22 "react-dom": "^18.3.1",
+130
apps/client/src/components/Toast.tsx
··· 1 + import { cva } from "class-variance-authority"; 2 + import { useCallback, useEffect, useState } from "react"; 3 + import { CloseIcon, ToastIcon } from "./icons"; 4 + 5 + export type ToastLevel = "success" | "warning" | "info" | "error"; 6 + 7 + export interface Toast { 8 + id: string; 9 + title: string; 10 + message?: string; 11 + level: ToastLevel; 12 + duration?: number; 13 + } 14 + 15 + export interface ToastConfig { 16 + defaultDuration: number; 17 + animationDuration: number; 18 + fadeInDelay: number; 19 + } 20 + 21 + interface ToastProps { 22 + toast: Toast; 23 + onRemove: (id: string) => void; 24 + config: ToastConfig; 25 + } 26 + 27 + // CVA variants for toast styling 28 + const toastVariants = cva( 29 + "flex items-start gap-3 p-4 rounded-lg shadow-lg border transition-all duration-300 transform", 30 + { 31 + variants: { 32 + level: { 33 + success: "bg-green-50 border-green-200 text-green-800", 34 + warning: "bg-yellow-50 border-yellow-200 text-yellow-800", 35 + info: "bg-blue-50 border-blue-200 text-blue-800", 36 + error: "bg-red-50 border-red-200 text-red-800", 37 + }, 38 + visibility: { 39 + visible: "translate-x-0 opacity-100", 40 + hidden: "translate-x-full opacity-0", 41 + }, 42 + }, 43 + defaultVariants: { 44 + level: "info", 45 + visibility: "hidden", 46 + }, 47 + }, 48 + ); 49 + 50 + const closeButtonVariants = cva( 51 + "flex-shrink-0 ml-2 text-gray-400 hover:text-gray-600 transition-colors", 52 + { 53 + variants: { 54 + level: { 55 + success: "hover:text-green-600", 56 + warning: "hover:text-yellow-600", 57 + info: "hover:text-blue-600", 58 + error: "hover:text-red-600", 59 + }, 60 + }, 61 + defaultVariants: { 62 + level: "info", 63 + }, 64 + }, 65 + ); 66 + 67 + /** 68 + * Individual toast notification component 69 + */ 70 + export default function ToastComponent({ 71 + toast, 72 + onRemove, 73 + config, 74 + }: ToastProps) { 75 + const [isVisible, setIsVisible] = useState(false); 76 + const [isLeaving, setIsLeaving] = useState(false); 77 + 78 + const handleRemove = useCallback(() => { 79 + setIsLeaving(true); 80 + setTimeout(() => { 81 + onRemove(toast.id); 82 + }, config.animationDuration); 83 + }, [onRemove, toast.id, config.animationDuration]); 84 + 85 + useEffect(() => { 86 + // Animate in 87 + const timer = setTimeout(() => setIsVisible(true), config.fadeInDelay); 88 + return () => clearTimeout(timer); 89 + }, [config.fadeInDelay]); 90 + 91 + useEffect(() => { 92 + const duration = toast.duration ?? config.defaultDuration; 93 + if (duration > 0) { 94 + const timer = setTimeout(() => { 95 + handleRemove(); 96 + }, duration); 97 + return () => clearTimeout(timer); 98 + } 99 + return undefined; 100 + }, [toast.duration, config.defaultDuration, handleRemove]); 101 + 102 + return ( 103 + <div 104 + className={toastVariants({ 105 + level: toast.level, 106 + visibility: isVisible && !isLeaving ? "visible" : "hidden", 107 + })} 108 + > 109 + <div className="flex-shrink-0 mt-0.5"> 110 + <ToastIcon level={toast.level} /> 111 + </div> 112 + 113 + <div className="flex-1 min-w-0"> 114 + <h4 className="text-sm font-semibold">{toast.title}</h4> 115 + {toast.message && ( 116 + <p className="mt-1 text-sm opacity-90">{toast.message}</p> 117 + )} 118 + </div> 119 + 120 + <button 121 + type="button" 122 + onClick={handleRemove} 123 + className={closeButtonVariants({ level: toast.level })} 124 + title="Close notification" 125 + > 126 + <CloseIcon /> 127 + </button> 128 + </div> 129 + ); 130 + }
+32
apps/client/src/components/ToastContainer.tsx
··· 1 + import type { Toast, ToastConfig } from "./Toast"; 2 + import ToastComponent from "./Toast"; 3 + 4 + interface ToastContainerProps { 5 + toasts: Toast[]; 6 + onRemove: (id: string) => void; 7 + config: ToastConfig; 8 + } 9 + 10 + /** 11 + * Container component for managing multiple toast notifications 12 + */ 13 + export default function ToastContainer({ 14 + toasts, 15 + onRemove, 16 + config, 17 + }: ToastContainerProps) { 18 + if (toasts.length === 0) return null; 19 + 20 + return ( 21 + <div className="fixed top-4 right-4 z-50 space-y-2 max-w-sm w-full"> 22 + {toasts.map((toast) => ( 23 + <ToastComponent 24 + key={toast.id} 25 + toast={toast} 26 + onRemove={onRemove} 27 + config={config} 28 + /> 29 + ))} 30 + </div> 31 + ); 32 + }
+25
apps/client/src/components/icons/CloseIcon.tsx
··· 1 + interface CloseIconProps { 2 + className?: string; 3 + } 4 + 5 + /** 6 + * Close icon component 7 + */ 8 + export default function CloseIcon({ className = "w-4 h-4" }: CloseIconProps) { 9 + return ( 10 + <svg 11 + className={className} 12 + fill="none" 13 + stroke="currentColor" 14 + viewBox="0 0 24 24" 15 + > 16 + <title>Close icon</title> 17 + <path 18 + strokeLinecap="round" 19 + strokeLinejoin="round" 20 + strokeWidth={2} 21 + d="M6 18L18 6M6 6l12 12" 22 + /> 23 + </svg> 24 + ); 25 + }
+58
apps/client/src/components/icons/ToastIcon.tsx
··· 1 + import { cva, type VariantProps } from "class-variance-authority"; 2 + 3 + const iconVariants = cva("w-5 h-5", { 4 + variants: { 5 + level: { 6 + success: "text-green-600", 7 + warning: "text-yellow-600", 8 + info: "text-blue-600", 9 + error: "text-red-600", 10 + }, 11 + }, 12 + defaultVariants: { 13 + level: "info", 14 + }, 15 + }); 16 + 17 + interface ToastIconProps extends VariantProps<typeof iconVariants> { 18 + level: "success" | "warning" | "info" | "error"; 19 + } 20 + 21 + /** 22 + * Toast icon component with CVA styling 23 + */ 24 + export default function ToastIcon({ level }: ToastIconProps) { 25 + const iconProps = { 26 + className: iconVariants({ level }), 27 + fill: "none", 28 + stroke: "currentColor", 29 + viewBox: "0 0 24 24", 30 + }; 31 + 32 + return ( 33 + <svg {...iconProps}> 34 + <title>{level.charAt(0).toUpperCase() + level.slice(1)} icon</title> 35 + <path 36 + strokeLinecap="round" 37 + strokeLinejoin="round" 38 + strokeWidth={2} 39 + d={getIconPath(level)} 40 + /> 41 + </svg> 42 + ); 43 + } 44 + 45 + /** 46 + * Get the SVG path for the given toast level 47 + */ 48 + function getIconPath(level: "success" | "warning" | "info" | "error"): string { 49 + const iconPaths = { 50 + success: "M5 13l4 4L19 7", 51 + warning: 52 + "M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z", 53 + info: "M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z", 54 + error: "M6 18L18 6M6 6l12 12", 55 + }; 56 + 57 + return iconPaths[level]; 58 + }
+2
apps/client/src/components/icons/index.ts
··· 1 + export { default as CloseIcon } from "./CloseIcon"; 2 + export { default as ToastIcon } from "./ToastIcon";
+97
apps/client/src/contexts/ToastContext.tsx
··· 1 + import type { ReactNode } from "react"; 2 + import { createContext, useContext, useState } from "react"; 3 + import type { Toast, ToastConfig } from "@/components/Toast"; 4 + import ToastContainer from "@/components/ToastContainer"; 5 + 6 + interface ToastContextType { 7 + showToast: (toast: Omit<Toast, "id">) => void; 8 + showSuccess: (title: string, message?: string) => void; 9 + showWarning: (title: string, message?: string) => void; 10 + showInfo: (title: string, message?: string) => void; 11 + showError: (title: string, message?: string) => void; 12 + removeToast: (id: string) => void; 13 + } 14 + 15 + const ToastContext = createContext<ToastContextType | undefined>(undefined); 16 + 17 + interface ToastProviderProps { 18 + children: ReactNode; 19 + config?: Partial<ToastConfig>; 20 + } 21 + 22 + /** 23 + * Toast provider component for managing global toast notifications 24 + */ 25 + export function ToastProvider({ 26 + children, 27 + config: userConfig, 28 + }: ToastProviderProps) { 29 + const [toasts, setToasts] = useState<Toast[]>([]); 30 + 31 + // Default configuration 32 + const defaultConfig: ToastConfig = { 33 + defaultDuration: 5000, 34 + animationDuration: 300, 35 + fadeInDelay: 10, 36 + }; 37 + 38 + const config = { ...defaultConfig, ...userConfig }; 39 + 40 + const showToast = (toast: Omit<Toast, "id">) => { 41 + const id = Math.random().toString(36).substr(2, 9); 42 + const newToast: Toast = { 43 + ...toast, 44 + id, 45 + duration: toast.duration ?? config.defaultDuration, 46 + }; 47 + 48 + setToasts((prev) => [...prev, newToast]); 49 + }; 50 + 51 + const showSuccess = (title: string, message?: string) => { 52 + showToast({ title, message, level: "success" }); 53 + }; 54 + 55 + const showWarning = (title: string, message?: string) => { 56 + showToast({ title, message, level: "warning" }); 57 + }; 58 + 59 + const showInfo = (title: string, message?: string) => { 60 + showToast({ title, message, level: "info" }); 61 + }; 62 + 63 + const showError = (title: string, message?: string) => { 64 + showToast({ title, message, level: "error" }); 65 + }; 66 + 67 + const removeToast = (id: string) => { 68 + setToasts((prev) => prev.filter((toast) => toast.id !== id)); 69 + }; 70 + 71 + const value: ToastContextType = { 72 + showToast, 73 + showSuccess, 74 + showWarning, 75 + showInfo, 76 + showError, 77 + removeToast, 78 + }; 79 + 80 + return ( 81 + <ToastContext.Provider value={value}> 82 + {children} 83 + <ToastContainer toasts={toasts} onRemove={removeToast} config={config} /> 84 + </ToastContext.Provider> 85 + ); 86 + } 87 + 88 + /** 89 + * Hook to use toast context 90 + */ 91 + export function useToast() { 92 + const context = useContext(ToastContext); 93 + if (context === undefined) { 94 + throw new Error("useToast must be used within a ToastProvider"); 95 + } 96 + return context; 97 + }
+4 -1
apps/client/src/main.tsx
··· 2 2 import React from "react"; 3 3 import { createRoot } from "react-dom/client"; 4 4 import App from "./App"; 5 + import { ToastProvider } from "./contexts/ToastContext"; 5 6 import { apolloClient } from "./lib/apollo-client"; 6 7 import { TokenProvider } from "./providers/TokenProvider"; 7 8 import "./index.css"; ··· 14 15 <React.StrictMode> 15 16 <ApolloProvider client={apolloClient}> 16 17 <TokenProvider> 17 - <App /> 18 + <ToastProvider> 19 + <App /> 20 + </ToastProvider> 18 21 </TokenProvider> 19 22 </ApolloProvider> 20 23 </React.StrictMode>,