import { AlertCircle, CheckCircle, ChevronDown, ChevronUp, Info, Loader2, X, XCircle } from 'lucide-react'; import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { formatFileSize } from '../utils/file'; type ToastType = 'success' | 'error' | 'warning' | 'info' | 'loading'; // Dispatch-toast types — ported verbatim from // 0b43ac0d:frontend/src/contexts/ToastContext.tsx. The visual rendering // block below is the legacy code 1:1; the only swap is the event ingestion // (now sourced from `bambuddy:dispatch-toast` window events that // useWebSocket forwards from the four backend WS event types added in // the #1625 follow-up). Same shape, same DOM, same styling, same i18n // surface — guarantees the modal looks identical to the pre-scheduler // experience that users remember. type DispatchJobStatus = 'processing' | 'completed' | 'failed'; interface DispatchToastJob { jobId: number; sourceName: string; printerName: string; status: DispatchJobStatus; uploadBytes?: number; uploadTotalBytes?: number; uploadProgressPct?: number; failReason?: string; } interface DispatchToastData { total: number; processing: number; completed: number; failed: number; jobs: DispatchToastJob[]; } interface ToastAction { label: string; href: string; onClick?: () => void; } type ShowPersistentToast = ( id: string, message: string, type?: ToastType, options?: { action?: ToastAction }, ) => void; interface Toast { id: string; message: string; type: ToastType; persistent?: boolean; action?: ToastAction; dispatchData?: DispatchToastData; } interface ToastContextType { showToast: (message: string, type?: ToastType) => void; showPersistentToast: ShowPersistentToast; dismissToast: (id: string) => void; setViewportSuppressed: (suppressed: boolean) => void; } const ToastContext = createContext(undefined); export function useToast() { const context = useContext(ToastContext); if (!context) { throw new Error('useToast must be used within a ToastProvider'); } return context; } const icons = { success: , error: , warning: , info: , loading: , }; const bgColors = { success: 'bg-green-500/10 border-green-500/30', error: 'bg-red-500/10 border-red-500/30', warning: 'bg-yellow-500/10 border-yellow-500/30', info: 'bg-blue-500/10 border-blue-500/30', loading: 'bg-bambu-green/10 border-bambu-green/30', }; const DISPATCH_TOAST_ID = 'background-dispatch'; const DISPATCH_TERMINAL_DISMISS_MS = 3500; // Auto-dismiss windows for the plain (non-persistent) toasts. Errors and // warnings get double the default because they carry far more text than a // success confirmation — a backend failure reason or a validation message // often runs to a couple of lines, and 3s isn't long enough to finish // reading one before it slides away. Success/info stay short: they confirm // something the user just did and are skimmed, not read. const TOAST_DISMISS_MS = 3000; const TOAST_DISMISS_LONG_MS = 2 * TOAST_DISMISS_MS; const LONG_LIVED_TOAST_TYPES: ReadonlySet = new Set(['error', 'warning']); interface DispatchEventDetail { type: string; queue_item_id: number; printer_id?: number | null; printer_name?: string | null; file_name?: string; total_bytes?: number; bytes_transferred?: number; pct?: number; reason?: string; } function isAwaitingPrinter(job: DispatchToastJob): boolean { // Same trick the legacy code used to derive "Awaiting printer…" without // a separate status. While the job is still 'processing' AND upload pct // has reached 99.9%, the printer hasn't yet acked our project_file. return ( job.status === 'processing' && typeof job.uploadProgressPct === 'number' && job.uploadProgressPct >= 99.9 ); } function recomputeAggregate(jobs: DispatchToastJob[]): DispatchToastData { return { total: jobs.length, processing: jobs.filter((j) => j.status === 'processing').length, completed: jobs.filter((j) => j.status === 'completed').length, failed: jobs.filter((j) => j.status === 'failed').length, jobs, }; } export function ToastProvider({ children }: { children: ReactNode }) { const { t } = useTranslation(); const [toasts, setToasts] = useState([]); const [viewportSuppressed, setViewportSuppressed] = useState(false); const [isDispatchCollapsed, setIsDispatchCollapsed] = useState(false); const timeoutRefs = useRef>>(new Map()); // Tracks whether the provider is still mounted. A toast can be triggered by // an async callback that resolves AFTER React has unmounted us (common in // tests: `cleanup()` runs while a login promise is still in flight, then // the error handler calls showToast). In that case, scheduling a setTimeout // that later calls setToasts produces "window is not defined" once the jsdom // environment is torn down. Guard every setToasts call behind this ref so a // post-unmount showToast is a no-op instead of crashing. const isMountedRef = useRef(true); // Clean up all timeouts on unmount useEffect(() => { isMountedRef.current = true; const timeouts = timeoutRefs.current; return () => { isMountedRef.current = false; timeouts.forEach((timeout) => clearTimeout(timeout)); timeouts.clear(); }; }, []); const showToast = useCallback((message: string, type: ToastType = 'success') => { if (!isMountedRef.current) return; const id = Math.random().toString(36).substr(2, 9); setToasts((prev) => [...prev, { id, message, type }]); // Auto-dismiss — longer for the types that carry more to read. const timeout = setTimeout(() => { if (!isMountedRef.current) return; setToasts((prev) => prev.filter((t) => t.id !== id)); timeoutRefs.current.delete(id); }, LONG_LIVED_TOAST_TYPES.has(type) ? TOAST_DISMISS_LONG_MS : TOAST_DISMISS_MS); timeoutRefs.current.set(id, timeout); }, []); const showPersistentToast = useCallback( (id: string, message: string, type: ToastType = 'info', options?: { action?: ToastAction }) => { if (!isMountedRef.current) return; setToasts((prev) => { // Update existing toast if same id, otherwise add new one const exists = prev.find((t) => t.id === id); if (exists) { return prev.map((t) => t.id === id ? { ...t, message, type, persistent: true, action: options?.action } : t, ); } return [...prev, { id, message, type, persistent: true, action: options?.action }]; }); }, [], ); const dismissToast = useCallback((id: string) => { if (!isMountedRef.current) return; // Clear any pending auto-dismiss timeout const timeout = timeoutRefs.current.get(id); if (timeout) { clearTimeout(timeout); timeoutRefs.current.delete(id); } setToasts((prev) => prev.filter((t) => t.id !== id)); }, []); // Dispatch-toast ingestion. The four event types from the backend // (queue_item_uploading / upload_progress / acked / failed) map to // the legacy DispatchToastJob shape, then the same auto-dismiss + // aggregate-recompute logic from 0b43ac0d takes over. useEffect(() => { const onDispatchEvent = (event: Event) => { if (!isMountedRef.current) return; const detail = (event as CustomEvent).detail; if (!detail || typeof detail.queue_item_id !== 'number') return; const jobId = detail.queue_item_id; setToasts((prev) => { const existing = prev.find((toastItem) => toastItem.id === DISPATCH_TOAST_ID); const existingJobs = existing?.dispatchData?.jobs ?? []; const existingJobIndex = existingJobs.findIndex((j) => j.jobId === jobId); const existingJob = existingJobIndex >= 0 ? existingJobs[existingJobIndex] : undefined; let nextJob: DispatchToastJob | null = null; const sourceName = detail.file_name || existingJob?.sourceName || t('dispatchToast.untitled'); const printerName = detail.printer_name || existingJob?.printerName || (detail.printer_id ? `Printer ${detail.printer_id}` : ''); switch (detail.type) { case 'queue_item_uploading': // Materialization point — job appears here, never on queue-add. nextJob = { jobId, sourceName, printerName, status: 'processing', uploadBytes: 0, uploadTotalBytes: detail.total_bytes, uploadProgressPct: 0, }; break; case 'queue_item_upload_progress': if (!existingJob) return prev; nextJob = { ...existingJob, uploadBytes: detail.bytes_transferred, uploadTotalBytes: detail.total_bytes ?? existingJob.uploadTotalBytes, uploadProgressPct: detail.pct, }; break; case 'queue_item_acked': if (!existingJob) return prev; nextJob = { ...existingJob, status: 'completed', uploadProgressPct: 100, }; break; case 'queue_item_failed': if (!existingJob) return prev; nextJob = { ...existingJob, status: 'failed', failReason: detail.reason, }; break; default: return prev; } // Compose the updated jobs list let updatedJobs: DispatchToastJob[]; if (existingJob) { updatedJobs = [...existingJobs]; updatedJobs[existingJobIndex] = nextJob; } else { updatedJobs = [...existingJobs, nextJob]; } const dispatchData = recomputeAggregate(updatedJobs); const toastShape: Toast = { id: DISPATCH_TOAST_ID, message: t('dispatchToast.startingPrints'), type: 'loading', persistent: true, dispatchData, }; if (existing) { return prev.map((toastItem) => toastItem.id === DISPATCH_TOAST_ID ? toastShape : toastItem, ); } return [...prev, toastShape]; }); }; window.addEventListener('bambuddy:dispatch-toast', onDispatchEvent); return () => window.removeEventListener('bambuddy:dispatch-toast', onDispatchEvent); }, [t]); // Auto-dismiss the wrapper once every job has reached a terminal state. useEffect(() => { const dispatchToast = toasts.find((tst) => tst.id === DISPATCH_TOAST_ID); if (!dispatchToast?.dispatchData) return; const data = dispatchToast.dispatchData; if (data.total === 0 || data.processing !== 0) return; const existing = timeoutRefs.current.get(DISPATCH_TOAST_ID); if (existing) clearTimeout(existing); const timeout = setTimeout(() => { if (!isMountedRef.current) return; setToasts((prev) => prev.filter((tst) => tst.id !== DISPATCH_TOAST_ID)); timeoutRefs.current.delete(DISPATCH_TOAST_ID); }, DISPATCH_TERMINAL_DISMISS_MS); timeoutRefs.current.set(DISPATCH_TOAST_ID, timeout); }, [toasts]); return ( {children} {/* Toast Container — to the left of the bug-report bubble (bottom-4 right-4 w-12). The kiosk layout suppresses this entire viewport so SpoolBuddy displays stay free of main-app notifications. Position is set via safe-area-aware calc() rather than bottom-4/right-20 so an installed PWA on a notched phone clears the home indicator / landscape notch (#2612): the 5rem right offset keeps clearance for the bug bubble. */}
{toasts.map((toast) => (
{toast.dispatchData ? ( // Legacy dispatch-toast rendering — verbatim port from // 0b43ac0d:frontend/src/contexts/ToastContext.tsx lines // 515–650. Same DOM, same Tailwind classes, same uppercase // status chip, same `awaitingPrinter` derivation. Only // diff vs legacy: no cancel button (the BG dispatch // cancel endpoint doesn't exist in the scheduler model). <>
{icons[toast.type]}

{t('dispatchToast.startingPrints')}

{t('dispatchToast.progressSummary', { complete: toast.dispatchData.completed + toast.dispatchData.failed, total: toast.dispatchData.total, processing: toast.dispatchData.processing, })}

{!isDispatchCollapsed && (
{toast.dispatchData.jobs.map((job) => { const uploadDoneAwaitingPrinter = isAwaitingPrinter(job); const barColorByStatus: Record = { processing: 'bg-bambu-green', completed: 'bg-green-500', failed: 'bg-red-500', }; const progressByStatus: Record = { processing: 60, completed: 100, failed: 100, }; return (
{/* min-w-0 + flex-1 lets truncate actually kick in when the toast is capped to a phone's width (#2612); the status chip stays put with shrink-0. */} {job.sourceName} {t(`dispatchToast.status.${job.status}`)}
{job.printerName && (
{job.printerName}
)} {job.status === 'processing' ? ( uploadDoneAwaitingPrinter ? (
{t('dispatchToast.awaitingPrinter')}
) : typeof job.uploadBytes === 'number' && typeof job.uploadTotalBytes === 'number' && job.uploadTotalBytes > 0 ? (
{formatFileSize(job.uploadBytes)} / {formatFileSize(job.uploadTotalBytes)} {typeof job.uploadProgressPct === 'number' ? ` (${job.uploadProgressPct.toFixed(1)}%)` : ''}
) : null ) : job.status === 'failed' && job.failReason ? (
{t(`dispatchToast.failed.${job.failReason}`, { defaultValue: t('dispatchToast.failed.generic') })}
) : null}
); })}
)} ) : ( <> {icons[toast.type]} {toast.message} {toast.action && ( { toast.action?.onClick?.(); dismissToast(toast.id); }} className="ml-2 px-2 py-1 rounded text-xs font-medium bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 whitespace-nowrap" > {toast.action.label} )} )}
))}
); }