ToastContext.tsx 26 KB

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