feat(ui): add ErrorToast with pauseable timer and task error handling
This commit is contained in:
parent
12e06468cd
commit
dd2a8a5637
3 changed files with 144 additions and 3 deletions
|
|
@ -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 (
|
||||
<>
|
||||
<ErrorToast />
|
||||
<div className="hidden md:flex">
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
138
app/components/ErrorToast.tsx
Normal file
138
app/components/ErrorToast.tsx
Normal file
|
|
@ -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<number | null>(null);
|
||||
const animFrameRef = useRef<number | null>(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 (
|
||||
<div
|
||||
onMouseEnter={() => 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"
|
||||
>
|
||||
<div className="flex items-start gap-3.5 p-4 pb-3">
|
||||
<div className="bg-destructive/10 p-2 rounded-xl text-destructive shrink-0 animate-pulse">
|
||||
<AlertCircle size={20} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-semibold tracking-wider uppercase text-destructive text-sm">
|
||||
Access Denied
|
||||
</h4>
|
||||
<button
|
||||
onClick={dismissToast}
|
||||
className="text-foreground/40 hover:text-foreground transition-colors p-1 -mr-1 rounded-lg hover:bg-foreground/5 cursor-pointer"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-foreground/70 leading-relaxed pr-2 mt-0.5">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-destructive/10 h-1">
|
||||
<div
|
||||
className="bg-destructive h-full transition-all duration-75 ease-linear"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue