ToastContext.tsx 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648
  1. import { AlertCircle, CheckCircle, ChevronDown, ChevronUp, Info, Loader2, X, XCircle } from 'lucide-react';
  2. import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
  3. import { useTranslation } from 'react-i18next';
  4. import { api } from '../api/client';
  5. import { formatFileSize } from '../utils/file';
  6. type ToastType = 'success' | 'error' | 'warning' | 'info' | 'loading';
  7. type ShowPersistentToast = (id: string, message: string, type?: ToastType) => void;
  8. interface Toast {
  9. id: string;
  10. message: string;
  11. type: ToastType;
  12. persistent?: boolean;
  13. dispatchData?: DispatchToastData;
  14. }
  15. type DispatchJobStatus = 'dispatched' | 'processing' | 'completed' | 'failed' | 'cancelled';
  16. interface DispatchToastJob {
  17. jobId: number;
  18. sourceName: string;
  19. printerName: string;
  20. status: DispatchJobStatus;
  21. message?: string;
  22. uploadBytes?: number;
  23. uploadTotalBytes?: number;
  24. uploadProgressPct?: number;
  25. }
  26. interface DispatchToastData {
  27. total: number;
  28. dispatched: number;
  29. processing: number;
  30. completed: number;
  31. failed: number;
  32. jobs: DispatchToastJob[];
  33. }
  34. interface ToastContextType {
  35. showToast: (message: string, type?: ToastType) => void;
  36. showPersistentToast: ShowPersistentToast;
  37. dismissToast: (id: string) => void;
  38. /**
  39. * Suppress the visible toast viewport while keeping the state machine alive.
  40. * Used by the SpoolBuddy kiosk layout to keep the kiosk display free of
  41. * main-app notifications (background dispatch progress, etc.) without
  42. * tearing down the dispatch-job subscription that other tabs rely on.
  43. */
  44. setViewportSuppressed: (suppressed: boolean) => void;
  45. }
  46. const ToastContext = createContext<ToastContextType | undefined>(undefined);
  47. export function useToast() {
  48. const context = useContext(ToastContext);
  49. if (!context) {
  50. throw new Error('useToast must be used within a ToastProvider');
  51. }
  52. return context;
  53. }
  54. const icons = {
  55. success: <CheckCircle className="w-5 h-5 text-green-400" />,
  56. error: <XCircle className="w-5 h-5 text-red-400" />,
  57. warning: <AlertCircle className="w-5 h-5 text-yellow-400" />,
  58. info: <Info className="w-5 h-5 text-blue-400" />,
  59. loading: <Loader2 className="w-5 h-5 text-bambu-green animate-spin" />,
  60. };
  61. const bgColors = {
  62. success: 'bg-green-500/10 border-green-500/30',
  63. error: 'bg-red-500/10 border-red-500/30',
  64. warning: 'bg-yellow-500/10 border-yellow-500/30',
  65. info: 'bg-blue-500/10 border-blue-500/30',
  66. loading: 'bg-bambu-green/10 border-bambu-green/30',
  67. };
  68. export function ToastProvider({ children }: { children: ReactNode }) {
  69. const { t } = useTranslation();
  70. const [toasts, setToasts] = useState<Toast[]>([]);
  71. const [isDispatchCollapsed, setIsDispatchCollapsed] = useState(false);
  72. const [viewportSuppressed, setViewportSuppressed] = useState(false);
  73. const [cancellingDispatchJobIds, setCancellingDispatchJobIds] = useState<Set<number>>(new Set());
  74. const timeoutRefs = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
  75. const dispatchToastId = 'background-dispatch';
  76. const lastDispatchSummaryRef = useRef<string | null>(null);
  77. // Tracks whether the provider is still mounted. A toast can be triggered by
  78. // an async callback that resolves AFTER React has unmounted us (common in
  79. // tests: `cleanup()` runs while a login promise is still in flight, then
  80. // the error handler calls showToast). In that case, scheduling a setTimeout
  81. // that later calls setToasts produces "window is not defined" once the jsdom
  82. // environment is torn down. Guard every setToasts call behind this ref so a
  83. // post-unmount showToast is a no-op instead of crashing.
  84. const isMountedRef = useRef(true);
  85. // Clean up all timeouts on unmount
  86. useEffect(() => {
  87. isMountedRef.current = true;
  88. const timeouts = timeoutRefs.current;
  89. return () => {
  90. isMountedRef.current = false;
  91. timeouts.forEach((timeout) => clearTimeout(timeout));
  92. timeouts.clear();
  93. };
  94. }, []);
  95. const showToast = useCallback((message: string, type: ToastType = 'success') => {
  96. if (!isMountedRef.current) return;
  97. const id = Math.random().toString(36).substr(2, 9);
  98. setToasts((prev) => [...prev, { id, message, type }]);
  99. // Auto-dismiss after 3 seconds
  100. const timeout = setTimeout(() => {
  101. if (!isMountedRef.current) return;
  102. setToasts((prev) => prev.filter((t) => t.id !== id));
  103. timeoutRefs.current.delete(id);
  104. }, 3000);
  105. timeoutRefs.current.set(id, timeout);
  106. }, []);
  107. const showPersistentToast = useCallback((id: string, message: string, type: ToastType = 'info') => {
  108. if (!isMountedRef.current) return;
  109. setToasts((prev) => {
  110. // Update existing toast if same id, otherwise add new one
  111. const exists = prev.find((t) => t.id === id);
  112. if (exists) {
  113. return prev.map((t) => (t.id === id ? { ...t, message, type, persistent: true } : t));
  114. }
  115. return [...prev, { id, message, type, persistent: true }];
  116. });
  117. }, []);
  118. const dismissToast = useCallback((id: string) => {
  119. if (!isMountedRef.current) return;
  120. // Clear any pending auto-dismiss timeout
  121. const timeout = timeoutRefs.current.get(id);
  122. if (timeout) {
  123. clearTimeout(timeout);
  124. timeoutRefs.current.delete(id);
  125. }
  126. setToasts((prev) => prev.filter((t) => t.id !== id));
  127. }, []);
  128. const cancelDispatchJob = useCallback(async (jobId: number) => {
  129. setCancellingDispatchJobIds((prev) => {
  130. const next = new Set(prev);
  131. next.add(jobId);
  132. return next;
  133. });
  134. try {
  135. const result = await api.cancelBackgroundDispatchJob(jobId);
  136. showToast(
  137. result.status === 'cancelling'
  138. ? t('backgroundDispatch.toast.cancellingUpload')
  139. : t('backgroundDispatch.toast.cancelled'),
  140. 'info'
  141. );
  142. } catch (error) {
  143. const message = error instanceof Error ? error.message : t('backgroundDispatch.toast.cancelFailed');
  144. showToast(message, 'error');
  145. } finally {
  146. setCancellingDispatchJobIds((prev) => {
  147. const next = new Set(prev);
  148. next.delete(jobId);
  149. return next;
  150. });
  151. }
  152. }, [showToast, t]);
  153. useEffect(() => {
  154. interface DispatchEventDetail {
  155. total?: number;
  156. dispatched?: number;
  157. processing?: number;
  158. completed?: number;
  159. failed?: number;
  160. dispatched_jobs?: Array<{
  161. job_id: number;
  162. source_name?: string;
  163. printer_name?: string;
  164. }>;
  165. active_job?: {
  166. job_id?: number;
  167. printer_name?: string;
  168. source_name?: string;
  169. message?: string;
  170. upload_bytes?: number;
  171. upload_total_bytes?: number;
  172. upload_progress_pct?: number;
  173. } | null;
  174. active_jobs?: Array<{
  175. job_id?: number;
  176. printer_name?: string;
  177. source_name?: string;
  178. message?: string;
  179. upload_bytes?: number;
  180. upload_total_bytes?: number;
  181. upload_progress_pct?: number;
  182. }>;
  183. recent_event?: {
  184. status?: string;
  185. job_id?: number;
  186. source_name?: string;
  187. printer_name?: string;
  188. message?: string;
  189. };
  190. }
  191. const updateJob = (
  192. jobs: DispatchToastJob[],
  193. jobId: number,
  194. next: Partial<DispatchToastJob> & {
  195. status: DispatchJobStatus;
  196. sourceName: string;
  197. printerName: string;
  198. }
  199. ) => {
  200. const index = jobs.findIndex((job) => job.jobId === jobId);
  201. if (index === -1) {
  202. return [...jobs, { jobId, ...next }];
  203. }
  204. const copy = [...jobs];
  205. copy[index] = {
  206. ...copy[index],
  207. ...next,
  208. };
  209. return copy;
  210. };
  211. const statusWeight = (status: DispatchJobStatus) => {
  212. switch (status) {
  213. case 'failed':
  214. return 0;
  215. case 'processing':
  216. return 1;
  217. case 'dispatched':
  218. return 2;
  219. case 'completed':
  220. return 3;
  221. case 'cancelled':
  222. return 4;
  223. }
  224. };
  225. const onDispatchEvent = (event: Event) => {
  226. const detail = (event as CustomEvent<DispatchEventDetail>).detail || {};
  227. const total = detail.total ?? 0;
  228. const dispatched = detail.dispatched ?? 0;
  229. const processing = detail.processing ?? 0;
  230. const completed = detail.completed ?? 0;
  231. const failed = detail.failed ?? 0;
  232. const hasActiveWork = dispatched + processing > 0;
  233. const allDone = total > 0 && completed + failed >= total && !hasActiveWork;
  234. const recentStatus = detail.recent_event?.status;
  235. // Once any print starts successfully, dismiss the dispatch toast (#615)
  236. // Remaining jobs continue in the background silently
  237. if (recentStatus === 'completed' && completed > 0) {
  238. const summaryKey = `first-complete:${completed}:${failed}`;
  239. if (lastDispatchSummaryRef.current !== summaryKey) {
  240. lastDispatchSummaryRef.current = summaryKey;
  241. const remaining = total - completed - failed;
  242. const doneMessage = remaining > 0
  243. ? t('backgroundDispatch.toast.printStartedRemaining', { completed, remaining })
  244. : failed > 0
  245. ? t('backgroundDispatch.toast.completeWithFailures', { completed, failed })
  246. : t('backgroundDispatch.toast.completeSuccess', { completed });
  247. setToasts((prev) => {
  248. const doneToast: Toast = {
  249. id: dispatchToastId,
  250. message: doneMessage,
  251. type: failed > 0 ? 'warning' : 'success',
  252. persistent: true,
  253. };
  254. const exists = prev.find((toastItem) => toastItem.id === dispatchToastId);
  255. if (exists) {
  256. return prev.map((toastItem) =>
  257. toastItem.id === dispatchToastId ? doneToast : toastItem
  258. );
  259. }
  260. return [...prev, doneToast];
  261. });
  262. const existingTimeout = timeoutRefs.current.get(dispatchToastId);
  263. if (existingTimeout) clearTimeout(existingTimeout);
  264. const timeout = setTimeout(() => {
  265. if (!isMountedRef.current) return;
  266. setToasts((prev) => prev.filter((t) => t.id !== dispatchToastId));
  267. timeoutRefs.current.delete(dispatchToastId);
  268. lastDispatchSummaryRef.current = null;
  269. }, 3000);
  270. timeoutRefs.current.set(dispatchToastId, timeout);
  271. }
  272. return;
  273. }
  274. if (hasActiveWork) {
  275. // New batch starting — reset dedup guard so completion toast works
  276. lastDispatchSummaryRef.current = null;
  277. setToasts((prev) => {
  278. const existing = prev.find((toastItem) => toastItem.id === dispatchToastId);
  279. const existingJobs = existing?.dispatchData?.jobs || [];
  280. const dispatchedJobs: DispatchToastJob[] = (detail.dispatched_jobs || []).map((job) => ({
  281. jobId: job.job_id,
  282. sourceName: job.source_name || t('backgroundDispatch.unknownFile'),
  283. printerName: job.printer_name || t('backgroundDispatch.unknownPrinter'),
  284. status: 'dispatched',
  285. }));
  286. const activeJobsPayload =
  287. detail.active_jobs && detail.active_jobs.length > 0
  288. ? detail.active_jobs
  289. : detail.active_job?.job_id
  290. ? [detail.active_job]
  291. : [];
  292. const activeJobs: DispatchToastJob[] = activeJobsPayload
  293. .filter((job) => typeof job.job_id === 'number')
  294. .map((job) => ({
  295. jobId: job.job_id as number,
  296. sourceName: job.source_name || t('backgroundDispatch.unknownFile'),
  297. printerName: job.printer_name || t('backgroundDispatch.unknownPrinter'),
  298. status: 'processing',
  299. message: job.message,
  300. uploadBytes: job.upload_bytes,
  301. uploadTotalBytes: job.upload_total_bytes,
  302. uploadProgressPct: job.upload_progress_pct,
  303. }));
  304. const activeIds = new Set([...dispatchedJobs, ...activeJobs].map((job) => job.jobId));
  305. const historicalJobs = existingJobs.filter(
  306. (job) => !activeIds.has(job.jobId) && ['completed', 'failed', 'cancelled'].includes(job.status)
  307. );
  308. let jobs = [...dispatchedJobs, ...activeJobs, ...historicalJobs];
  309. if (detail.recent_event?.job_id && detail.recent_event?.status) {
  310. const rawStatus = detail.recent_event.status;
  311. const eventStatus = (
  312. rawStatus === 'cancelled' ? 'cancelled' : rawStatus === 'cancelling' ? 'processing' : rawStatus
  313. ) as DispatchJobStatus;
  314. const sourceName = detail.recent_event.source_name || t('backgroundDispatch.unknownFile');
  315. const printerName = detail.recent_event.printer_name || t('backgroundDispatch.unknownPrinter');
  316. jobs = updateJob(jobs, detail.recent_event.job_id, {
  317. status: eventStatus,
  318. sourceName,
  319. printerName,
  320. message: detail.recent_event.message,
  321. });
  322. }
  323. activeJobs.forEach((activeJob) => {
  324. jobs = updateJob(jobs, activeJob.jobId, {
  325. status: 'processing',
  326. sourceName: activeJob.sourceName,
  327. printerName: activeJob.printerName,
  328. message: activeJob.message,
  329. uploadBytes: activeJob.uploadBytes,
  330. uploadTotalBytes: activeJob.uploadTotalBytes,
  331. uploadProgressPct: activeJob.uploadProgressPct,
  332. });
  333. });
  334. const dispatchData: DispatchToastData = {
  335. total,
  336. dispatched,
  337. processing,
  338. completed,
  339. failed,
  340. jobs: [...jobs].sort((a, b) => {
  341. const byStatus = statusWeight(a.status) - statusWeight(b.status);
  342. if (byStatus !== 0) {
  343. return byStatus;
  344. }
  345. return a.jobId - b.jobId;
  346. }),
  347. };
  348. const exists = prev.find((toastItem) => toastItem.id === dispatchToastId);
  349. if (exists) {
  350. return prev.map((toastItem) =>
  351. toastItem.id === dispatchToastId
  352. ? {
  353. ...toastItem,
  354. message: t('backgroundDispatch.startingPrints'),
  355. type: 'loading',
  356. persistent: true,
  357. dispatchData,
  358. }
  359. : toastItem
  360. );
  361. }
  362. return [
  363. ...prev,
  364. {
  365. id: dispatchToastId,
  366. message: t('backgroundDispatch.startingPrints'),
  367. type: 'loading',
  368. persistent: true,
  369. dispatchData,
  370. },
  371. ];
  372. });
  373. return;
  374. }
  375. if (allDone) {
  376. const summaryKey = `${completed}:${failed}`;
  377. if (lastDispatchSummaryRef.current === summaryKey) {
  378. return;
  379. }
  380. lastDispatchSummaryRef.current = summaryKey;
  381. const doneMessage = failed > 0
  382. ? t('backgroundDispatch.toast.completeWithFailures', { completed, failed })
  383. : t('backgroundDispatch.toast.completeSuccess', { completed });
  384. // Show a brief "completed" state on the dispatch toast before replacing with summary
  385. // This ensures the user sees confirmation even for fast uploads (#615)
  386. setToasts((prev) => {
  387. const doneToast: Toast = {
  388. id: dispatchToastId,
  389. message: doneMessage,
  390. type: failed > 0 ? 'warning' : 'success',
  391. persistent: true,
  392. // Clear dispatchData so it renders as a simple text toast
  393. };
  394. const exists = prev.find((toastItem) => toastItem.id === dispatchToastId);
  395. if (exists) {
  396. return prev.map((toastItem) =>
  397. toastItem.id === dispatchToastId ? doneToast : toastItem
  398. );
  399. }
  400. return [...prev, doneToast];
  401. });
  402. // Auto-dismiss after 3 seconds
  403. const existingTimeout = timeoutRefs.current.get(dispatchToastId);
  404. if (existingTimeout) clearTimeout(existingTimeout);
  405. const timeout = setTimeout(() => {
  406. setToasts((prev) => prev.filter((t) => t.id !== dispatchToastId));
  407. timeoutRefs.current.delete(dispatchToastId);
  408. lastDispatchSummaryRef.current = null;
  409. }, 3000);
  410. timeoutRefs.current.set(dispatchToastId, timeout);
  411. return;
  412. }
  413. if (!hasActiveWork && recentStatus && ['cancelled', 'failed', 'completed', 'idle'].includes(recentStatus)) {
  414. setToasts((prev) => prev.filter((t) => t.id !== dispatchToastId));
  415. lastDispatchSummaryRef.current = null;
  416. }
  417. if (detail.recent_event?.status === 'idle' && !hasActiveWork) {
  418. setToasts((prev) => prev.filter((t) => t.id !== dispatchToastId));
  419. lastDispatchSummaryRef.current = null;
  420. }
  421. if (!hasActiveWork) {
  422. setCancellingDispatchJobIds(new Set());
  423. }
  424. if (detail.dispatched_jobs) {
  425. const dispatchedIds = new Set(detail.dispatched_jobs.map((job) => job.job_id));
  426. setCancellingDispatchJobIds((prev) => {
  427. const next = new Set<number>();
  428. prev.forEach((id) => {
  429. if (dispatchedIds.has(id)) {
  430. next.add(id);
  431. }
  432. });
  433. return next;
  434. });
  435. }
  436. };
  437. window.addEventListener('background-dispatch', onDispatchEvent);
  438. return () => window.removeEventListener('background-dispatch', onDispatchEvent);
  439. }, [t]);
  440. return (
  441. <ToastContext.Provider value={{ showToast, showPersistentToast, dismissToast, setViewportSuppressed }}>
  442. {children}
  443. {/* Toast Container — to the left of the bug-report bubble (bottom-4 right-4 w-12).
  444. The kiosk layout suppresses this entire viewport so SpoolBuddy displays stay
  445. free of main-app notifications. */}
  446. <div className={`fixed bottom-4 right-20 z-[60] flex flex-col items-end gap-2 ${viewportSuppressed ? 'hidden' : ''}`}>
  447. {toasts.map((toast) => (
  448. <div
  449. key={toast.id}
  450. className={`rounded-lg border shadow-lg backdrop-blur-sm animate-slide-in ${bgColors[toast.type]} ${
  451. toast.dispatchData ? 'w-[420px] p-3' : 'flex items-center gap-3 px-4 py-3'
  452. }`}
  453. >
  454. {toast.dispatchData ? (
  455. <>
  456. <div className="flex items-start justify-between gap-3">
  457. <div className="flex items-start gap-2">
  458. {icons[toast.type]}
  459. <div>
  460. <p className="text-white text-sm font-medium">{t('backgroundDispatch.startingPrints')}</p>
  461. <p className="text-xs text-bambu-gray mt-0.5">
  462. {t('backgroundDispatch.progressSummary', {
  463. complete: toast.dispatchData.completed + toast.dispatchData.failed,
  464. total: toast.dispatchData.total,
  465. dispatched: toast.dispatchData.dispatched,
  466. processing: toast.dispatchData.processing,
  467. })}
  468. </p>
  469. </div>
  470. </div>
  471. <div className="flex items-center gap-1">
  472. <button
  473. onClick={() => setIsDispatchCollapsed((prev) => !prev)}
  474. className="text-bambu-gray hover:text-white transition-colors"
  475. aria-label={
  476. isDispatchCollapsed
  477. ? t('backgroundDispatch.expandDetails')
  478. : t('backgroundDispatch.collapseDetails')
  479. }
  480. >
  481. {isDispatchCollapsed ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
  482. </button>
  483. <button
  484. onClick={() => dismissToast(toast.id)}
  485. className="text-bambu-gray hover:text-white transition-colors"
  486. aria-label={t('backgroundDispatch.dismissToast')}
  487. >
  488. <X className="w-4 h-4" />
  489. </button>
  490. </div>
  491. </div>
  492. {!isDispatchCollapsed && (
  493. <div className="mt-3 space-y-2 max-h-64 overflow-y-auto pr-1">
  494. {toast.dispatchData.jobs.map((job) => {
  495. const progressByStatus: Record<DispatchJobStatus, number> = {
  496. dispatched: 15,
  497. processing: 60,
  498. completed: 100,
  499. failed: 100,
  500. cancelled: 100,
  501. };
  502. // Upload byte count reached the total — the printer hasn't yet
  503. // confirmed it received the file (state is still 'processing').
  504. // Without distinguishing this we show a frozen 100% bar that
  505. // reads as "stuck" on small files where the upload completed
  506. // in <500ms.
  507. const uploadDoneAwaitingPrinter =
  508. job.status === 'processing' &&
  509. typeof job.uploadProgressPct === 'number' &&
  510. job.uploadProgressPct >= 99.9;
  511. const barColorByStatus: Record<DispatchJobStatus, string> = {
  512. dispatched: 'bg-bambu-gray/60',
  513. processing: 'bg-bambu-green',
  514. completed: 'bg-green-500',
  515. failed: 'bg-red-500',
  516. cancelled: 'bg-yellow-500',
  517. };
  518. return (
  519. <div key={job.jobId} className="rounded border border-white/10 bg-black/15 p-2">
  520. <div className="flex items-center justify-between gap-2">
  521. <span className="text-xs text-white truncate" title={job.sourceName}>
  522. {job.sourceName}
  523. </span>
  524. <div className="flex items-center gap-2">
  525. {(job.status === 'dispatched' || job.status === 'processing') && (
  526. <button
  527. onClick={() => void cancelDispatchJob(job.jobId)}
  528. disabled={cancellingDispatchJobIds.has(job.jobId)}
  529. className="text-[11px] text-red-300 hover:text-red-200 disabled:opacity-50 disabled:cursor-not-allowed"
  530. title={t('backgroundDispatch.cancelDispatchJob')}
  531. >
  532. {cancellingDispatchJobIds.has(job.jobId)
  533. ? t('backgroundDispatch.cancelling')
  534. : t('backgroundDispatch.cancel')}
  535. </button>
  536. )}
  537. <span className="text-[11px] uppercase tracking-wide text-bambu-gray">
  538. {t(`backgroundDispatch.status.${job.status}`)}
  539. </span>
  540. </div>
  541. </div>
  542. <div className="text-[11px] text-bambu-gray truncate" title={job.printerName}>
  543. {job.printerName}
  544. </div>
  545. {job.message && (
  546. <div className="text-[11px] text-bambu-gray truncate" title={job.message}>
  547. {job.message}
  548. </div>
  549. )}
  550. {job.status === 'processing' && (
  551. uploadDoneAwaitingPrinter ? (
  552. <div className="text-[11px] text-bambu-gray truncate">
  553. {t('backgroundDispatch.awaitingPrinter')}
  554. </div>
  555. ) : typeof job.uploadBytes === 'number' && typeof job.uploadTotalBytes === 'number' && job.uploadTotalBytes > 0 ? (
  556. <div className="text-[11px] text-bambu-gray truncate">
  557. {formatFileSize(job.uploadBytes)} / {formatFileSize(job.uploadTotalBytes)}
  558. {typeof job.uploadProgressPct === 'number' ? ` (${job.uploadProgressPct.toFixed(1)}%)` : ''}
  559. </div>
  560. ) : null
  561. )}
  562. <div className="mt-1 h-1.5 w-full rounded bg-white/10 overflow-hidden">
  563. <div
  564. className={`h-full ${barColorByStatus[job.status]} transition-all duration-300 ${uploadDoneAwaitingPrinter ? 'animate-pulse' : ''}`}
  565. style={{
  566. width: `${
  567. job.status === 'processing' && typeof job.uploadProgressPct === 'number'
  568. ? Math.max(0, Math.min(100, job.uploadProgressPct))
  569. : progressByStatus[job.status]
  570. }%`,
  571. }}
  572. />
  573. </div>
  574. </div>
  575. );
  576. })}
  577. </div>
  578. )}
  579. </>
  580. ) : (
  581. <>
  582. {icons[toast.type]}
  583. <span className="text-white text-sm">{toast.message}</span>
  584. <button
  585. onClick={() => dismissToast(toast.id)}
  586. className="ml-2 text-bambu-gray hover:text-white transition-colors"
  587. >
  588. <X className="w-4 h-4" />
  589. </button>
  590. </>
  591. )}
  592. </div>
  593. ))}
  594. </div>
  595. </ToastContext.Provider>
  596. );
  597. }