spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1"use client";23import { AlertTriangle, CheckCircle2, Info, X } from "lucide-react";4import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from "react";5import { cn } from "@/components/ui/cn";67export type ToastTone = "ok" | "warn" | "danger" | "info";8export interface ToastInput {9 title: string;10 description?: string;11 tone?: ToastTone;12 /** ms; default 4500, 0 = sticky */13 duration?: number;14}15interface ToastItem extends ToastInput {16 id: number;17}1819const Ctx = createContext<{ toast: (t: ToastInput) => void } | null>(null);2021export function useToast(): (t: ToastInput) => void {22 const ctx = useContext(Ctx);23 if (!ctx) throw new Error("useToast outside ToastProvider");24 return ctx.toast;25}2627const ICON: Record<ToastTone, typeof Info> = { ok: CheckCircle2, warn: AlertTriangle, danger: AlertTriangle, info: Info };28const TONE: Record<ToastTone, string> = { ok: "text-ok", warn: "text-warn", danger: "text-danger", info: "text-info" };2930export function ToastProvider({ children }: { children: ReactNode }) {31 const [items, setItems] = useState<ToastItem[]>([]);32 const seq = useRef(0);33 const dismiss = useCallback((id: number) => setItems((p) => p.filter((t) => t.id !== id)), []);34 const toast = useCallback(35 (t: ToastInput) => {36 const id = ++seq.current;37 setItems((p) => [...p.slice(-4), { ...t, id }]);38 const ms = t.duration ?? (t.tone === "danger" ? 7000 : 4500);39 if (ms > 0) setTimeout(() => dismiss(id), ms);40 },41 [dismiss],42 );43 const value = useMemo(() => ({ toast }), [toast]);44 return (45 <Ctx.Provider value={value}>46 {children}47 <div aria-live="polite" className="pointer-events-none fixed inset-x-3 bottom-3 z-[120] flex flex-col items-center gap-2 sm:inset-x-auto sm:right-4 sm:bottom-4 sm:items-end">48 {items.map((t) => {49 const Icon = ICON[t.tone ?? "info"];50 return (51 <div key={t.id} role="status" className="pointer-events-auto flex w-full max-w-[420px] items-start gap-2.5 rounded-sm border border-hair-2 bg-surface px-3 py-2.5 text-[13px] text-ink animate-fade-in">52 <Icon className={cn("mt-0.5 size-4 shrink-0", TONE[t.tone ?? "info"])} strokeWidth={1.75} />53 <div className="min-w-0 flex-1">54 <div className="font-medium leading-snug">{t.title}</div>55 {t.description && <div className="mt-0.5 break-words text-[12px] leading-snug text-ink-3">{t.description}</div>}56 </div>57 <button type="button" onClick={() => dismiss(t.id)} aria-label="Dismiss" className="-mr-1 inline-flex size-7 shrink-0 items-center justify-center rounded-xs text-ink-3 hover:bg-surface-2 hover:text-ink">58 <X className="size-3.5" />59 </button>60 </div>61 );62 })}63 </div>64 </Ctx.Provider>65 );66}67