hasari-api / apps /desktop /src /components /Toaster.tsx
erdoganpeker's picture
v0.3.0 — multimodal vehicle damage MVP
e327f0d
/**
* Toaster — lightweight bottom-right notification stack.
*
* Subscribes to two app-level CustomEvents:
* - `hasarui:toast` { kind: 'info'|'success'|'error', message }
* - `hasarui:file-size-rejected` { rejected: {name,size}[], maxMb }
*
* Pages can fire `window.dispatchEvent(new CustomEvent('hasarui:toast', { detail: ... }))`
* without importing this module — keeps the toast surface decoupled.
*/
import { useEffect, useState, useCallback } from 'react';
import { AlertTriangle, CheckCircle2, Info, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
type ToastKind = 'info' | 'success' | 'error';
interface Toast {
id: number;
kind: ToastKind;
message: string;
}
let nextId = 1;
export default function Toaster() {
const { t } = useTranslation();
const [toasts, setToasts] = useState<Toast[]>([]);
const push = useCallback((kind: ToastKind, message: string) => {
const id = nextId++;
setToasts((cur) => [...cur, { id, kind, message }]);
window.setTimeout(() => {
setToasts((cur) => cur.filter((x) => x.id !== id));
}, 5000);
}, []);
useEffect(() => {
function onToast(e: Event) {
const ev = e as CustomEvent<{ kind?: ToastKind; message: string }>;
if (!ev.detail?.message) return;
push(ev.detail.kind ?? 'info', ev.detail.message);
}
function onSizeRejected(e: Event) {
const ev = e as CustomEvent<{
rejected: { name: string; size: number }[];
maxMb: number;
}>;
const r = ev.detail?.rejected ?? [];
if (!r.length) return;
const msg =
r.length === 1
? t('errors.fileTooLargeOne', { name: r[0]?.name ?? '', maxMb: ev.detail.maxMb })
: t('errors.fileTooLarge', { count: r.length, maxMb: ev.detail.maxMb });
push('error', msg);
}
window.addEventListener('hasarui:toast', onToast as EventListener);
window.addEventListener('hasarui:file-size-rejected', onSizeRejected as EventListener);
return () => {
window.removeEventListener('hasarui:toast', onToast as EventListener);
window.removeEventListener(
'hasarui:file-size-rejected',
onSizeRejected as EventListener,
);
};
}, [push, t]);
if (toasts.length === 0) return null;
return (
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2">
{toasts.map((toast) => (
<div
key={toast.id}
className={`pointer-events-auto flex items-start gap-2 rounded-lg border px-3 py-2 text-sm shadow-lg backdrop-blur ${
toast.kind === 'error'
? 'border-red-200 bg-red-50/95 text-red-900 dark:border-red-800 dark:bg-red-900/80 dark:text-red-100'
: toast.kind === 'success'
? 'border-emerald-200 bg-emerald-50/95 text-emerald-900 dark:border-emerald-800 dark:bg-emerald-900/80 dark:text-emerald-100'
: 'border-slate-200 bg-white/95 text-slate-800 dark:border-slate-700 dark:bg-slate-800/95 dark:text-slate-100'
}`}
>
<span className="mt-0.5 shrink-0">
{toast.kind === 'error' ? (
<AlertTriangle className="h-4 w-4" />
) : toast.kind === 'success' ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<Info className="h-4 w-4" />
)}
</span>
<div className="flex-1 leading-snug">{toast.message}</div>
<button
type="button"
onClick={() => setToasts((cur) => cur.filter((x) => x.id !== toast.id))}
className="rounded p-0.5 opacity-60 hover:opacity-100"
aria-label="dismiss"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
);
}