mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 08:15:55 +00:00
Uploads in Files had their own card with a thick progress bar, larger corners and a heavier shadow, so they looked unlike the toasts around them. Toasts can now show work in progress: the thin line at the bottom fills with progress instead of counting down, and the toast stays until it is updated or removed. Uploads use it, with the file and percentage below the title and Cancel as the toast action.
245 lines
7.6 KiB
TypeScript
245 lines
7.6 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useCallback, useRef } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { X, CheckCircle, AlertCircle, Info, AlertTriangle, Loader2 } from "@/components/icons";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export type ToastType = "success" | "error" | "info" | "warning";
|
|
|
|
export interface ToastAction {
|
|
label: string;
|
|
onClick: () => void;
|
|
}
|
|
|
|
export interface Toast {
|
|
id: string;
|
|
type: ToastType;
|
|
title: string;
|
|
message?: string;
|
|
duration?: number;
|
|
onClick?: () => void;
|
|
icon?: React.ReactNode;
|
|
action?: ToastAction;
|
|
secondaryAction?: ToastAction;
|
|
/**
|
|
* A third action. Three buttons do not fit beside the text, so a toast
|
|
* that has one lays its actions out on a row of their own.
|
|
*/
|
|
tertiaryAction?: ToastAction;
|
|
/**
|
|
* Work in progress, e.g. an upload: 0..1, or null while the amount is
|
|
* unknown. The bottom line then shows progress instead of a countdown,
|
|
* and the toast stays until it is updated or removed.
|
|
*/
|
|
progress?: number | null;
|
|
}
|
|
|
|
interface ToastProps {
|
|
toast: Toast;
|
|
onClose: (id: string) => void;
|
|
}
|
|
|
|
const icons = {
|
|
success: CheckCircle,
|
|
error: AlertCircle,
|
|
info: Info,
|
|
warning: AlertTriangle,
|
|
};
|
|
|
|
// Only errors and warnings carry colour; success and info stay neutral
|
|
// (repos/branding/APP.md).
|
|
const iconStyles = {
|
|
success: "text-muted-foreground",
|
|
error: "text-red-600 dark:text-red-400",
|
|
info: "text-muted-foreground",
|
|
warning: "text-amber-600 dark:text-amber-400",
|
|
};
|
|
|
|
const progressBarStyles = {
|
|
success: "bg-muted-foreground",
|
|
error: "bg-destructive",
|
|
info: "bg-muted-foreground",
|
|
warning: "bg-warning",
|
|
};
|
|
|
|
export function ToastItem({ toast, onClose }: ToastProps) {
|
|
const Icon = icons[toast.type];
|
|
const [exiting, setExiting] = useState(false);
|
|
const [paused, setPaused] = useState(false);
|
|
const remainingRef = useRef(toast.duration ?? 5000);
|
|
const startRef = useRef(Date.now());
|
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const isProgress = toast.progress !== undefined;
|
|
|
|
// A toast updated in place (a finished upload) starts a fresh countdown.
|
|
const lastDurationRef = useRef(toast.duration);
|
|
if (lastDurationRef.current !== toast.duration) {
|
|
lastDurationRef.current = toast.duration;
|
|
remainingRef.current = toast.duration ?? 5000;
|
|
}
|
|
|
|
const dismiss = useCallback(() => {
|
|
setExiting(true);
|
|
setTimeout(() => onClose(toast.id), 280);
|
|
}, [onClose, toast.id]);
|
|
|
|
useEffect(() => {
|
|
if (!toast.duration || toast.duration <= 0 || isProgress) return;
|
|
|
|
if (paused) {
|
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
remainingRef.current = remainingRef.current - (Date.now() - startRef.current);
|
|
return;
|
|
}
|
|
|
|
startRef.current = Date.now();
|
|
timerRef.current = setTimeout(dismiss, remainingRef.current);
|
|
return () => {
|
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
};
|
|
}, [toast.duration, paused, dismiss, isProgress]);
|
|
|
|
const actionButton = (action: ToastAction) => (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
try {
|
|
action.onClick();
|
|
dismiss();
|
|
} catch {
|
|
// Don't close toast on error so user can retry
|
|
}
|
|
}}
|
|
className={cn(
|
|
"text-[13px] font-semibold text-foreground px-1.5 py-0.5 rounded-md transition-colors",
|
|
"underline underline-offset-[3px] hover:bg-foreground/5"
|
|
)}
|
|
>
|
|
{action.label}
|
|
</button>
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"toast-item group relative flex items-start gap-2.5 w-[360px] max-w-[calc(100vw-2.5rem)] rounded-md",
|
|
"bg-popover text-popover-foreground border border-border",
|
|
"shadow-[0_6px_16px_rgb(0,0,0,0.1)] dark:shadow-[0_6px_16px_rgb(0,0,0,0.4)]",
|
|
"overflow-hidden pt-3 pe-2.5 pb-3.5 ps-3.5",
|
|
exiting ? "toast-exit" : "toast-enter",
|
|
toast.onClick && !toast.action && "cursor-pointer"
|
|
)}
|
|
onMouseEnter={() => setPaused(true)}
|
|
onMouseLeave={() => setPaused(false)}
|
|
onClick={() => {
|
|
if (toast.onClick && !toast.action) {
|
|
toast.onClick();
|
|
dismiss();
|
|
}
|
|
}}
|
|
>
|
|
{/* Icon */}
|
|
{toast.icon !== undefined ? (
|
|
toast.icon
|
|
) : isProgress ? (
|
|
<Loader2 className="w-[18px] h-[18px] flex-shrink-0 mt-px text-muted-foreground animate-spin" />
|
|
) : (
|
|
<Icon className={cn("w-[18px] h-[18px] flex-shrink-0 mt-px", iconStyles[toast.type])} />
|
|
)}
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 min-w-0 pt-px">
|
|
<p className="text-[13.5px] font-medium text-foreground leading-snug">{toast.title}</p>
|
|
{toast.message && (
|
|
<p className="text-[12.5px] mt-0.5 text-muted-foreground leading-snug">{toast.message}</p>
|
|
)}
|
|
{toast.tertiaryAction && (
|
|
<div className="flex flex-wrap items-center gap-1 mt-1.5 -ms-1.5">
|
|
{actionButton(toast.tertiaryAction)}
|
|
{toast.secondaryAction && actionButton(toast.secondaryAction)}
|
|
{toast.action && actionButton(toast.action)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{!toast.tertiaryAction && (toast.action || toast.secondaryAction) && (
|
|
<div className="flex items-center gap-1 flex-shrink-0">
|
|
{toast.secondaryAction && actionButton(toast.secondaryAction)}
|
|
{toast.action && actionButton(toast.action)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Close button */}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
dismiss();
|
|
}}
|
|
className={cn(
|
|
"flex-shrink-0 p-1 rounded-md transition-all",
|
|
"text-muted-foreground hover:text-foreground hover:bg-foreground/5",
|
|
"opacity-0 group-hover:opacity-100 group-focus-within:opacity-100"
|
|
)}
|
|
>
|
|
<X className="w-3.5 h-3.5" />
|
|
</button>
|
|
|
|
{/* Progress line: the same line as the countdown, filled by the work done */}
|
|
{isProgress && (
|
|
<div
|
|
className="absolute bottom-0 left-0 right-0 h-[2px] bg-foreground/[0.06]"
|
|
role="progressbar"
|
|
aria-valuemin={0}
|
|
aria-valuemax={100}
|
|
aria-valuenow={toast.progress == null ? undefined : Math.round(toast.progress * 100)}
|
|
>
|
|
<div
|
|
className={cn("h-full opacity-60 transition-[width] duration-300", progressBarStyles[toast.type], toast.progress == null && "w-1/3 animate-pulse")}
|
|
style={toast.progress == null ? undefined : { width: `${Math.max(0, Math.min(1, toast.progress)) * 100}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Countdown line */}
|
|
{!isProgress && !!toast.duration && toast.duration > 0 && (
|
|
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-foreground/[0.06]">
|
|
<div
|
|
className={cn("h-full opacity-60", progressBarStyles[toast.type])}
|
|
style={{
|
|
animation: `toast-progress ${toast.duration}ms linear forwards`,
|
|
animationPlayState: paused ? "paused" : "running",
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose: (id: string) => void }) {
|
|
const [mounted, setMounted] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
if (!mounted) return null;
|
|
|
|
return createPortal(
|
|
<div
|
|
className="fixed bottom-5 right-5 z-[99999] flex flex-col-reverse gap-2.5"
|
|
role="status"
|
|
aria-live="polite"
|
|
style={{ pointerEvents: "none" }}
|
|
>
|
|
{toasts.map((toast) => (
|
|
<div key={toast.id} style={{ pointerEvents: "auto" }}>
|
|
<ToastItem toast={toast} onClose={onClose} />
|
|
</div>
|
|
))}
|
|
</div>,
|
|
document.body
|
|
);
|
|
}
|