ToastContext.tsx 20 KB

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