VirtualPrinterCard.tsx 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630
  1. import { useState, useEffect } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
  4. import {
  5. Loader2, Check, AlertTriangle, Eye, EyeOff, Info,
  6. ChevronDown, ChevronRight, ArrowRightLeft, Trash2, ShieldCheck, Copy,
  7. } from 'lucide-react';
  8. import { api, multiVirtualPrinterApi } from '../api/client';
  9. import type { VirtualPrinterConfig } from '../api/client';
  10. import { Card, CardContent } from './Card';
  11. import { Button } from './Button';
  12. import { ConfirmModal } from './ConfirmModal';
  13. import { useToast } from '../contexts/ToastContext';
  14. type LocalMode = 'immediate' | 'review' | 'print_queue' | 'proxy';
  15. const MODE_LABELS: Record<string, string> = {
  16. immediate: 'archive',
  17. review: 'review',
  18. print_queue: 'queue',
  19. proxy: 'proxy',
  20. };
  21. interface VirtualPrinterCardProps {
  22. printer: VirtualPrinterConfig;
  23. models: Record<string, string>;
  24. }
  25. export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps) {
  26. const { t } = useTranslation();
  27. const queryClient = useQueryClient();
  28. const { showToast } = useToast();
  29. const [expanded, setExpanded] = useState(true);
  30. const [localEnabled, setLocalEnabled] = useState(printer.enabled);
  31. const [localName, setLocalName] = useState(printer.name);
  32. const [localAccessCode, setLocalAccessCode] = useState('');
  33. const [localMode, setLocalMode] = useState<LocalMode>(
  34. (printer.mode === 'queue' ? 'review' : printer.mode) as LocalMode
  35. );
  36. const [localTargetPrinterId, setLocalTargetPrinterId] = useState<number | null>(printer.target_printer_id);
  37. const [localBindIp, setLocalBindIp] = useState(printer.bind_ip || '');
  38. const [localRemoteInterfaceIp, setLocalRemoteInterfaceIp] = useState(printer.remote_interface_ip || '');
  39. const [localModel, setLocalModel] = useState(printer.model || '');
  40. const [localAutoDispatch, setLocalAutoDispatch] = useState(printer.auto_dispatch ?? true);
  41. const [localQueueForceColorMatch, setLocalQueueForceColorMatch] = useState(printer.queue_force_color_match ?? false);
  42. const [localTailscaleDisabled, setLocalTailscaleDisabled] = useState(printer.tailscale_disabled ?? true);
  43. const [showAccessCode, setShowAccessCode] = useState(false);
  44. const [pendingAction, setPendingAction] = useState<string | null>(null);
  45. const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
  46. const [fqdnCopied, setFqdnCopied] = useState(false);
  47. // Host-level Tailscale identity (same for every VP) — shown inline on the card when
  48. // the user has marked this VP as "exposed over Tailscale". Cert handling does NOT
  49. // depend on this toggle; the slicer trusts the bambuddy CA the user imports once.
  50. const { data: tailscaleStatus } = useQuery({
  51. queryKey: ['tailscale-status'],
  52. queryFn: multiVirtualPrinterApi.getTailscaleStatus,
  53. enabled: !localTailscaleDisabled,
  54. staleTime: 60_000,
  55. });
  56. const tailscaleFqdn = tailscaleStatus?.available ? tailscaleStatus.fqdn : '';
  57. const tailscaleIp = tailscaleStatus?.available ? tailscaleStatus.tailscale_ips?.[0] ?? '' : '';
  58. const handleCopyFqdn = async (e: React.MouseEvent) => {
  59. e.stopPropagation();
  60. const fqdn = tailscaleFqdn;
  61. if (!fqdn) return;
  62. let ok = false;
  63. // Modern API — only works in secure contexts (HTTPS / localhost).
  64. if (navigator.clipboard && window.isSecureContext) {
  65. try {
  66. await navigator.clipboard.writeText(fqdn);
  67. ok = true;
  68. } catch {
  69. // fall through to legacy
  70. }
  71. }
  72. // Legacy fallback for HTTP (common when Bambuddy is reached over LAN / tailnet IP).
  73. if (!ok) {
  74. const ta = document.createElement('textarea');
  75. ta.value = fqdn;
  76. ta.style.position = 'fixed';
  77. ta.style.opacity = '0';
  78. document.body.appendChild(ta);
  79. try {
  80. ta.select();
  81. ok = document.execCommand('copy');
  82. } catch {
  83. ok = false;
  84. } finally {
  85. if (ta.parentNode) ta.parentNode.removeChild(ta);
  86. }
  87. }
  88. if (ok) {
  89. setFqdnCopied(true);
  90. showToast(t('printers.copied'));
  91. setTimeout(() => setFqdnCopied(false), 2000);
  92. } else {
  93. showToast(t('virtualPrinter.toast.copyFailed'), 'error');
  94. }
  95. };
  96. // Sync local state when props change (e.g., after backend auto-disable)
  97. useEffect(() => {
  98. if (!pendingAction) {
  99. setLocalEnabled(printer.enabled);
  100. setLocalMode((printer.mode === 'queue' ? 'review' : printer.mode) as LocalMode);
  101. setLocalName(printer.name);
  102. setLocalTargetPrinterId(printer.target_printer_id);
  103. setLocalBindIp(printer.bind_ip || '');
  104. setLocalRemoteInterfaceIp(printer.remote_interface_ip || '');
  105. setLocalModel(printer.model || '');
  106. setLocalAutoDispatch(printer.auto_dispatch ?? true);
  107. setLocalQueueForceColorMatch(printer.queue_force_color_match ?? false);
  108. setLocalTailscaleDisabled(printer.tailscale_disabled ?? true);
  109. }
  110. }, [printer, pendingAction]);
  111. // Fetch printers for dropdown
  112. const { data: printers } = useQuery({
  113. queryKey: ['printers'],
  114. queryFn: api.getPrinters,
  115. });
  116. // Fetch network interfaces
  117. const { data: networkInterfaces } = useQuery({
  118. queryKey: ['network-interfaces'],
  119. queryFn: () => api.getNetworkInterfaces().then(res => res.interfaces),
  120. });
  121. const updateMutation = useMutation({
  122. mutationFn: (data: Parameters<typeof multiVirtualPrinterApi.update>[1]) =>
  123. multiVirtualPrinterApi.update(printer.id, data),
  124. onSuccess: () => {
  125. queryClient.invalidateQueries({ queryKey: ['virtual-printers'] });
  126. showToast(t('virtualPrinter.toast.updated'));
  127. setPendingAction(null);
  128. },
  129. onError: (error: Error) => {
  130. showToast(error.message || t('virtualPrinter.toast.failedToUpdate'), 'error');
  131. setLocalEnabled(printer.enabled);
  132. setLocalMode((printer.mode === 'queue' ? 'review' : printer.mode) as LocalMode);
  133. setLocalTargetPrinterId(printer.target_printer_id);
  134. setLocalBindIp(printer.bind_ip || '');
  135. setLocalTailscaleDisabled(printer.tailscale_disabled ?? true);
  136. setPendingAction(null);
  137. },
  138. });
  139. const deleteMutation = useMutation({
  140. mutationFn: () => multiVirtualPrinterApi.remove(printer.id),
  141. onSuccess: () => {
  142. queryClient.invalidateQueries({ queryKey: ['virtual-printers'] });
  143. showToast(t('virtualPrinter.toast.deleted'));
  144. setShowDeleteConfirm(false);
  145. },
  146. onError: (error: Error) => {
  147. showToast(error.message || t('virtualPrinter.toast.failedToDelete'), 'error');
  148. setShowDeleteConfirm(false);
  149. },
  150. });
  151. const handleToggleEnabled = (e: React.MouseEvent) => {
  152. e.stopPropagation();
  153. const newEnabled = !localEnabled;
  154. if (newEnabled) {
  155. if (!localBindIp) {
  156. showToast(t('virtualPrinter.toast.bindIpRequired'), 'error');
  157. return;
  158. }
  159. if (localMode === 'proxy') {
  160. if (!localTargetPrinterId) {
  161. showToast(t('virtualPrinter.toast.targetPrinterRequired'), 'error');
  162. return;
  163. }
  164. } else {
  165. if (!localAccessCode && !printer.access_code_set) {
  166. showToast(t('virtualPrinter.toast.accessCodeRequired'), 'error');
  167. return;
  168. }
  169. }
  170. }
  171. setLocalEnabled(newEnabled);
  172. setPendingAction('toggle');
  173. updateMutation.mutate({ enabled: newEnabled });
  174. };
  175. const handleNameChange = () => {
  176. if (!localName.trim()) return;
  177. setPendingAction('name');
  178. updateMutation.mutate({ name: localName.trim() });
  179. };
  180. const handleAccessCodeChange = () => {
  181. if (!localAccessCode) {
  182. showToast(t('virtualPrinter.toast.accessCodeEmpty'), 'error');
  183. return;
  184. }
  185. if (localAccessCode.length !== 8) {
  186. showToast(t('virtualPrinter.toast.accessCodeLength'), 'error');
  187. return;
  188. }
  189. setPendingAction('accessCode');
  190. updateMutation.mutate({ access_code: localAccessCode });
  191. setLocalAccessCode('');
  192. };
  193. const handleModeChange = (mode: LocalMode) => {
  194. setLocalMode(mode);
  195. setPendingAction('mode');
  196. updateMutation.mutate({ mode });
  197. };
  198. const handleModelChange = (model: string) => {
  199. setLocalModel(model);
  200. setPendingAction('model');
  201. updateMutation.mutate({ model });
  202. };
  203. const handleTargetPrinterChange = (printerId: number) => {
  204. setLocalTargetPrinterId(printerId);
  205. setPendingAction('targetPrinter');
  206. updateMutation.mutate({ target_printer_id: printerId });
  207. };
  208. const handleRemoteInterfaceChange = (ip: string) => {
  209. setLocalRemoteInterfaceIp(ip);
  210. setPendingAction('remoteInterface');
  211. updateMutation.mutate({ remote_interface_ip: ip });
  212. };
  213. const isRunning = printer.status?.running || false;
  214. const modeLabel = t(`virtualPrinter.mode.${MODE_LABELS[localMode] || 'archive'}`);
  215. const targetPrinterName = printers?.find(p => p.id === localTargetPrinterId)?.name;
  216. return (
  217. <>
  218. <Card>
  219. {/* Collapsed header - always visible, clickable to expand */}
  220. <div
  221. className="px-4 py-3 flex items-center gap-3 cursor-pointer select-none"
  222. onClick={() => setExpanded(!expanded)}
  223. >
  224. <button className="text-bambu-gray flex-shrink-0">
  225. {expanded
  226. ? <ChevronDown className="w-4 h-4" />
  227. : <ChevronRight className="w-4 h-4" />
  228. }
  229. </button>
  230. <span className={`w-2 h-2 rounded-full flex-shrink-0 ${isRunning ? 'bg-green-400 animate-pulse' : 'bg-gray-500'}`} />
  231. <span className="text-white font-medium truncate">{printer.name}</span>
  232. <span className="text-xs text-bambu-gray flex-shrink-0">{modeLabel}</span>
  233. {printer.model_name && (
  234. <span className="text-xs text-bambu-gray flex-shrink-0">{printer.model_name}</span>
  235. )}
  236. {targetPrinterName && (
  237. <span className="text-xs text-bambu-gray flex-shrink-0 truncate">
  238. {localMode === 'proxy' && <ArrowRightLeft className="w-3 h-3 inline mr-1" />}
  239. {targetPrinterName}
  240. </span>
  241. )}
  242. {localBindIp && (
  243. <span className="text-[10px] text-bambu-gray flex-shrink-0 font-mono">{localBindIp}</span>
  244. )}
  245. {localRemoteInterfaceIp && (
  246. <span className="text-[10px] text-bambu-gray flex-shrink-0 font-mono">{localRemoteInterfaceIp}</span>
  247. )}
  248. <div className="ml-auto flex items-center gap-2 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
  249. <button
  250. onClick={handleToggleEnabled}
  251. disabled={pendingAction === 'toggle'}
  252. className={`relative w-10 h-5 rounded-full transition-colors ${
  253. localEnabled ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
  254. } ${pendingAction === 'toggle' ? 'opacity-50' : ''}`}
  255. >
  256. <span
  257. className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform ${
  258. localEnabled ? 'translate-x-5' : ''
  259. }`}
  260. />
  261. </button>
  262. </div>
  263. </div>
  264. {/* Expanded content */}
  265. {expanded && (
  266. <CardContent className="pt-0 space-y-4">
  267. <div className="border-t border-bambu-dark-tertiary" />
  268. {/* Name + delete */}
  269. <div className="flex items-center gap-2">
  270. <input
  271. type="text"
  272. value={localName}
  273. onChange={(e) => setLocalName(e.target.value)}
  274. onBlur={handleNameChange}
  275. onKeyDown={(e) => e.key === 'Enter' && handleNameChange()}
  276. className="flex-1 text-sm text-white bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 focus:border-bambu-green focus:outline-none"
  277. />
  278. <button
  279. onClick={() => setShowDeleteConfirm(true)}
  280. className="p-1.5 text-bambu-gray hover:text-red-400 transition-colors flex-shrink-0"
  281. title={t('common.delete')}
  282. >
  283. <Trash2 className="w-4 h-4" />
  284. </button>
  285. </div>
  286. {/* Tailscale identity (host-level) + serial — compact info row.
  287. Shown only when this VP is marked Tailscale-exposed AND the daemon is up. */}
  288. <div className="flex items-center gap-2 -mt-2">
  289. {tailscaleFqdn && (
  290. <span className="flex items-center gap-1 text-green-400/70 min-w-0">
  291. <ShieldCheck className="w-3.5 h-3.5 flex-shrink-0" />
  292. <span className="font-mono text-xs truncate">
  293. {tailscaleIp ? `${tailscaleIp} (${tailscaleFqdn})` : tailscaleFqdn}
  294. </span>
  295. <button
  296. onClick={handleCopyFqdn}
  297. className="p-0.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors flex-shrink-0"
  298. title={fqdnCopied ? t('printers.copied') : t('printers.copyToClipboard')}
  299. >
  300. {fqdnCopied ? (
  301. <Check className="w-3.5 h-3.5 text-bambu-green" />
  302. ) : (
  303. <Copy className="w-3.5 h-3.5" />
  304. )}
  305. </button>
  306. </span>
  307. )}
  308. <span className="text-xs text-bambu-gray font-mono ml-auto flex-shrink-0">{printer.serial}</span>
  309. </div>
  310. {/* Mode */}
  311. <div>
  312. <div className="text-white text-sm font-medium mb-2">{t('virtualPrinter.mode.title')}</div>
  313. <div className="grid grid-cols-2 gap-2">
  314. {(['immediate', 'review', 'print_queue', 'proxy'] as const).map((mode) => (
  315. <button
  316. key={mode}
  317. onClick={() => handleModeChange(mode)}
  318. disabled={pendingAction === 'mode'}
  319. className={`p-2 rounded-lg border text-left transition-colors ${
  320. localMode === mode
  321. ? mode === 'proxy'
  322. ? 'border-blue-500 bg-blue-500/10'
  323. : 'border-bambu-green bg-bambu-green/10'
  324. : 'border-bambu-dark-tertiary hover:border-bambu-gray'
  325. }`}
  326. >
  327. <div className="flex items-center gap-1.5 text-white text-xs font-medium">
  328. {mode === 'proxy' && <ArrowRightLeft className="w-3 h-3" />}
  329. {t(`virtualPrinter.mode.${MODE_LABELS[mode]}`)}
  330. </div>
  331. <div className="text-[10px] text-bambu-gray">
  332. {t(`virtualPrinter.mode.${MODE_LABELS[mode]}Desc`)}
  333. </div>
  334. </button>
  335. ))}
  336. </div>
  337. </div>
  338. {/* Auto-dispatch toggle - only for print_queue mode */}
  339. {localMode === 'print_queue' && (
  340. <div className="pt-2 border-t border-bambu-dark-tertiary">
  341. <div className="flex items-center justify-between gap-3">
  342. <div className="min-w-0">
  343. <div className="text-white text-sm font-medium">{t('virtualPrinter.autoDispatch.title')}</div>
  344. <div className="text-[10px] text-bambu-gray">{t('virtualPrinter.autoDispatch.description')}</div>
  345. </div>
  346. <button
  347. onClick={() => {
  348. const newVal = !localAutoDispatch;
  349. setLocalAutoDispatch(newVal);
  350. setPendingAction('autoDispatch');
  351. updateMutation.mutate({ auto_dispatch: newVal });
  352. }}
  353. disabled={pendingAction === 'autoDispatch'}
  354. className={`relative w-10 h-5 rounded-full transition-colors flex-shrink-0 ${
  355. localAutoDispatch ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
  356. } ${pendingAction === 'autoDispatch' ? 'opacity-50' : ''}`}
  357. >
  358. <span
  359. className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform ${
  360. localAutoDispatch ? 'translate-x-5' : ''
  361. }`}
  362. />
  363. </button>
  364. </div>
  365. </div>
  366. )}
  367. {/* Force-color-match toggle - only for print_queue mode (#1188) */}
  368. {localMode === 'print_queue' && (
  369. <div className="pt-2 border-t border-bambu-dark-tertiary">
  370. <div className="flex items-center justify-between gap-3">
  371. <div className="min-w-0">
  372. <div className="text-white text-sm font-medium">{t('virtualPrinter.queueForceColorMatch.title')}</div>
  373. <div className="text-[10px] text-bambu-gray">{t('virtualPrinter.queueForceColorMatch.description')}</div>
  374. </div>
  375. <button
  376. onClick={() => {
  377. const newVal = !localQueueForceColorMatch;
  378. setLocalQueueForceColorMatch(newVal);
  379. setPendingAction('queueForceColorMatch');
  380. updateMutation.mutate({ queue_force_color_match: newVal });
  381. }}
  382. disabled={pendingAction === 'queueForceColorMatch'}
  383. className={`relative w-10 h-5 rounded-full transition-colors flex-shrink-0 ${
  384. localQueueForceColorMatch ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
  385. } ${pendingAction === 'queueForceColorMatch' ? 'opacity-50' : ''}`}
  386. >
  387. <span
  388. className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform ${
  389. localQueueForceColorMatch ? 'translate-x-5' : ''
  390. }`}
  391. />
  392. </button>
  393. </div>
  394. </div>
  395. )}
  396. {/* Tailscale toggle */}
  397. <div className="pt-2 border-t border-bambu-dark-tertiary">
  398. <div className="flex items-center justify-between gap-3">
  399. <div className="min-w-0">
  400. <div className="text-white text-sm font-medium">{t('virtualPrinter.tailscaleDisabled.title')}</div>
  401. <div className="text-[10px] text-bambu-gray">{t('virtualPrinter.tailscaleDisabled.description')}</div>
  402. </div>
  403. <button
  404. onClick={() => {
  405. const newVal = !localTailscaleDisabled;
  406. setLocalTailscaleDisabled(newVal);
  407. setPendingAction('tailscaleDisabled');
  408. updateMutation.mutate({ tailscale_disabled: newVal });
  409. }}
  410. disabled={pendingAction === 'tailscaleDisabled'}
  411. className={`relative w-10 h-5 rounded-full transition-colors shrink-0 ${
  412. !localTailscaleDisabled ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
  413. } ${pendingAction === 'tailscaleDisabled' ? 'opacity-50' : ''}`}
  414. >
  415. <span
  416. className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform ${
  417. !localTailscaleDisabled ? 'translate-x-5' : ''
  418. }`}
  419. />
  420. </button>
  421. </div>
  422. </div>
  423. {/* Printer Model - for non-proxy modes */}
  424. {localMode !== 'proxy' && (
  425. <div className="pt-2 border-t border-bambu-dark-tertiary">
  426. <div className="text-white text-sm font-medium mb-1">{t('virtualPrinter.model.title')}</div>
  427. <p className="text-xs text-bambu-gray mb-2">{t('virtualPrinter.model.description')}</p>
  428. <div className="relative">
  429. <select
  430. value={localModel}
  431. onChange={(e) => handleModelChange(e.target.value)}
  432. disabled={pendingAction === 'model'}
  433. className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-white text-sm appearance-none cursor-pointer disabled:opacity-50 pr-10"
  434. >
  435. {Object.entries(models).map(([code, name]) => (
  436. <option key={code} value={code}>{name} ({code})</option>
  437. ))}
  438. </select>
  439. <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
  440. </div>
  441. </div>
  442. )}
  443. {/* Proxy mode: hint about using target printer's access code */}
  444. {localMode === 'proxy' && (
  445. <div className="pt-2 border-t border-bambu-dark-tertiary">
  446. <div className="flex items-start gap-2 p-2 rounded bg-blue-500/10 border border-blue-500/30">
  447. <Info className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
  448. <p className="text-xs text-bambu-gray">
  449. {t('virtualPrinter.proxy.accessCodeHint')}
  450. </p>
  451. </div>
  452. </div>
  453. )}
  454. {/* Access Code - only for non-proxy modes */}
  455. {localMode !== 'proxy' && (
  456. <div className="pt-2 border-t border-bambu-dark-tertiary">
  457. <div className="flex items-center gap-2 mb-2">
  458. <div className="text-white text-sm font-medium">{t('virtualPrinter.accessCode.title')}</div>
  459. {printer.access_code_set ? (
  460. <span className="flex items-center gap-1 text-xs text-green-400">
  461. <Check className="w-3 h-3" />
  462. {t('virtualPrinter.accessCode.isSet')}
  463. </span>
  464. ) : (
  465. <span className="flex items-center gap-1 text-xs text-yellow-400">
  466. <AlertTriangle className="w-3 h-3" />
  467. {t('virtualPrinter.accessCode.notSet')}
  468. </span>
  469. )}
  470. </div>
  471. <div className="flex gap-2">
  472. <div className="relative flex-1">
  473. <input
  474. type={showAccessCode ? 'text' : 'password'}
  475. value={localAccessCode}
  476. onChange={(e) => setLocalAccessCode(e.target.value)}
  477. placeholder={printer.access_code_set ? t('virtualPrinter.accessCode.placeholderChange') : t('virtualPrinter.accessCode.placeholder')}
  478. maxLength={8}
  479. className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-white text-sm placeholder-bambu-gray pr-10 font-mono"
  480. />
  481. <button
  482. onClick={() => setShowAccessCode(!showAccessCode)}
  483. className="absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
  484. >
  485. {showAccessCode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
  486. </button>
  487. </div>
  488. <Button
  489. onClick={handleAccessCodeChange}
  490. disabled={!localAccessCode || pendingAction === 'accessCode'}
  491. variant="primary"
  492. >
  493. {pendingAction === 'accessCode' ? <Loader2 className="w-4 h-4 animate-spin" /> : t('common.save')}
  494. </Button>
  495. </div>
  496. {localAccessCode && (
  497. <p className="text-xs text-bambu-gray mt-1">
  498. <span className={localAccessCode.length === 8 ? 'text-green-400' : 'text-yellow-400'}>
  499. {t('virtualPrinter.accessCode.charCount', { count: localAccessCode.length })}
  500. </span>
  501. </p>
  502. )}
  503. </div>
  504. )}
  505. {/* Target Printer */}
  506. <div className="pt-2 border-t border-bambu-dark-tertiary">
  507. <div className="text-white text-sm font-medium mb-2">{t('virtualPrinter.targetPrinter.title')}</div>
  508. <div className="relative">
  509. <select
  510. value={localTargetPrinterId ?? ''}
  511. onChange={(e) => {
  512. const id = parseInt(e.target.value, 10);
  513. if (!isNaN(id)) handleTargetPrinterChange(id);
  514. }}
  515. disabled={pendingAction === 'targetPrinter'}
  516. className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-white text-sm appearance-none cursor-pointer disabled:opacity-50 pr-10"
  517. >
  518. <option value="">{t('virtualPrinter.targetPrinter.placeholder')}</option>
  519. {printers?.map((p) => (
  520. <option key={p.id} value={p.id}>{p.name} ({p.ip_address})</option>
  521. ))}
  522. </select>
  523. <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
  524. </div>
  525. </div>
  526. {/* Bind Interface */}
  527. <div className="pt-2 border-t border-bambu-dark-tertiary">
  528. <div className="text-white text-sm font-medium mb-1">{t('virtualPrinter.bindIp.title')}</div>
  529. <div className="relative">
  530. <select
  531. value={localBindIp}
  532. onChange={(e) => {
  533. setLocalBindIp(e.target.value);
  534. setPendingAction('bindIp');
  535. updateMutation.mutate({ bind_ip: e.target.value });
  536. }}
  537. disabled={pendingAction === 'bindIp'}
  538. className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-white text-sm appearance-none cursor-pointer disabled:opacity-50 pr-10"
  539. >
  540. <option value="">{t('virtualPrinter.bindIp.placeholder')}</option>
  541. {networkInterfaces?.map((iface) => (
  542. <option key={iface.ip} value={iface.ip}>
  543. {iface.name} ({iface.ip}){iface.is_alias ? ' [alias]' : ''} - {iface.subnet}
  544. </option>
  545. ))}
  546. </select>
  547. <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
  548. </div>
  549. <p className="text-xs text-bambu-gray mt-1">{t('virtualPrinter.bindIp.hint')}</p>
  550. </div>
  551. {/* Remote Interface - always visible for configuration */}
  552. <div className="pt-2 border-t border-bambu-dark-tertiary">
  553. <div className="flex items-center gap-2 mb-1">
  554. <div className="text-white text-sm font-medium">{t('virtualPrinter.remoteInterface.title')}</div>
  555. {localRemoteInterfaceIp ? (
  556. <span className="flex items-center gap-1 text-xs text-green-400"><Check className="w-3 h-3" /></span>
  557. ) : (
  558. <span className="flex items-center gap-1 text-xs text-bambu-gray" title={t('virtualPrinter.remoteInterface.optional')}><Info className="w-3 h-3" /></span>
  559. )}
  560. </div>
  561. <div className="relative">
  562. <select
  563. value={localRemoteInterfaceIp}
  564. onChange={(e) => handleRemoteInterfaceChange(e.target.value)}
  565. disabled={pendingAction === 'remoteInterface'}
  566. className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-white text-sm appearance-none cursor-pointer disabled:opacity-50 pr-10"
  567. >
  568. <option value="">{t('virtualPrinter.remoteInterface.placeholder')}</option>
  569. {networkInterfaces?.map((iface) => (
  570. <option key={iface.ip} value={iface.ip}>
  571. {iface.name} ({iface.ip}) - {iface.subnet}
  572. </option>
  573. ))}
  574. </select>
  575. <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
  576. </div>
  577. </div>
  578. </CardContent>
  579. )}
  580. </Card>
  581. {showDeleteConfirm && (
  582. <ConfirmModal
  583. title={t('virtualPrinter.deleteConfirm.title')}
  584. message={t('virtualPrinter.deleteConfirm.message', { name: printer.name })}
  585. variant="danger"
  586. confirmText={t('common.delete')}
  587. isLoading={deleteMutation.isPending}
  588. onConfirm={() => deleteMutation.mutate()}
  589. onCancel={() => setShowDeleteConfirm(false)}
  590. />
  591. )}
  592. </>
  593. );
  594. }