174 lines
5.2 KiB
TypeScript
174 lines
5.2 KiB
TypeScript
|
|
// components/Toast.tsx
|
|||
|
|
// Tiny in-app toast system. Each toast is a row in a vertical stack
|
|||
|
|
// anchored to the bottom-right. Auto-dismiss after 5s (configurable).
|
|||
|
|
// Includes a 3-second coalesce window — bursty events (e.g. 20 tasks
|
|||
|
|
// flipping to done at once) only show one toast.
|
|||
|
|
|
|||
|
|
"use client";
|
|||
|
|
|
|||
|
|
import {
|
|||
|
|
createContext,
|
|||
|
|
useCallback,
|
|||
|
|
useContext,
|
|||
|
|
useEffect,
|
|||
|
|
useRef,
|
|||
|
|
useState,
|
|||
|
|
type ReactNode,
|
|||
|
|
} from "react";
|
|||
|
|
|
|||
|
|
export type ToastKind = "info" | "success" | "error" | "warn";
|
|||
|
|
|
|||
|
|
export interface Toast {
|
|||
|
|
id: string;
|
|||
|
|
kind: ToastKind;
|
|||
|
|
title: string;
|
|||
|
|
body?: string;
|
|||
|
|
/** Optional click target — e.g. open a task detail panel. */
|
|||
|
|
onClick?: () => void;
|
|||
|
|
/** Auto-dismiss in ms. 0 = sticky. Default 5000. */
|
|||
|
|
duration?: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface ToastContextValue {
|
|||
|
|
toast: (t: Omit<Toast, "id">) => void;
|
|||
|
|
dismiss: (id: string) => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const ToastContext = createContext<ToastContextValue | null>(null);
|
|||
|
|
|
|||
|
|
const COALESCE_MS = 3000;
|
|||
|
|
const MAX_VISIBLE = 4;
|
|||
|
|
|
|||
|
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
|||
|
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
|||
|
|
const lastFiredRef = useRef<{ key: string; at: number } | null>(null);
|
|||
|
|
|
|||
|
|
const dismiss = useCallback((id: string) => {
|
|||
|
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
const toast = useCallback((t: Omit<Toast, "id">) => {
|
|||
|
|
const key = `${t.kind}|${t.title}|${t.body ?? ""}`;
|
|||
|
|
const now = Date.now();
|
|||
|
|
if (lastFiredRef.current && lastFiredRef.current.key === key && now - lastFiredRef.current.at < COALESCE_MS) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
lastFiredRef.current = { key, at: now };
|
|||
|
|
|
|||
|
|
const id = `${now}-${Math.random().toString(36).slice(2, 8)}`;
|
|||
|
|
setToasts((prev) => {
|
|||
|
|
const next = [...prev, { ...t, id }];
|
|||
|
|
return next.slice(-MAX_VISIBLE);
|
|||
|
|
});
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<ToastContext.Provider value={{ toast, dismiss }}>
|
|||
|
|
{children}
|
|||
|
|
<ToastViewport toasts={toasts} onDismiss={dismiss} />
|
|||
|
|
</ToastContext.Provider>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function useToast(): ToastContextValue {
|
|||
|
|
const ctx = useContext(ToastContext);
|
|||
|
|
if (!ctx) {
|
|||
|
|
// Safe no-op so non-provider contexts (e.g. tests) don't crash.
|
|||
|
|
return { toast: () => {}, dismiss: () => {} };
|
|||
|
|
}
|
|||
|
|
return ctx;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function ToastViewport({
|
|||
|
|
toasts,
|
|||
|
|
onDismiss,
|
|||
|
|
}: {
|
|||
|
|
toasts: Toast[];
|
|||
|
|
onDismiss: (id: string) => void;
|
|||
|
|
}) {
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
aria-live="polite"
|
|||
|
|
aria-atomic="true"
|
|||
|
|
className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 w-[min(360px,calc(100vw-2rem))]"
|
|||
|
|
>
|
|||
|
|
{toasts.map((t) => (
|
|||
|
|
<ToastItem key={t.id} toast={t} onDismiss={() => onDismiss(t.id)} />
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) {
|
|||
|
|
const duration = toast.duration ?? 5000;
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (duration <= 0) return;
|
|||
|
|
const t = setTimeout(onDismiss, duration);
|
|||
|
|
return () => clearTimeout(t);
|
|||
|
|
}, [duration, onDismiss]);
|
|||
|
|
|
|||
|
|
const palette = {
|
|||
|
|
info: "border-blue-300 bg-blue-50 text-blue-900",
|
|||
|
|
success: "border-green-300 bg-green-50 text-green-900",
|
|||
|
|
error: "border-red-300 bg-red-50 text-red-900",
|
|||
|
|
warn: "border-amber-300 bg-amber-50 text-amber-900",
|
|||
|
|
}[toast.kind];
|
|||
|
|
|
|||
|
|
const icon = {
|
|||
|
|
info: "ℹ",
|
|||
|
|
success: "✅",
|
|||
|
|
error: "❌",
|
|||
|
|
warn: "⚠",
|
|||
|
|
}[toast.kind];
|
|||
|
|
|
|||
|
|
const inner = (
|
|||
|
|
<div
|
|||
|
|
role={toast.onClick ? "button" : undefined}
|
|||
|
|
onClick={toast.onClick}
|
|||
|
|
className={`rounded-md border ${palette} px-4 py-3 shadow-sm text-sm flex gap-3 items-start ${toast.onClick ? "cursor-pointer hover:shadow-md transition-shadow" : ""}`}
|
|||
|
|
>
|
|||
|
|
<span className="text-base leading-tight">{icon}</span>
|
|||
|
|
<div className="flex-1 min-w-0">
|
|||
|
|
<p className="font-semibold">{toast.title}</p>
|
|||
|
|
{toast.body ? <p className="text-xs mt-0.5 opacity-80">{toast.body}</p> : null}
|
|||
|
|
</div>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={(e) => {
|
|||
|
|
e.stopPropagation();
|
|||
|
|
onDismiss();
|
|||
|
|
}}
|
|||
|
|
aria-label="Dismiss"
|
|||
|
|
className="text-xs opacity-60 hover:opacity-100"
|
|||
|
|
>
|
|||
|
|
✕
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
return inner;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─────────────────────────────────────────────────────────────────
|
|||
|
|
// Browser notifications — request permission on first prompt.
|
|||
|
|
// ─────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
export async function maybeRequestNotificationPermission(): Promise<boolean> {
|
|||
|
|
if (typeof window === "undefined") return false;
|
|||
|
|
if (!("Notification" in window)) return false;
|
|||
|
|
if (Notification.permission === "granted") return true;
|
|||
|
|
if (Notification.permission === "denied") return false;
|
|||
|
|
const result = await Notification.requestPermission();
|
|||
|
|
return result === "granted";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function fireBrowserNotification(title: string, options: NotificationOptions = {}) {
|
|||
|
|
if (typeof window === "undefined") return;
|
|||
|
|
if (!("Notification" in window)) return;
|
|||
|
|
if (Notification.permission !== "granted") return;
|
|||
|
|
try {
|
|||
|
|
new Notification(title, options);
|
|||
|
|
} catch {
|
|||
|
|
// Some browsers throw on focus-tab notifications — ignore.
|
|||
|
|
}
|
|||
|
|
}
|