flowstate/app/components/ui/ErrorToast.tsx
Chneemann ca01a3b44e
All checks were successful
Deploy Flowstate to VPS / deploy (push) Successful in 48s
refactor(ui): reorganize component directory structure
2026-08-18 10:18:06 +02:00

138 lines
4.7 KiB
TypeScript

/**
* @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>
);
}