diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index c1dc0ae..41284ce 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -1,6 +1,6 @@ /** * @file layout.tsx - * @description Server component layout protecting authenticated routes, verifying user session status, and rendering the responsive application shell structure. + * @description Server component layout protecting authenticated routes, verifying user session status, and rendering the responsive application shell structure with global toast notifications. */ import { auth } from "@/auth"; @@ -8,6 +8,7 @@ import { redirect } from "next/navigation"; import Sidebar from "@/app/components/layout/Sidebar"; import Header from "@/app/components/layout/Header"; import Navbar from "@/app/components/layout/Navbar"; +import ErrorToast from "../components/ErrorToast"; /** * Renders the protected application layout wrapper. @@ -31,6 +32,7 @@ export default async function AppLayout({ return ( <> +
diff --git a/app/(app)/tasks/page.tsx b/app/(app)/tasks/page.tsx index 1c70b38..9a22ff5 100644 --- a/app/(app)/tasks/page.tsx +++ b/app/(app)/tasks/page.tsx @@ -43,11 +43,12 @@ export default async function TaskPage({ searchParams }: TaskPageProps) { let initialData = undefined; if (params.task === "edit" && params.id) { - if (!UUID_REGEX.test(params.id)) redirect("/dashboard"); + if (!UUID_REGEX.test(params.id)) + redirect("/dashboard?error=task_not_found"); initialData = await TaskService.getEditableTask(params.id, session.user.id); - if (!initialData) redirect("/dashboard"); + if (!initialData) redirect("/dashboard?error=unauthorized_edit"); } const users = await db diff --git a/app/components/ErrorToast.tsx b/app/components/ErrorToast.tsx new file mode 100644 index 0000000..270e114 --- /dev/null +++ b/app/components/ErrorToast.tsx @@ -0,0 +1,138 @@ +/** + * @file components/ErrorToast.tsx + * @description Client component displaying a modern floating glassmorphism error notification with a pauseable countdown progress bar driven by requestAnimationFrame. + */ + +"use client"; + +import { useSearchParams, useRouter } from "next/navigation"; +import { useEffect, useState, useRef, useCallback } from "react"; +import { AlertCircle, X } from "lucide-react"; + +const AUTO_DISMISS_TIME = 5000; + +/** + * Renders an optimized, pauseable error toast notification using requestAnimationFrame for smooth progress animations, + * automatically dismissing itself or handling user-triggered closures. + * + * @returns {JSX.Element | null} The rendered error toast component or null if not visible. + */ +export default function ErrorToast() { + const searchParams = useSearchParams(); + const router = useRouter(); + const error = searchParams.get("error"); + + const [isVisible, setIsVisible] = useState(false); + const [progress, setProgress] = useState(100); + const [isPaused, setIsPaused] = useState(false); + + const timeLeftRef = useRef(AUTO_DISMISS_TIME); + const prevTimeRef = useRef(null); + const animFrameRef = useRef(null); + + /** + * Dismisses the toast notification and cleans up URL parameters. + */ + const dismissToast = useCallback(() => { + setIsVisible(false); + router.replace("/dashboard", { scroll: false }); + }, [router]); + + // Show a toast message when an error occurs and reset the timer + useEffect(() => { + if (error) { + setIsVisible(true); + setProgress(100); + timeLeftRef.current = AUTO_DISMISS_TIME; + prevTimeRef.current = null; + } else { + setIsVisible(false); + } + }, [error]); + + // RequestAnimationFrame loop for smooth, pausable progress + useEffect(() => { + if (!isVisible || isPaused) { + prevTimeRef.current = null; + if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current); + return; + } + + /** + * Updates the animation frame progress based on elapsed time. + * + * @param {number} now - Current timestamp provided by requestAnimationFrame. + */ + const updateAnimation = (now: number) => { + if (prevTimeRef.current === null) { + prevTimeRef.current = now; + } + + const elapsed = now - prevTimeRef.current; + prevTimeRef.current = now; + + timeLeftRef.current = Math.max(0, timeLeftRef.current - elapsed); + setProgress((timeLeftRef.current / AUTO_DISMISS_TIME) * 100); + + if (timeLeftRef.current <= 0) { + dismissToast(); + } else { + animFrameRef.current = requestAnimationFrame(updateAnimation); + } + }; + + animFrameRef.current = requestAnimationFrame(updateAnimation); + + return () => { + if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current); + }; + }, [isVisible, isPaused, dismissToast]); + + if (!isVisible || !error) return null; + + const message = + error === "unauthorized_edit" + ? "You cannot edit tasks that belong to others or are in the trash." + : error === "task_not_found" + ? "The requested task does not exist or has been deleted." + : "An unexpected error occurred."; + + return ( +
setIsPaused(true)} + onMouseLeave={() => setIsPaused(false)} + className="fixed top-6 right-6 z-99 max-w-sm w-full overflow-hidden flex flex-col bg-background/80 dark:bg-zinc-950/80 backdrop-blur-xl border border-destructive/30 text-foreground rounded-2xl shadow-[0_12px_40px_-10px_hsl(0_84%_60%/0.15)] animate-in slide-in-from-top-4 fade-in duration-300 ease-out cursor-default select-none" + > +
+
+ +
+ +
+
+

+ Access Denied +

+ +
+

+ {message} +

+
+
+ +
+
+
+
+ ); +}