ToastContext.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537
  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 Toast {
  8. id: string;
  9. message: string;
  10. type: ToastType;
  11. persistent?: boolean;
  12. dispatchData?: DispatchToastData;
  13. }
  14. type DispatchJobStatus = 'dispatched' | 'processing' | 'completed' | 'failed' | 'cancelled';
  15. interface DispatchToastJob {
  16. jobId: number;
  17. sourceName: string;
  18. printerName: string;
  19. status: DispatchJobStatus;
  20. message?: string;
  21. uploadBytes?: number;
  22. uploadTotalBytes?: number;
  23. uploadProgressPct?: number;
  24. }
  25. interface DispatchToastData {
  26. total: number;
  27. dispatched: number;
  28. processing: number;
  29. completed: number;
  30. failed: number;
  31. jobs: DispatchToastJob[];
  32. }
  33. interface ToastContextType {
  34. showToast: (message: string, type?: ToastType) => void;
  35. showPersistentToast: (id: string, message: string, type?: ToastType) => void;
  36. dismissToast: (id: string) => void;
  37. }
  38. const ToastContext = createContext<ToastContextType | undefined>(undefined);
  39. export function useToast() {
  40. const context = useContext(ToastContext);
  41. if (!context) {
  42. throw new Error('useToast must be used within a ToastProvider');
  43. }
  44. return context;
  45. }
  46. const icons = {
  47. success: <CheckCircle className="w-5 h-5 text-green-400" />,
  48. error: <XCircle className="w-5 h-5 text-red-400" />,
  49. warning: <AlertCircle className="w-5 h-5 text-yellow-400" />,
  50. info: <Info className="w-5 h-5 text-blue-400" />,
  51. loading: <Loader2 className="w-5 h-5 text-bambu-green animate-spin" />,
  52. };
  53. const bgColors = {
  54. success: 'bg-green-500/10 border-green-500/30',
  55. error: 'bg-red-500/10 border-red-500/30',
  56. warning: 'bg-yellow-500/10 border-yellow-500/30',
  57. info: 'bg-blue-500/10 border-blue-500/30',
  58. loading: 'bg-bambu-green/10 border-bambu-green/30',
  59. };
  60. export function ToastProvider({ children }: { children: ReactNode }) {
  61. const { t } = useTranslation();
  62. const [toasts, setToasts] = useState<Toast[]>([]);
  63. const [isDispatchCollapsed, setIsDispatchCollapsed] = useState(false);
  64. const [cancellingDispatchJobIds, setCancellingDispatchJobIds] = useState<Set<number>>(new Set());
  65. const timeoutRefs = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
  66. const dispatchToastId = 'background-dispatch';
  67. const lastDispatchSummaryRef = useRef<string | null>(null);
  68. // Clean up all timeouts on unmount
  69. useEffect(() => {
  70. const timeouts = timeoutRefs.current;
  71. return () => {
  72. timeouts.forEach((timeout) => clearTimeout(timeout));
  73. timeouts.clear();
  74. };
  75. }, []);
  76. const showToast = useCallback((message: string, type: ToastType = 'success') => {
  77. const id = Math.random().toString(36).substr(2, 9);
  78. setToasts((prev) => [...prev, { id, message, type }]);
  79. // Auto-dismiss after 3 seconds
  80. const timeout = setTimeout(() => {
  81. setToasts((prev) => prev.filter((t) => t.id !== id));
  82. timeoutRefs.current.delete(id);
  83. }, 3000);
  84. timeoutRefs.current.set(id, timeout);
  85. }, []);
  86. const showPersistentToast = useCallback((id: string, message: string, type: ToastType = 'info') => {
  87. setToasts((prev) => {
  88. // Update existing toast if same id, otherwise add new one
  89. const exists = prev.find((t) => t.id === id);
  90. if (exists) {
  91. return prev.map((t) => (t.id === id ? { ...t, message, type, persistent: true } : t));
  92. }
  93. return [...prev, { id, message, type, persistent: true }];
  94. });
  95. }, []);
  96. const dismissToast = useCallback((id: string) => {
  97. // Clear any pending auto-dismiss timeout
  98. const timeout = timeoutRefs.current.get(id);
  99. if (timeout) {
  100. clearTimeout(timeout);
  101. timeoutRefs.current.delete(id);
  102. }
  103. setToasts((prev) => prev.filter((t) => t.id !== id));
  104. }, []);
  105. const cancelDispatchJob = useCallback(async (jobId: number) => {
  106. setCancellingDispatchJobIds((prev) => {
  107. const next = new Set(prev);
  108. next.add(jobId);
  109. return next;
  110. });
  111. try {
  112. const result = await api.cancelBackgroundDispatchJob(jobId);
  113. showToast(
  114. result.status === 'cancelling'
  115. ? t('backgroundDispatch.toast.cancellingUpload')
  116. : t('backgroundDispatch.toast.cancelled'),
  117. 'info'
  118. );
  119. } catch (error) {
  120. const message = error instanceof Error ? error.message : t('backgroundDispatch.toast.cancelFailed');
  121. showToast(message, 'error');
  122. } finally {
  123. setCancellingDispatchJobIds((prev) => {
  124. const next = new Set(prev);
  125. next.delete(jobId);
  126. return next;
  127. });
  128. }
  129. }, [showToast, t]);
  130. useEffect(() => {
  131. interface DispatchEventDetail {
  132. total?: number;
  133. dispatched?: number;
  134. processing?: number;
  135. completed?: number;
  136. failed?: number;
  137. dispatched_jobs?: Array<{
  138. job_id: number;
  139. source_name?: string;
  140. printer_name?: string;
  141. }>;
  142. active_job?: {
  143. job_id?: number;
  144. printer_name?: string;
  145. source_name?: string;
  146. message?: string;
  147. upload_bytes?: number;
  148. upload_total_bytes?: number;
  149. upload_progress_pct?: number;
  150. } | null;
  151. active_jobs?: Array<{
  152. job_id?: number;
  153. printer_name?: string;
  154. source_name?: string;
  155. message?: string;
  156. upload_bytes?: number;
  157. upload_total_bytes?: number;
  158. upload_progress_pct?: number;
  159. }>;
  160. recent_event?: {
  161. status?: string;
  162. job_id?: number;
  163. source_name?: string;
  164. printer_name?: string;
  165. message?: string;
  166. };
  167. }
  168. const updateJob = (
  169. jobs: DispatchToastJob[],
  170. jobId: number,
  171. next: Partial<DispatchToastJob> & {
  172. status: DispatchJobStatus;
  173. sourceName: string;
  174. printerName: string;
  175. }
  176. ) => {
  177. const index = jobs.findIndex((job) => job.jobId === jobId);
  178. if (index === -1) {
  179. return [...jobs, { jobId, ...next }];
  180. }
  181. const copy = [...jobs];
  182. copy[index] = {
  183. ...copy[index],
  184. ...next,
  185. };
  186. return copy;
  187. };
  188. const statusWeight = (status: DispatchJobStatus) => {
  189. switch (status) {
  190. case 'failed':
  191. return 0;
  192. case 'processing':
  193. return 1;
  194. case 'dispatched':
  195. return 2;
  196. case 'completed':
  197. return 3;
  198. case 'cancelled':
  199. return 4;
  200. }
  201. };
  202. const onDispatchEvent = (event: Event) => {
  203. const detail = (event as CustomEvent<DispatchEventDetail>).detail || {};
  204. const total = detail.total ?? 0;
  205. const dispatched = detail.dispatched ?? 0;
  206. const processing = detail.processing ?? 0;
  207. const completed = detail.completed ?? 0;
  208. const failed = detail.failed ?? 0;
  209. const hasActiveWork = dispatched + processing > 0;
  210. const allDone = total > 0 && completed + failed >= total && !hasActiveWork;
  211. if (hasActiveWork) {
  212. setToasts((prev) => {
  213. const existing = prev.find((toastItem) => toastItem.id === dispatchToastId);
  214. const existingJobs = existing?.dispatchData?.jobs || [];
  215. const dispatchedJobs: DispatchToastJob[] = (detail.dispatched_jobs || []).map((job) => ({
  216. jobId: job.job_id,
  217. sourceName: job.source_name || t('backgroundDispatch.unknownFile'),
  218. printerName: job.printer_name || t('backgroundDispatch.unknownPrinter'),
  219. status: 'dispatched',
  220. }));
  221. const activeJobsPayload =
  222. detail.active_jobs && detail.active_jobs.length > 0
  223. ? detail.active_jobs
  224. : detail.active_job?.job_id
  225. ? [detail.active_job]
  226. : [];
  227. const activeJobs: DispatchToastJob[] = activeJobsPayload
  228. .filter((job) => typeof job.job_id === 'number')
  229. .map((job) => ({
  230. jobId: job.job_id as number,
  231. sourceName: job.source_name || t('backgroundDispatch.unknownFile'),
  232. printerName: job.printer_name || t('backgroundDispatch.unknownPrinter'),
  233. status: 'processing',
  234. message: job.message,
  235. uploadBytes: job.upload_bytes,
  236. uploadTotalBytes: job.upload_total_bytes,
  237. uploadProgressPct: job.upload_progress_pct,
  238. }));
  239. const activeIds = new Set([...dispatchedJobs, ...activeJobs].map((job) => job.jobId));
  240. const historicalJobs = existingJobs.filter(
  241. (job) => !activeIds.has(job.jobId) && ['completed', 'failed', 'cancelled'].includes(job.status)
  242. );
  243. let jobs = [...dispatchedJobs, ...activeJobs, ...historicalJobs];
  244. if (detail.recent_event?.job_id && detail.recent_event?.status) {
  245. const rawStatus = detail.recent_event.status;
  246. const eventStatus = (
  247. rawStatus === 'cancelled' ? 'cancelled' : rawStatus === 'cancelling' ? 'processing' : rawStatus
  248. ) as DispatchJobStatus;
  249. const sourceName = detail.recent_event.source_name || t('backgroundDispatch.unknownFile');
  250. const printerName = detail.recent_event.printer_name || t('backgroundDispatch.unknownPrinter');
  251. jobs = updateJob(jobs, detail.recent_event.job_id, {
  252. status: eventStatus,
  253. sourceName,
  254. printerName,
  255. message: detail.recent_event.message,
  256. });
  257. }
  258. activeJobs.forEach((activeJob) => {
  259. jobs = updateJob(jobs, activeJob.jobId, {
  260. status: 'processing',
  261. sourceName: activeJob.sourceName,
  262. printerName: activeJob.printerName,
  263. message: activeJob.message,
  264. uploadBytes: activeJob.uploadBytes,
  265. uploadTotalBytes: activeJob.uploadTotalBytes,
  266. uploadProgressPct: activeJob.uploadProgressPct,
  267. });
  268. });
  269. const dispatchData: DispatchToastData = {
  270. total,
  271. dispatched,
  272. processing,
  273. completed,
  274. failed,
  275. jobs: [...jobs].sort((a, b) => {
  276. const byStatus = statusWeight(a.status) - statusWeight(b.status);
  277. if (byStatus !== 0) {
  278. return byStatus;
  279. }
  280. return a.jobId - b.jobId;
  281. }),
  282. };
  283. const exists = prev.find((toastItem) => toastItem.id === dispatchToastId);
  284. if (exists) {
  285. return prev.map((toastItem) =>
  286. toastItem.id === dispatchToastId
  287. ? {
  288. ...toastItem,
  289. message: t('backgroundDispatch.startingPrints'),
  290. type: 'loading',
  291. persistent: true,
  292. dispatchData,
  293. }
  294. : toastItem
  295. );
  296. }
  297. return [
  298. ...prev,
  299. {
  300. id: dispatchToastId,
  301. message: t('backgroundDispatch.startingPrints'),
  302. type: 'loading',
  303. persistent: true,
  304. dispatchData,
  305. },
  306. ];
  307. });
  308. return;
  309. }
  310. const recentStatus = detail.recent_event?.status;
  311. if (!hasActiveWork && recentStatus && ['cancelled', 'failed', 'completed', 'idle'].includes(recentStatus)) {
  312. setToasts((prev) => prev.filter((t) => t.id !== dispatchToastId));
  313. }
  314. if (allDone) {
  315. const summaryKey = `${completed}:${failed}`;
  316. if (lastDispatchSummaryRef.current === summaryKey) {
  317. return;
  318. }
  319. lastDispatchSummaryRef.current = summaryKey;
  320. setToasts((prev) => prev.filter((t) => t.id !== dispatchToastId));
  321. const doneMessage = failed > 0
  322. ? t('backgroundDispatch.toast.completeWithFailures', { completed, failed })
  323. : t('backgroundDispatch.toast.completeSuccess', { completed });
  324. const id = Math.random().toString(36).substr(2, 9);
  325. setToasts((prev) => [...prev, { id, message: doneMessage, type: failed > 0 ? 'warning' : 'success' }]);
  326. const timeout = setTimeout(() => {
  327. setToasts((prev) => prev.filter((t) => t.id !== id));
  328. timeoutRefs.current.delete(id);
  329. }, 4000);
  330. timeoutRefs.current.set(id, timeout);
  331. }
  332. if (detail.recent_event?.status === 'idle' && !hasActiveWork) {
  333. setToasts((prev) => prev.filter((t) => t.id !== dispatchToastId));
  334. }
  335. if (!hasActiveWork) {
  336. setCancellingDispatchJobIds(new Set());
  337. }
  338. if (detail.dispatched_jobs) {
  339. const dispatchedIds = new Set(detail.dispatched_jobs.map((job) => job.job_id));
  340. setCancellingDispatchJobIds((prev) => {
  341. const next = new Set<number>();
  342. prev.forEach((id) => {
  343. if (dispatchedIds.has(id)) {
  344. next.add(id);
  345. }
  346. });
  347. return next;
  348. });
  349. }
  350. };
  351. window.addEventListener('background-dispatch', onDispatchEvent);
  352. return () => window.removeEventListener('background-dispatch', onDispatchEvent);
  353. }, [t]);
  354. return (
  355. <ToastContext.Provider value={{ showToast, showPersistentToast, dismissToast }}>
  356. {children}
  357. {/* Toast Container */}
  358. <div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
  359. {toasts.map((toast) => (
  360. <div
  361. key={toast.id}
  362. className={`rounded-lg border shadow-lg backdrop-blur-sm animate-slide-in ${bgColors[toast.type]} ${
  363. toast.dispatchData ? 'w-[420px] p-3' : 'flex items-center gap-3 px-4 py-3'
  364. }`}
  365. >
  366. {toast.dispatchData ? (
  367. <>
  368. <div className="flex items-start justify-between gap-3">
  369. <div className="flex items-start gap-2">
  370. {icons[toast.type]}
  371. <div>
  372. <p className="text-white text-sm font-medium">{t('backgroundDispatch.startingPrints')}</p>
  373. <p className="text-xs text-bambu-gray mt-0.5">
  374. {t('backgroundDispatch.progressSummary', {
  375. complete: toast.dispatchData.completed + toast.dispatchData.failed,
  376. total: toast.dispatchData.total,
  377. dispatched: toast.dispatchData.dispatched,
  378. processing: toast.dispatchData.processing,
  379. })}
  380. </p>
  381. </div>
  382. </div>
  383. <div className="flex items-center gap-1">
  384. <button
  385. onClick={() => setIsDispatchCollapsed((prev) => !prev)}
  386. className="text-bambu-gray hover:text-white transition-colors"
  387. aria-label={
  388. isDispatchCollapsed
  389. ? t('backgroundDispatch.expandDetails')
  390. : t('backgroundDispatch.collapseDetails')
  391. }
  392. >
  393. {isDispatchCollapsed ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
  394. </button>
  395. <button
  396. onClick={() => dismissToast(toast.id)}
  397. className="text-bambu-gray hover:text-white transition-colors"
  398. aria-label={t('backgroundDispatch.dismissToast')}
  399. >
  400. <X className="w-4 h-4" />
  401. </button>
  402. </div>
  403. </div>
  404. {!isDispatchCollapsed && (
  405. <div className="mt-3 space-y-2 max-h-64 overflow-y-auto pr-1">
  406. {toast.dispatchData.jobs.map((job) => {
  407. const progressByStatus: Record<DispatchJobStatus, number> = {
  408. dispatched: 15,
  409. processing: 60,
  410. completed: 100,
  411. failed: 100,
  412. cancelled: 100,
  413. };
  414. const barColorByStatus: Record<DispatchJobStatus, string> = {
  415. dispatched: 'bg-bambu-gray/60',
  416. processing: 'bg-bambu-green',
  417. completed: 'bg-green-500',
  418. failed: 'bg-red-500',
  419. cancelled: 'bg-yellow-500',
  420. };
  421. return (
  422. <div key={job.jobId} className="rounded border border-white/10 bg-black/15 p-2">
  423. <div className="flex items-center justify-between gap-2">
  424. <span className="text-xs text-white truncate" title={job.sourceName}>
  425. {job.sourceName}
  426. </span>
  427. <div className="flex items-center gap-2">
  428. {(job.status === 'dispatched' || job.status === 'processing') && (
  429. <button
  430. onClick={() => void cancelDispatchJob(job.jobId)}
  431. disabled={cancellingDispatchJobIds.has(job.jobId)}
  432. className="text-[11px] text-red-300 hover:text-red-200 disabled:opacity-50 disabled:cursor-not-allowed"
  433. title={t('backgroundDispatch.cancelDispatchJob')}
  434. >
  435. {cancellingDispatchJobIds.has(job.jobId)
  436. ? t('backgroundDispatch.cancelling')
  437. : t('backgroundDispatch.cancel')}
  438. </button>
  439. )}
  440. <span className="text-[11px] uppercase tracking-wide text-bambu-gray">
  441. {t(`backgroundDispatch.status.${job.status}`)}
  442. </span>
  443. </div>
  444. </div>
  445. <div className="text-[11px] text-bambu-gray truncate" title={job.printerName}>
  446. {job.printerName}
  447. </div>
  448. {job.message && (
  449. <div className="text-[11px] text-bambu-gray truncate" title={job.message}>
  450. {job.message}
  451. </div>
  452. )}
  453. {job.status === 'processing' && typeof job.uploadBytes === 'number' && typeof job.uploadTotalBytes === 'number' && job.uploadTotalBytes > 0 && (
  454. <div className="text-[11px] text-bambu-gray truncate">
  455. {formatFileSize(job.uploadBytes)} / {formatFileSize(job.uploadTotalBytes)}
  456. {typeof job.uploadProgressPct === 'number' ? ` (${job.uploadProgressPct.toFixed(1)}%)` : ''}
  457. </div>
  458. )}
  459. <div className="mt-1 h-1.5 w-full rounded bg-white/10 overflow-hidden">
  460. <div
  461. className={`h-full ${barColorByStatus[job.status]} transition-all duration-300`}
  462. style={{
  463. width: `${
  464. job.status === 'processing' && typeof job.uploadProgressPct === 'number'
  465. ? Math.max(0, Math.min(100, job.uploadProgressPct))
  466. : progressByStatus[job.status]
  467. }%`,
  468. }}
  469. />
  470. </div>
  471. </div>
  472. );
  473. })}
  474. </div>
  475. )}
  476. </>
  477. ) : (
  478. <>
  479. {icons[toast.type]}
  480. <span className="text-white text-sm">{toast.message}</span>
  481. <button
  482. onClick={() => dismissToast(toast.id)}
  483. className="ml-2 text-bambu-gray hover:text-white transition-colors"
  484. >
  485. <X className="w-4 h-4" />
  486. </button>
  487. </>
  488. )}
  489. </div>
  490. ))}
  491. </div>
  492. </ToastContext.Provider>
  493. );
  494. }