PrintersPage.tsx 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725
  1. import { useState, useEffect } from 'react';
  2. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  3. import {
  4. Plus,
  5. Wifi,
  6. WifiOff,
  7. Thermometer,
  8. Clock,
  9. MoreVertical,
  10. Trash2,
  11. RefreshCw,
  12. Box,
  13. HardDrive,
  14. AlertTriangle,
  15. Terminal,
  16. Power,
  17. PowerOff,
  18. Zap,
  19. } from 'lucide-react';
  20. import { api } from '../api/client';
  21. import type { Printer, PrinterCreate } from '../api/client';
  22. import { Card, CardContent } from '../components/Card';
  23. import { Button } from '../components/Button';
  24. import { ConfirmModal } from '../components/ConfirmModal';
  25. import { FileManagerModal } from '../components/FileManagerModal';
  26. import { MQTTDebugModal } from '../components/MQTTDebugModal';
  27. import { HMSErrorModal } from '../components/HMSErrorModal';
  28. function formatTime(seconds: number): string {
  29. const hours = Math.floor(seconds / 3600);
  30. const minutes = Math.floor((seconds % 3600) / 60);
  31. return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
  32. }
  33. function CoverImage({ url, printName }: { url: string | null; printName?: string }) {
  34. const [loaded, setLoaded] = useState(false);
  35. const [error, setError] = useState(false);
  36. const [showOverlay, setShowOverlay] = useState(false);
  37. return (
  38. <>
  39. <div
  40. className={`w-20 h-20 flex-shrink-0 rounded-lg overflow-hidden bg-bambu-dark-tertiary flex items-center justify-center ${url && loaded ? 'cursor-pointer' : ''}`}
  41. onClick={() => url && loaded && setShowOverlay(true)}
  42. >
  43. {url && !error ? (
  44. <>
  45. <img
  46. src={url}
  47. alt="Print preview"
  48. className={`w-full h-full object-cover ${loaded ? 'block' : 'hidden'}`}
  49. onLoad={() => setLoaded(true)}
  50. onError={() => setError(true)}
  51. />
  52. {!loaded && <Box className="w-8 h-8 text-bambu-gray" />}
  53. </>
  54. ) : (
  55. <Box className="w-8 h-8 text-bambu-gray" />
  56. )}
  57. </div>
  58. {/* Cover Image Overlay */}
  59. {showOverlay && url && (
  60. <div
  61. className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-8"
  62. onClick={() => setShowOverlay(false)}
  63. >
  64. <div className="relative max-w-2xl max-h-full">
  65. <img
  66. src={url}
  67. alt="Print preview"
  68. className="max-w-full max-h-[80vh] rounded-lg shadow-2xl"
  69. />
  70. {printName && (
  71. <p className="text-white text-center mt-4 text-lg">{printName}</p>
  72. )}
  73. </div>
  74. </div>
  75. )}
  76. </>
  77. );
  78. }
  79. function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIfDisconnected?: boolean }) {
  80. const queryClient = useQueryClient();
  81. const [showMenu, setShowMenu] = useState(false);
  82. const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
  83. const [showFileManager, setShowFileManager] = useState(false);
  84. const [showMQTTDebug, setShowMQTTDebug] = useState(false);
  85. const [showPowerOnConfirm, setShowPowerOnConfirm] = useState(false);
  86. const [showPowerOffConfirm, setShowPowerOffConfirm] = useState(false);
  87. const [showHMSModal, setShowHMSModal] = useState(false);
  88. const { data: status } = useQuery({
  89. queryKey: ['printerStatus', printer.id],
  90. queryFn: () => api.getPrinterStatus(printer.id),
  91. refetchInterval: 30000, // Fallback polling, WebSocket handles real-time
  92. });
  93. // Fetch smart plug for this printer
  94. const { data: smartPlug } = useQuery({
  95. queryKey: ['smartPlugByPrinter', printer.id],
  96. queryFn: () => api.getSmartPlugByPrinter(printer.id),
  97. });
  98. // Fetch smart plug status if plug exists
  99. const { data: plugStatus } = useQuery({
  100. queryKey: ['smartPlugStatus', smartPlug?.id],
  101. queryFn: () => smartPlug ? api.getSmartPlugStatus(smartPlug.id) : null,
  102. enabled: !!smartPlug,
  103. refetchInterval: 30000,
  104. });
  105. // Determine if this card should be hidden
  106. const shouldHide = hideIfDisconnected && status && !status.connected;
  107. const deleteMutation = useMutation({
  108. mutationFn: () => api.deletePrinter(printer.id),
  109. onSuccess: () => {
  110. queryClient.invalidateQueries({ queryKey: ['printers'] });
  111. },
  112. });
  113. const connectMutation = useMutation({
  114. mutationFn: () => api.connectPrinter(printer.id),
  115. onSuccess: () => {
  116. queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
  117. },
  118. });
  119. // Smart plug control mutations
  120. const powerControlMutation = useMutation({
  121. mutationFn: (action: 'on' | 'off') =>
  122. smartPlug ? api.controlSmartPlug(smartPlug.id, action) : Promise.reject('No plug'),
  123. onSuccess: () => {
  124. queryClient.invalidateQueries({ queryKey: ['smartPlugStatus', smartPlug?.id] });
  125. },
  126. });
  127. const toggleAutoOffMutation = useMutation({
  128. mutationFn: (enabled: boolean) =>
  129. smartPlug ? api.updateSmartPlug(smartPlug.id, { auto_off: enabled }) : Promise.reject('No plug'),
  130. onSuccess: () => {
  131. queryClient.invalidateQueries({ queryKey: ['smartPlugByPrinter', printer.id] });
  132. },
  133. });
  134. if (shouldHide) {
  135. return null;
  136. }
  137. return (
  138. <Card className="relative">
  139. <CardContent>
  140. {/* Header */}
  141. <div className="flex items-start justify-between mb-4">
  142. <div>
  143. <h3 className="text-lg font-semibold text-white">{printer.name}</h3>
  144. <p className="text-sm text-bambu-gray">{printer.model || 'Unknown Model'}</p>
  145. </div>
  146. <div className="flex items-center gap-2">
  147. <span
  148. className={`flex items-center gap-1.5 px-2 py-1 rounded-full text-xs ${
  149. status?.connected
  150. ? 'bg-bambu-green/20 text-bambu-green'
  151. : 'bg-red-500/20 text-red-400'
  152. }`}
  153. >
  154. {status?.connected ? (
  155. <Wifi className="w-3 h-3" />
  156. ) : (
  157. <WifiOff className="w-3 h-3" />
  158. )}
  159. {status?.connected ? 'Connected' : 'Offline'}
  160. </span>
  161. {/* HMS Status Indicator */}
  162. {status?.connected && (
  163. <button
  164. onClick={() => setShowHMSModal(true)}
  165. className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs cursor-pointer hover:opacity-80 transition-opacity ${
  166. status.hms_errors && status.hms_errors.length > 0
  167. ? status.hms_errors.some(e => e.severity <= 2)
  168. ? 'bg-red-500/20 text-red-400'
  169. : 'bg-orange-500/20 text-orange-400'
  170. : 'bg-bambu-green/20 text-bambu-green'
  171. }`}
  172. title="Click to view HMS errors"
  173. >
  174. <AlertTriangle className="w-3 h-3" />
  175. {status.hms_errors && status.hms_errors.length > 0
  176. ? status.hms_errors.length
  177. : 'OK'}
  178. </button>
  179. )}
  180. <div className="relative">
  181. <Button
  182. variant="ghost"
  183. size="sm"
  184. onClick={() => setShowMenu(!showMenu)}
  185. >
  186. <MoreVertical className="w-4 h-4" />
  187. </Button>
  188. {showMenu && (
  189. <div className="absolute right-0 mt-2 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-10">
  190. <button
  191. className="w-full px-4 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2"
  192. onClick={() => {
  193. connectMutation.mutate();
  194. setShowMenu(false);
  195. }}
  196. >
  197. <RefreshCw className="w-4 h-4" />
  198. Reconnect
  199. </button>
  200. <button
  201. className="w-full px-4 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2"
  202. onClick={() => {
  203. setShowMQTTDebug(true);
  204. setShowMenu(false);
  205. }}
  206. >
  207. <Terminal className="w-4 h-4" />
  208. MQTT Debug
  209. </button>
  210. <button
  211. className="w-full px-4 py-2 text-left text-sm text-red-400 hover:bg-bambu-dark-tertiary flex items-center gap-2"
  212. onClick={() => {
  213. setShowDeleteConfirm(true);
  214. setShowMenu(false);
  215. }}
  216. >
  217. <Trash2 className="w-4 h-4" />
  218. Delete
  219. </button>
  220. </div>
  221. )}
  222. </div>
  223. </div>
  224. </div>
  225. {/* Delete Confirmation */}
  226. {showDeleteConfirm && (
  227. <ConfirmModal
  228. title="Delete Printer"
  229. message={`Are you sure you want to delete "${printer.name}"? This will also remove all connection settings.`}
  230. confirmText="Delete"
  231. variant="danger"
  232. onConfirm={() => {
  233. deleteMutation.mutate();
  234. setShowDeleteConfirm(false);
  235. }}
  236. onCancel={() => setShowDeleteConfirm(false)}
  237. />
  238. )}
  239. {/* Status */}
  240. {status?.connected && (
  241. <>
  242. {/* Current Print or Idle Placeholder */}
  243. <div className="mb-4 p-3 bg-bambu-dark rounded-lg">
  244. <div className="flex gap-3">
  245. {/* Cover Image */}
  246. <CoverImage
  247. url={status.state === 'RUNNING' ? status.cover_url : null}
  248. printName={status.state === 'RUNNING' ? (status.subtask_name || status.current_print || undefined) : undefined}
  249. />
  250. {/* Print Info */}
  251. <div className="flex-1 min-w-0">
  252. {status.current_print && status.state === 'RUNNING' ? (
  253. <>
  254. <p className="text-sm text-bambu-gray mb-1">Printing</p>
  255. <p className="text-white text-sm mb-2 truncate">
  256. {status.subtask_name || status.current_print}
  257. </p>
  258. <div className="flex items-center justify-between text-sm">
  259. <div className="flex-1 bg-bambu-dark-tertiary rounded-full h-2 mr-3">
  260. <div
  261. className="bg-bambu-green h-2 rounded-full transition-all"
  262. style={{ width: `${status.progress || 0}%` }}
  263. />
  264. </div>
  265. <span className="text-white">{Math.round(status.progress || 0)}%</span>
  266. </div>
  267. <div className="flex items-center gap-3 mt-2 text-xs text-bambu-gray">
  268. {status.remaining_time != null && status.remaining_time > 0 && (
  269. <span className="flex items-center gap-1">
  270. <Clock className="w-3 h-3" />
  271. {formatTime(status.remaining_time * 60)}
  272. </span>
  273. )}
  274. {status.layer_num != null && status.total_layers != null && status.total_layers > 0 && (
  275. <span>
  276. Layer {status.layer_num}/{status.total_layers}
  277. </span>
  278. )}
  279. </div>
  280. </>
  281. ) : (
  282. <>
  283. <p className="text-sm text-bambu-gray mb-1">Status</p>
  284. <p className="text-white text-sm mb-2 capitalize">
  285. {status.state?.toLowerCase() || 'Idle'}
  286. </p>
  287. <div className="flex items-center justify-between text-sm">
  288. <div className="flex-1 bg-bambu-dark-tertiary rounded-full h-2 mr-3">
  289. <div className="bg-bambu-dark-tertiary h-2 rounded-full" />
  290. </div>
  291. <span className="text-bambu-gray">—</span>
  292. </div>
  293. <p className="text-xs text-bambu-gray mt-2">Ready to print</p>
  294. </>
  295. )}
  296. </div>
  297. </div>
  298. </div>
  299. {/* Temperatures */}
  300. {status.temperatures && (
  301. <div className="grid grid-cols-3 gap-3">
  302. <div className="text-center p-2 bg-bambu-dark rounded-lg">
  303. <Thermometer className="w-4 h-4 mx-auto mb-1 text-orange-400" />
  304. <p className="text-xs text-bambu-gray">Nozzle</p>
  305. <p className="text-sm text-white">
  306. {Math.round(status.temperatures.nozzle || 0)}°C
  307. </p>
  308. </div>
  309. <div className="text-center p-2 bg-bambu-dark rounded-lg">
  310. <Thermometer className="w-4 h-4 mx-auto mb-1 text-blue-400" />
  311. <p className="text-xs text-bambu-gray">Bed</p>
  312. <p className="text-sm text-white">
  313. {Math.round(status.temperatures.bed || 0)}°C
  314. </p>
  315. </div>
  316. {status.temperatures.chamber !== undefined && (
  317. <div className="text-center p-2 bg-bambu-dark rounded-lg">
  318. <Thermometer className="w-4 h-4 mx-auto mb-1 text-green-400" />
  319. <p className="text-xs text-bambu-gray">Chamber</p>
  320. <p className="text-sm text-white">
  321. {Math.round(status.temperatures.chamber || 0)}°C
  322. </p>
  323. </div>
  324. )}
  325. </div>
  326. )}
  327. </>
  328. )}
  329. {/* Smart Plug Controls */}
  330. {smartPlug && (
  331. <div className="mt-4 pt-4 border-t border-bambu-dark-tertiary">
  332. <div className="flex items-center gap-3">
  333. {/* Plug name and status */}
  334. <div className="flex items-center gap-2 min-w-0">
  335. <Zap className="w-4 h-4 text-bambu-gray flex-shrink-0" />
  336. <span className="text-sm text-white truncate">{smartPlug.name}</span>
  337. {plugStatus && (
  338. <span
  339. className={`text-xs px-1.5 py-0.5 rounded flex-shrink-0 ${
  340. plugStatus.state === 'ON'
  341. ? 'bg-bambu-green/20 text-bambu-green'
  342. : plugStatus.state === 'OFF'
  343. ? 'bg-red-500/20 text-red-400'
  344. : 'bg-bambu-gray/20 text-bambu-gray'
  345. }`}
  346. >
  347. {plugStatus.state || '?'}
  348. </span>
  349. )}
  350. </div>
  351. {/* Spacer */}
  352. <div className="flex-1" />
  353. {/* Power buttons */}
  354. <div className="flex items-center gap-1">
  355. <button
  356. onClick={() => setShowPowerOnConfirm(true)}
  357. disabled={powerControlMutation.isPending || plugStatus?.state === 'ON'}
  358. className={`px-2 py-1 text-xs rounded transition-colors flex items-center gap-1 ${
  359. plugStatus?.state === 'ON'
  360. ? 'bg-bambu-green text-white'
  361. : 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
  362. }`}
  363. >
  364. <Power className="w-3 h-3" />
  365. On
  366. </button>
  367. <button
  368. onClick={() => setShowPowerOffConfirm(true)}
  369. disabled={powerControlMutation.isPending || plugStatus?.state === 'OFF'}
  370. className={`px-2 py-1 text-xs rounded transition-colors flex items-center gap-1 ${
  371. plugStatus?.state === 'OFF'
  372. ? 'bg-red-500/30 text-red-400'
  373. : 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
  374. }`}
  375. >
  376. <PowerOff className="w-3 h-3" />
  377. Off
  378. </button>
  379. </div>
  380. {/* Auto-off toggle */}
  381. <div className="flex items-center gap-2 flex-shrink-0">
  382. <span className="text-xs text-bambu-gray hidden sm:inline">Auto-off</span>
  383. <button
  384. onClick={() => toggleAutoOffMutation.mutate(!smartPlug.auto_off)}
  385. disabled={toggleAutoOffMutation.isPending}
  386. title="Auto power-off after print"
  387. className={`relative w-9 h-5 rounded-full transition-colors flex-shrink-0 ${
  388. smartPlug.auto_off ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
  389. }`}
  390. >
  391. <span
  392. className={`absolute top-[2px] left-[2px] w-4 h-4 bg-white rounded-full transition-transform ${
  393. smartPlug.auto_off ? 'translate-x-4' : 'translate-x-0'
  394. }`}
  395. />
  396. </button>
  397. </div>
  398. </div>
  399. </div>
  400. )}
  401. {/* Connection Info & Actions */}
  402. <div className="mt-4 pt-4 border-t border-bambu-dark-tertiary flex items-center justify-between">
  403. <div className="text-xs text-bambu-gray">
  404. <p>{printer.ip_address}</p>
  405. <p className="truncate">{printer.serial_number}</p>
  406. </div>
  407. <Button
  408. variant="secondary"
  409. size="sm"
  410. onClick={() => setShowFileManager(true)}
  411. title="Browse printer files"
  412. >
  413. <HardDrive className="w-4 h-4" />
  414. Files
  415. </Button>
  416. </div>
  417. </CardContent>
  418. {/* File Manager Modal */}
  419. {showFileManager && (
  420. <FileManagerModal
  421. printerId={printer.id}
  422. printerName={printer.name}
  423. onClose={() => setShowFileManager(false)}
  424. />
  425. )}
  426. {/* MQTT Debug Modal */}
  427. {showMQTTDebug && (
  428. <MQTTDebugModal
  429. printerId={printer.id}
  430. printerName={printer.name}
  431. onClose={() => setShowMQTTDebug(false)}
  432. />
  433. )}
  434. {/* Power On Confirmation */}
  435. {showPowerOnConfirm && smartPlug && (
  436. <ConfirmModal
  437. title="Power On Printer"
  438. message={`Are you sure you want to turn ON the power for "${printer.name}"?`}
  439. confirmText="Power On"
  440. variant="default"
  441. onConfirm={() => {
  442. powerControlMutation.mutate('on');
  443. setShowPowerOnConfirm(false);
  444. }}
  445. onCancel={() => setShowPowerOnConfirm(false)}
  446. />
  447. )}
  448. {/* Power Off Confirmation */}
  449. {showPowerOffConfirm && smartPlug && (
  450. <ConfirmModal
  451. title="Power Off Printer"
  452. message={
  453. status?.state === 'RUNNING'
  454. ? `WARNING: "${printer.name}" is currently printing! Are you sure you want to turn OFF the power? This will interrupt the print and may damage the printer.`
  455. : `Are you sure you want to turn OFF the power for "${printer.name}"?`
  456. }
  457. confirmText="Power Off"
  458. variant="danger"
  459. onConfirm={() => {
  460. powerControlMutation.mutate('off');
  461. setShowPowerOffConfirm(false);
  462. }}
  463. onCancel={() => setShowPowerOffConfirm(false)}
  464. />
  465. )}
  466. {/* HMS Error Modal */}
  467. {showHMSModal && (
  468. <HMSErrorModal
  469. printerName={printer.name}
  470. errors={status?.hms_errors || []}
  471. onClose={() => setShowHMSModal(false)}
  472. />
  473. )}
  474. </Card>
  475. );
  476. }
  477. function AddPrinterModal({
  478. onClose,
  479. onAdd,
  480. }: {
  481. onClose: () => void;
  482. onAdd: (data: PrinterCreate) => void;
  483. }) {
  484. const [form, setForm] = useState<PrinterCreate>({
  485. name: '',
  486. serial_number: '',
  487. ip_address: '',
  488. access_code: '',
  489. model: '',
  490. auto_archive: true,
  491. });
  492. // Close on Escape key
  493. useEffect(() => {
  494. const handleKeyDown = (e: KeyboardEvent) => {
  495. if (e.key === 'Escape') onClose();
  496. };
  497. window.addEventListener('keydown', handleKeyDown);
  498. return () => window.removeEventListener('keydown', handleKeyDown);
  499. }, [onClose]);
  500. return (
  501. <div
  502. className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
  503. onClick={onClose}
  504. >
  505. <Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
  506. <CardContent>
  507. <h2 className="text-xl font-semibold mb-4">Add Printer</h2>
  508. <form
  509. onSubmit={(e) => {
  510. e.preventDefault();
  511. onAdd(form);
  512. }}
  513. className="space-y-4"
  514. >
  515. <div>
  516. <label className="block text-sm text-bambu-gray mb-1">Name</label>
  517. <input
  518. type="text"
  519. required
  520. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  521. value={form.name}
  522. onChange={(e) => setForm({ ...form, name: e.target.value })}
  523. placeholder="My Printer"
  524. />
  525. </div>
  526. <div>
  527. <label className="block text-sm text-bambu-gray mb-1">IP Address</label>
  528. <input
  529. type="text"
  530. required
  531. pattern="\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}"
  532. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  533. value={form.ip_address}
  534. onChange={(e) => setForm({ ...form, ip_address: e.target.value })}
  535. placeholder="192.168.1.100"
  536. />
  537. </div>
  538. <div>
  539. <label className="block text-sm text-bambu-gray mb-1">Serial Number</label>
  540. <input
  541. type="text"
  542. required
  543. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  544. value={form.serial_number}
  545. onChange={(e) => setForm({ ...form, serial_number: e.target.value })}
  546. placeholder="01P00A000000000"
  547. />
  548. </div>
  549. <div>
  550. <label className="block text-sm text-bambu-gray mb-1">Access Code</label>
  551. <input
  552. type="password"
  553. required
  554. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  555. value={form.access_code}
  556. onChange={(e) => setForm({ ...form, access_code: e.target.value })}
  557. placeholder="From printer settings"
  558. />
  559. </div>
  560. <div>
  561. <label className="block text-sm text-bambu-gray mb-1">Model (optional)</label>
  562. <select
  563. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  564. value={form.model || ''}
  565. onChange={(e) => setForm({ ...form, model: e.target.value })}
  566. >
  567. <option value="">Select model...</option>
  568. <optgroup label="H2 Series">
  569. <option value="H2C">H2C</option>
  570. <option value="H2D">H2D</option>
  571. <option value="H2S">H2S</option>
  572. </optgroup>
  573. <optgroup label="X1 Series">
  574. <option value="X1E">X1E</option>
  575. <option value="X1C">X1 Carbon</option>
  576. <option value="X1">X1</option>
  577. </optgroup>
  578. <optgroup label="P Series">
  579. <option value="P2S">P2S</option>
  580. <option value="P1S">P1S</option>
  581. <option value="P1P">P1P</option>
  582. </optgroup>
  583. <optgroup label="A1 Series">
  584. <option value="A1">A1</option>
  585. <option value="A1 Mini">A1 Mini</option>
  586. </optgroup>
  587. </select>
  588. </div>
  589. <div className="flex items-center gap-2">
  590. <input
  591. type="checkbox"
  592. id="auto_archive"
  593. checked={form.auto_archive}
  594. onChange={(e) => setForm({ ...form, auto_archive: e.target.checked })}
  595. className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
  596. />
  597. <label htmlFor="auto_archive" className="text-sm text-bambu-gray">
  598. Auto-archive completed prints
  599. </label>
  600. </div>
  601. <div className="flex gap-3 pt-4">
  602. <Button type="button" variant="secondary" onClick={onClose} className="flex-1">
  603. Cancel
  604. </Button>
  605. <Button type="submit" className="flex-1">
  606. Add Printer
  607. </Button>
  608. </div>
  609. </form>
  610. </CardContent>
  611. </Card>
  612. </div>
  613. );
  614. }
  615. export function PrintersPage() {
  616. const [showAddModal, setShowAddModal] = useState(false);
  617. const [hideDisconnected, setHideDisconnected] = useState(() => {
  618. return localStorage.getItem('hideDisconnectedPrinters') === 'true';
  619. });
  620. const queryClient = useQueryClient();
  621. const { data: printers, isLoading } = useQuery({
  622. queryKey: ['printers'],
  623. queryFn: api.getPrinters,
  624. });
  625. const addMutation = useMutation({
  626. mutationFn: api.createPrinter,
  627. onSuccess: () => {
  628. queryClient.invalidateQueries({ queryKey: ['printers'] });
  629. setShowAddModal(false);
  630. },
  631. });
  632. const toggleHideDisconnected = () => {
  633. const newValue = !hideDisconnected;
  634. setHideDisconnected(newValue);
  635. localStorage.setItem('hideDisconnectedPrinters', String(newValue));
  636. };
  637. return (
  638. <div className="p-8">
  639. <div className="flex items-center justify-between mb-8">
  640. <div>
  641. <h1 className="text-2xl font-bold text-white">Printers</h1>
  642. <p className="text-bambu-gray">Manage your Bambu Lab printers</p>
  643. </div>
  644. <div className="flex items-center gap-4">
  645. <label className="flex items-center gap-2 text-sm text-bambu-gray cursor-pointer">
  646. <input
  647. type="checkbox"
  648. checked={hideDisconnected}
  649. onChange={toggleHideDisconnected}
  650. className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
  651. />
  652. Hide offline
  653. </label>
  654. <Button onClick={() => setShowAddModal(true)}>
  655. <Plus className="w-4 h-4" />
  656. Add Printer
  657. </Button>
  658. </div>
  659. </div>
  660. {isLoading ? (
  661. <div className="text-center py-12 text-bambu-gray">Loading printers...</div>
  662. ) : printers?.length === 0 ? (
  663. <Card>
  664. <CardContent className="text-center py-12">
  665. <p className="text-bambu-gray mb-4">No printers configured yet</p>
  666. <Button onClick={() => setShowAddModal(true)}>
  667. <Plus className="w-4 h-4" />
  668. Add Your First Printer
  669. </Button>
  670. </CardContent>
  671. </Card>
  672. ) : (
  673. <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
  674. {printers?.map((printer) => (
  675. <PrinterCard key={printer.id} printer={printer} hideIfDisconnected={hideDisconnected} />
  676. ))}
  677. </div>
  678. )}
  679. {showAddModal && (
  680. <AddPrinterModal
  681. onClose={() => setShowAddModal(false)}
  682. onAdd={(data) => addMutation.mutate(data)}
  683. />
  684. )}
  685. </div>
  686. );
  687. }