VirtualPrinterSettings.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. import { useState, useEffect } from 'react';
  2. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  3. import { Loader2, Check, AlertTriangle, Printer, Eye, EyeOff, Info, ChevronDown, ExternalLink } from 'lucide-react';
  4. import { virtualPrinterApi } from '../api/client';
  5. import { Card, CardContent, CardHeader } from './Card';
  6. import { Button } from './Button';
  7. import { useToast } from '../contexts/ToastContext';
  8. export function VirtualPrinterSettings() {
  9. const queryClient = useQueryClient();
  10. const { showToast } = useToast();
  11. const [localEnabled, setLocalEnabled] = useState(false);
  12. const [localAccessCode, setLocalAccessCode] = useState('');
  13. const [localMode, setLocalMode] = useState<'immediate' | 'review' | 'print_queue'>('immediate');
  14. const [localModel, setLocalModel] = useState('3DPrinter-X1-Carbon');
  15. const [showAccessCode, setShowAccessCode] = useState(false);
  16. const [pendingAction, setPendingAction] = useState<'toggle' | 'accessCode' | 'mode' | 'model' | null>(null);
  17. // Fetch current settings
  18. const { data: settings, isLoading } = useQuery({
  19. queryKey: ['virtual-printer-settings'],
  20. queryFn: virtualPrinterApi.getSettings,
  21. refetchInterval: 10000, // Refresh every 10 seconds for status updates
  22. });
  23. // Fetch available models
  24. const { data: modelsData } = useQuery({
  25. queryKey: ['virtual-printer-models'],
  26. queryFn: virtualPrinterApi.getModels,
  27. });
  28. // Initialize local state from settings
  29. useEffect(() => {
  30. if (settings) {
  31. setLocalEnabled(settings.enabled);
  32. // Map legacy 'queue' mode to 'review'
  33. let mode: 'immediate' | 'review' | 'print_queue' = settings.mode === 'queue' ? 'review' : settings.mode;
  34. if (mode !== 'immediate' && mode !== 'review' && mode !== 'print_queue') {
  35. mode = 'immediate'; // fallback
  36. }
  37. setLocalMode(mode);
  38. setLocalModel(settings.model);
  39. }
  40. }, [settings]);
  41. // Update mutation
  42. const updateMutation = useMutation({
  43. mutationFn: (data: { enabled?: boolean; access_code?: string; mode?: 'immediate' | 'review' | 'print_queue'; model?: string }) =>
  44. virtualPrinterApi.updateSettings(data),
  45. onSuccess: () => {
  46. queryClient.invalidateQueries({ queryKey: ['virtual-printer-settings'] });
  47. showToast('Virtual printer settings updated');
  48. setPendingAction(null);
  49. },
  50. onError: (error: Error) => {
  51. showToast(error.message || 'Failed to update settings', 'error');
  52. // Revert local state on error
  53. if (settings) {
  54. setLocalEnabled(settings.enabled);
  55. // Map legacy 'queue' mode to 'review'
  56. const mode = settings.mode === 'queue' ? 'review' : settings.mode;
  57. setLocalMode(mode === 'print_queue' || mode === 'review' ? mode : 'immediate');
  58. setLocalModel(settings.model);
  59. }
  60. setPendingAction(null);
  61. },
  62. });
  63. const handleToggleEnabled = () => {
  64. const newEnabled = !localEnabled;
  65. // If enabling, must have access code
  66. if (newEnabled && !localAccessCode && !settings?.access_code_set) {
  67. showToast('Please set an access code first', 'error');
  68. return;
  69. }
  70. setLocalEnabled(newEnabled);
  71. setPendingAction('toggle');
  72. updateMutation.mutate({
  73. enabled: newEnabled,
  74. access_code: localAccessCode || undefined,
  75. mode: localMode,
  76. });
  77. };
  78. const handleAccessCodeChange = () => {
  79. if (!localAccessCode) {
  80. showToast('Access code cannot be empty', 'error');
  81. return;
  82. }
  83. if (localAccessCode.length !== 8) {
  84. showToast('Access code must be exactly 8 characters', 'error');
  85. return;
  86. }
  87. setPendingAction('accessCode');
  88. updateMutation.mutate({
  89. access_code: localAccessCode,
  90. });
  91. setLocalAccessCode(''); // Clear after saving
  92. };
  93. const handleModeChange = (mode: 'immediate' | 'review' | 'print_queue') => {
  94. setLocalMode(mode);
  95. setPendingAction('mode');
  96. updateMutation.mutate({ mode });
  97. };
  98. const handleModelChange = (model: string) => {
  99. setLocalModel(model);
  100. setPendingAction('model');
  101. updateMutation.mutate({ model });
  102. };
  103. if (isLoading) {
  104. return (
  105. <Card>
  106. <CardContent className="py-8 flex justify-center">
  107. <Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
  108. </CardContent>
  109. </Card>
  110. );
  111. }
  112. const status = settings?.status;
  113. const isRunning = status?.running || false;
  114. return (
  115. <div className="flex flex-col lg:flex-row gap-6 lg:gap-8">
  116. {/* Left Column - Settings */}
  117. <div className="space-y-6 lg:w-[480px] lg:flex-shrink-0">
  118. <Card>
  119. <CardHeader>
  120. <div className="flex items-center justify-between">
  121. <div className="flex items-center gap-2">
  122. <Printer className="w-5 h-5 text-bambu-green" />
  123. <h2 className="text-lg font-semibold text-white">Virtual Printer</h2>
  124. </div>
  125. {status && (
  126. <div className={`flex items-center gap-2 text-sm ${isRunning ? 'text-green-400' : 'text-bambu-gray'}`}>
  127. <span className={`w-2 h-2 rounded-full ${isRunning ? 'bg-green-400 animate-pulse' : 'bg-gray-500'}`} />
  128. {isRunning ? 'Running' : 'Stopped'}
  129. </div>
  130. )}
  131. </div>
  132. </CardHeader>
  133. <CardContent className="space-y-4">
  134. <p className="text-sm text-bambu-gray">
  135. Enable a virtual printer that appears in Bambu Studio and OrcaSlicer. Files sent to this printer
  136. will be archived directly without printing.
  137. </p>
  138. {/* Enable/Disable Toggle */}
  139. <div className="flex items-center justify-between py-3 border-t border-bambu-dark-tertiary">
  140. <div>
  141. <div className="text-white font-medium">Enable Virtual Printer</div>
  142. <div className="text-sm text-bambu-gray">
  143. {isRunning ? 'Visible as "Bambuddy" in slicer discovery' : 'Not visible to slicers'}
  144. </div>
  145. </div>
  146. <button
  147. onClick={handleToggleEnabled}
  148. disabled={pendingAction === 'toggle'}
  149. className={`relative w-12 h-6 rounded-full transition-colors ${
  150. localEnabled ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
  151. } ${pendingAction === 'toggle' ? 'opacity-50' : ''}`}
  152. >
  153. <span
  154. className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${
  155. localEnabled ? 'translate-x-6' : ''
  156. }`}
  157. />
  158. </button>
  159. </div>
  160. {/* Printer Model */}
  161. <div className="py-3 border-t border-bambu-dark-tertiary">
  162. <div className="text-white font-medium mb-2">Printer Model</div>
  163. <div className="text-sm text-bambu-gray mb-3">
  164. Select which printer model to emulate.
  165. </div>
  166. <div className="relative">
  167. <select
  168. value={localModel}
  169. onChange={(e) => handleModelChange(e.target.value)}
  170. disabled={pendingAction === 'model'}
  171. className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white appearance-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed pr-10"
  172. >
  173. {modelsData?.models && Object.entries(modelsData.models)
  174. .sort(([, a], [, b]) => (a as string).localeCompare(b as string))
  175. .map(([code, name]) => (
  176. <option key={code} value={code}>
  177. {name}
  178. </option>
  179. ))}
  180. </select>
  181. <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
  182. </div>
  183. {localEnabled && isRunning && (
  184. <p className="text-xs text-bambu-gray mt-2">
  185. <Info className="w-3 h-3 inline mr-1" />
  186. Changing the model will restart the virtual printer
  187. </p>
  188. )}
  189. </div>
  190. {/* Access Code */}
  191. <div className="py-3 border-t border-bambu-dark-tertiary">
  192. <div className="text-white font-medium mb-2">Access Code</div>
  193. <div className="text-sm text-bambu-gray mb-3">
  194. {settings?.access_code_set ? (
  195. <span className="flex items-center gap-1 text-green-400">
  196. <Check className="w-4 h-4" />
  197. Access code is set
  198. </span>
  199. ) : (
  200. <span className="flex items-center gap-1 text-yellow-400">
  201. <AlertTriangle className="w-4 h-4" />
  202. No access code set - required to enable
  203. </span>
  204. )}
  205. </div>
  206. <div className="flex gap-2">
  207. <div className="relative flex-1">
  208. <input
  209. type={showAccessCode ? 'text' : 'password'}
  210. value={localAccessCode}
  211. onChange={(e) => setLocalAccessCode(e.target.value)}
  212. placeholder={settings?.access_code_set ? 'Enter new code to change' : 'Enter 8-char code'}
  213. maxLength={8}
  214. className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white placeholder-bambu-gray pr-10 font-mono"
  215. />
  216. <button
  217. onClick={() => setShowAccessCode(!showAccessCode)}
  218. className="absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
  219. >
  220. {showAccessCode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
  221. </button>
  222. </div>
  223. <Button
  224. onClick={handleAccessCodeChange}
  225. disabled={!localAccessCode || pendingAction === 'accessCode'}
  226. variant="primary"
  227. >
  228. {pendingAction === 'accessCode' ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Save'}
  229. </Button>
  230. </div>
  231. <p className="text-xs text-bambu-gray mt-2">
  232. Must be exactly 8 characters. Used by slicers to authenticate.
  233. {localAccessCode && (
  234. <span className={localAccessCode.length === 8 ? 'text-green-400' : 'text-yellow-400'}>
  235. {' '}({localAccessCode.length}/8)
  236. </span>
  237. )}
  238. </p>
  239. </div>
  240. {/* Mode */}
  241. <div className="py-3 border-t border-bambu-dark-tertiary">
  242. <div className="text-white font-medium mb-2">Mode</div>
  243. <div className="grid grid-cols-3 gap-3">
  244. <button
  245. onClick={() => handleModeChange('immediate')}
  246. disabled={pendingAction === 'mode'}
  247. className={`p-3 rounded-lg border text-left transition-colors ${
  248. localMode === 'immediate'
  249. ? 'border-bambu-green bg-bambu-green/10'
  250. : 'border-bambu-dark-tertiary hover:border-bambu-gray'
  251. }`}
  252. >
  253. <div className="text-white font-medium">Archive</div>
  254. <div className="text-xs text-bambu-gray">Archive files immediately</div>
  255. </button>
  256. <button
  257. onClick={() => handleModeChange('review')}
  258. disabled={pendingAction === 'mode'}
  259. className={`p-3 rounded-lg border text-left transition-colors ${
  260. localMode === 'review'
  261. ? 'border-bambu-green bg-bambu-green/10'
  262. : 'border-bambu-dark-tertiary hover:border-bambu-gray'
  263. }`}
  264. >
  265. <div className="text-white font-medium">Review</div>
  266. <div className="text-xs text-bambu-gray">Review and tag before archiving</div>
  267. </button>
  268. <button
  269. onClick={() => handleModeChange('print_queue')}
  270. disabled={pendingAction === 'mode'}
  271. className={`p-3 rounded-lg border text-left transition-colors ${
  272. localMode === 'print_queue'
  273. ? 'border-bambu-green bg-bambu-green/10'
  274. : 'border-bambu-dark-tertiary hover:border-bambu-gray'
  275. }`}
  276. >
  277. <div className="text-white font-medium">Queue</div>
  278. <div className="text-xs text-bambu-gray">Archive and add to print queue</div>
  279. </button>
  280. </div>
  281. </div>
  282. </CardContent>
  283. </Card>
  284. </div>
  285. {/* Right Column - Info & Status */}
  286. <div className="space-y-6 lg:w-[480px] lg:flex-shrink-0">
  287. {/* Setup Required Warning */}
  288. <Card className="border-l-4 border-l-yellow-500">
  289. <CardContent className="py-4">
  290. <div className="flex items-start gap-3">
  291. <AlertTriangle className="w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5" />
  292. <div className="text-sm">
  293. <p className="text-white font-medium mb-2">
  294. Setup Required
  295. </p>
  296. <p className="text-bambu-gray mb-3">
  297. The virtual printer feature requires additional system configuration before it will work.
  298. This includes port forwarding, firewall rules, and platform-specific settings.
  299. </p>
  300. <a
  301. href="https://wiki.bambuddy.cool/features/virtual-printer/"
  302. target="_blank"
  303. rel="noopener noreferrer"
  304. className="inline-flex items-center gap-2 px-4 py-2 bg-yellow-500/20 border border-yellow-500/50 rounded-md text-yellow-400 hover:bg-yellow-500/30 transition-colors"
  305. >
  306. <ExternalLink className="w-4 h-4" />
  307. Read the setup guide before enabling
  308. </a>
  309. </div>
  310. </div>
  311. </CardContent>
  312. </Card>
  313. {/* How it works */}
  314. <Card>
  315. <CardContent className="py-4">
  316. <div className="flex items-start gap-3">
  317. <Info className="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
  318. <div className="text-sm text-bambu-gray">
  319. <p className="mb-2">
  320. <strong className="text-white">How it works:</strong>
  321. </p>
  322. <ol className="list-decimal list-inside space-y-1">
  323. <li>Complete the setup guide for your platform</li>
  324. <li>Enable the virtual printer and set an access code</li>
  325. <li>In Bambu Studio or OrcaSlicer, go to "Add Printer"</li>
  326. <li>The "Bambuddy" printer should appear in the discovery list</li>
  327. <li>Connect using the access code you set</li>
  328. <li>When you "print" to Bambuddy, the 3MF file is archived instead</li>
  329. </ol>
  330. </div>
  331. </div>
  332. </CardContent>
  333. </Card>
  334. {/* Status Details (when running) */}
  335. {status && isRunning && (
  336. <Card>
  337. <CardHeader>
  338. <h3 className="text-md font-semibold text-white">Status Details</h3>
  339. </CardHeader>
  340. <CardContent>
  341. <div className="grid grid-cols-2 gap-4 text-sm">
  342. <div>
  343. <div className="text-bambu-gray">Printer Name</div>
  344. <div className="text-white">{status.name}</div>
  345. </div>
  346. <div>
  347. <div className="text-bambu-gray">Model</div>
  348. <div className="text-white">{status.model_name || status.model}</div>
  349. </div>
  350. <div>
  351. <div className="text-bambu-gray">Serial Number</div>
  352. <div className="text-white font-mono">{status.serial}</div>
  353. </div>
  354. <div>
  355. <div className="text-bambu-gray">Mode</div>
  356. <div className="text-white capitalize">{status.mode}</div>
  357. </div>
  358. <div>
  359. <div className="text-bambu-gray">Pending Files</div>
  360. <div className="text-white">{status.pending_files}</div>
  361. </div>
  362. </div>
  363. </CardContent>
  364. </Card>
  365. )}
  366. </div>
  367. </div>
  368. );
  369. }