Add client-side calendar event notification system that evaluates JMAP CalendarEventAlert triggers and displays toast notifications when alert times are reached. Includes configurable notification sound, acknowledged alert persistence, and proactive event fetching for background alerts. Also mounts ToastContainer globally to fix silent toast failures.
92 lines
2.6 KiB
TypeScript
92 lines
2.6 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect } from "react";
|
|
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export type ToastType = "success" | "error" | "info" | "warning";
|
|
|
|
export interface Toast {
|
|
id: string;
|
|
type: ToastType;
|
|
title: string;
|
|
message?: string;
|
|
duration?: number;
|
|
onClick?: () => void;
|
|
icon?: React.ReactNode;
|
|
}
|
|
|
|
interface ToastProps {
|
|
toast: Toast;
|
|
onClose: (id: string) => void;
|
|
}
|
|
|
|
const icons = {
|
|
success: CheckCircle,
|
|
error: AlertCircle,
|
|
info: Info,
|
|
warning: AlertTriangle,
|
|
};
|
|
|
|
const styles = {
|
|
success: "bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-800 text-green-800 dark:text-green-200",
|
|
error: "bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-800 text-red-800 dark:text-red-200",
|
|
info: "bg-blue-50 dark:bg-blue-950/30 border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-200",
|
|
warning: "bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-200",
|
|
};
|
|
|
|
export function ToastItem({ toast, onClose }: ToastProps) {
|
|
const Icon = icons[toast.type];
|
|
|
|
useEffect(() => {
|
|
if (toast.duration && toast.duration > 0) {
|
|
const timer = setTimeout(() => {
|
|
onClose(toast.id);
|
|
}, toast.duration);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [toast, onClose]);
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"flex items-start gap-3 p-4 rounded-lg border shadow-lg bg-background animate-slide-in",
|
|
styles[toast.type],
|
|
toast.onClick && "cursor-pointer hover:opacity-90 transition-opacity"
|
|
)}
|
|
onClick={() => {
|
|
if (toast.onClick) {
|
|
toast.onClick();
|
|
onClose(toast.id);
|
|
}
|
|
}}
|
|
>
|
|
{toast.icon !== undefined ? toast.icon : <Icon className="w-5 h-5 flex-shrink-0 mt-0.5" />}
|
|
<div className="flex-1">
|
|
<h4 className="font-medium">{toast.title}</h4>
|
|
{toast.message && (
|
|
<p className="text-sm mt-1 opacity-90">{toast.message}</p>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onClose(toast.id);
|
|
}}
|
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose: (id: string) => void }) {
|
|
return (
|
|
<div className="fixed bottom-4 right-4 z-50 space-y-2 max-w-sm">
|
|
{toasts.map((toast) => (
|
|
<ToastItem key={toast.id} toast={toast} onClose={onClose} />
|
|
))}
|
|
</div>
|
|
);
|
|
} |