VirtualPrinterList.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. import { useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  4. import { Loader2, Plus, Printer, ExternalLink, AlertTriangle, Info, FileText, ShieldCheck, Copy, Check, Download } from 'lucide-react';
  5. import { multiVirtualPrinterApi, virtualPrinterApi } from '../api/client';
  6. import { Card, CardContent } from './Card';
  7. import { Button } from './Button';
  8. import { Toggle } from './Toggle';
  9. import { useToast } from '../contexts/ToastContext';
  10. import { copyTextToClipboard, downloadTextFile } from '../utils/clipboard';
  11. import { VirtualPrinterCard } from './VirtualPrinterCard';
  12. import { VirtualPrinterAddDialog } from './VirtualPrinterAddDialog';
  13. export function VirtualPrinterList() {
  14. const { t } = useTranslation();
  15. const queryClient = useQueryClient();
  16. const { showToast } = useToast();
  17. const [showAddDialog, setShowAddDialog] = useState(false);
  18. const { data, isLoading } = useQuery({
  19. queryKey: ['virtual-printers'],
  20. queryFn: multiVirtualPrinterApi.list,
  21. refetchInterval: 10000,
  22. });
  23. const { data: globalSettings } = useQuery({
  24. queryKey: ['virtual-printer-settings'],
  25. queryFn: virtualPrinterApi.getSettings,
  26. });
  27. const archiveNameSourceMutation = useMutation({
  28. mutationFn: (source: 'metadata' | 'filename') =>
  29. virtualPrinterApi.updateSettings({ archive_name_source: source }),
  30. onSuccess: () => {
  31. queryClient.invalidateQueries({ queryKey: ['virtual-printer-settings'] });
  32. showToast(t('virtualPrinter.toast.updated'));
  33. },
  34. onError: (error: Error) => {
  35. showToast(error.message || t('virtualPrinter.toast.failedToUpdate'), 'error');
  36. },
  37. });
  38. const useFilename = globalSettings?.archive_name_source === 'filename';
  39. // Shared CA certificate — the slicer imports it once to trust every VP's
  40. // TLS connection. Generated on demand by the backend, never changes.
  41. const { data: caCert } = useQuery({
  42. queryKey: ['vp-ca-certificate'],
  43. queryFn: multiVirtualPrinterApi.getCaCertificate,
  44. staleTime: Infinity,
  45. });
  46. const [caCopied, setCaCopied] = useState(false);
  47. const handleCopyCert = async () => {
  48. if (!caCert) return;
  49. const ok = await copyTextToClipboard(caCert.pem);
  50. if (ok) {
  51. setCaCopied(true);
  52. showToast(t('virtualPrinter.caCert.copied'));
  53. setTimeout(() => setCaCopied(false), 2000);
  54. } else {
  55. showToast(t('virtualPrinter.toast.copyFailed'), 'error');
  56. }
  57. };
  58. const handleDownloadCert = () => {
  59. if (!caCert) return;
  60. downloadTextFile(caCert.pem, 'bambuddy-virtual-printer-ca.crt', 'application/x-pem-file');
  61. };
  62. if (isLoading) {
  63. return (
  64. <Card>
  65. <CardContent className="py-8 flex justify-center">
  66. <Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
  67. </CardContent>
  68. </Card>
  69. );
  70. }
  71. const printers = data?.printers || [];
  72. const models = data?.models || {};
  73. return (
  74. <div className="space-y-4">
  75. {/* Top row - Setup Required (1 col) + How it works (2 cols) */}
  76. <div className="grid grid-cols-1 lg:grid-cols-3 gap-4 items-stretch">
  77. <Card className="border-l-4 border-l-yellow-500">
  78. <CardContent className="py-3 px-4">
  79. <div className="flex items-start gap-2">
  80. <AlertTriangle className="w-4 h-4 text-yellow-500 flex-shrink-0 mt-0.5" />
  81. <div className="text-xs">
  82. <p className="text-white font-medium">{t('virtualPrinter.setupRequired.title')}</p>
  83. <p className="text-bambu-gray mt-1">{t('virtualPrinter.setupRequired.description')}</p>
  84. <a
  85. href="https://wiki.bambuddy.cool/features/virtual-printer/"
  86. target="_blank"
  87. rel="noopener noreferrer"
  88. className="inline-flex items-center gap-1.5 mt-2 px-3 py-1.5 bg-yellow-500/20 border border-yellow-500/50 rounded text-yellow-400 hover:bg-yellow-500/30 transition-colors text-xs"
  89. >
  90. <ExternalLink className="w-3 h-3" />
  91. {t('virtualPrinter.setupRequired.readGuide')}
  92. </a>
  93. </div>
  94. </div>
  95. </CardContent>
  96. </Card>
  97. <Card className="lg:col-span-2">
  98. <CardContent className="py-3 px-4">
  99. <div className="flex items-start gap-2">
  100. <Info className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
  101. <div className="text-xs text-bambu-gray">
  102. <p className="text-white font-medium mb-1">{t('virtualPrinter.howItWorks.title')}</p>
  103. <ul className="space-y-1 list-disc list-inside">
  104. <li>{t('virtualPrinter.howItWorks.step1')}</li>
  105. <li>{t('virtualPrinter.howItWorks.step2')}</li>
  106. <li>{t('virtualPrinter.howItWorks.step3')}</li>
  107. </ul>
  108. </div>
  109. </div>
  110. </CardContent>
  111. </Card>
  112. </div>
  113. {/* Global VP behavior settings — two side-by-side cards, not full width */}
  114. <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 items-stretch">
  115. {/* Slicer CA certificate — shared by every VP, imported into the
  116. slicer's trust store once instead of fetching it from the CLI. */}
  117. <Card>
  118. <CardContent className="py-3 px-4">
  119. <div className="flex items-start gap-3">
  120. <ShieldCheck className="w-4 h-4 text-bambu-green flex-shrink-0 mt-1" />
  121. <div className="flex-1 min-w-0">
  122. <div className="flex items-center justify-between gap-3">
  123. <p className="text-sm text-white font-medium">
  124. {t('virtualPrinter.caCert.title')}
  125. </p>
  126. <div className="flex items-center gap-2 flex-shrink-0">
  127. <button
  128. onClick={handleCopyCert}
  129. disabled={!caCert}
  130. className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs rounded bg-bambu-dark-secondary border border-bambu-dark-tertiary text-white hover:border-bambu-gray disabled:opacity-50 transition-colors"
  131. >
  132. {caCopied
  133. ? <Check className="w-3.5 h-3.5 text-bambu-green" />
  134. : <Copy className="w-3.5 h-3.5" />}
  135. {caCopied ? t('virtualPrinter.caCert.copied') : t('virtualPrinter.caCert.copy')}
  136. </button>
  137. <button
  138. onClick={handleDownloadCert}
  139. disabled={!caCert}
  140. className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs rounded bg-bambu-dark-secondary border border-bambu-dark-tertiary text-white hover:border-bambu-gray disabled:opacity-50 transition-colors"
  141. >
  142. <Download className="w-3.5 h-3.5" />
  143. {t('virtualPrinter.caCert.download')}
  144. </button>
  145. </div>
  146. </div>
  147. <p className="text-xs text-bambu-gray mt-1">
  148. {t('virtualPrinter.caCert.description')}
  149. </p>
  150. {caCert && (
  151. <p
  152. className="text-[10px] text-bambu-gray font-mono mt-1 truncate"
  153. title={caCert.fingerprint_sha256}
  154. >
  155. {t('virtualPrinter.caCert.fingerprint')}: {caCert.fingerprint_sha256}
  156. </p>
  157. )}
  158. </div>
  159. </div>
  160. </CardContent>
  161. </Card>
  162. {/* Archive name source */}
  163. <Card>
  164. <CardContent className="py-3 px-4">
  165. <div className="flex items-start gap-3">
  166. <FileText className="w-4 h-4 text-bambu-green flex-shrink-0 mt-1" />
  167. <div className="flex-1 min-w-0">
  168. <div className="flex items-center justify-between gap-3">
  169. <p className="text-sm text-white font-medium">
  170. {t('virtualPrinter.archiveNameSource.title')}
  171. </p>
  172. <div className="flex items-center gap-2 flex-shrink-0">
  173. <span className={`text-xs ${useFilename ? 'text-bambu-gray' : 'text-white'}`}>
  174. {t('virtualPrinter.archiveNameSource.metadata')}
  175. </span>
  176. <Toggle
  177. checked={useFilename}
  178. onChange={(checked) => archiveNameSourceMutation.mutate(checked ? 'filename' : 'metadata')}
  179. disabled={archiveNameSourceMutation.isPending}
  180. />
  181. <span className={`text-xs ${useFilename ? 'text-white' : 'text-bambu-gray'}`}>
  182. {t('virtualPrinter.archiveNameSource.filename')}
  183. </span>
  184. </div>
  185. </div>
  186. <p className="text-xs text-bambu-gray mt-1">
  187. {t('virtualPrinter.archiveNameSource.description')}
  188. </p>
  189. </div>
  190. </div>
  191. </CardContent>
  192. </Card>
  193. </div>
  194. {/* Header with add button */}
  195. <div className="flex items-center justify-between">
  196. <div className="flex items-center gap-2">
  197. <Printer className="w-5 h-5 text-bambu-green" />
  198. <h2 className="text-lg font-semibold text-white">{t('virtualPrinter.list.title')}</h2>
  199. <span className="text-sm text-bambu-gray">({printers.length})</span>
  200. </div>
  201. <Button variant="primary" onClick={() => setShowAddDialog(true)}>
  202. <Plus className="w-4 h-4 mr-1" />
  203. {t('virtualPrinter.list.add')}
  204. </Button>
  205. </div>
  206. {/* Printer cards - 3 column grid */}
  207. {printers.length === 0 ? (
  208. <Card>
  209. <CardContent className="py-8 text-center">
  210. <Printer className="w-12 h-12 text-bambu-gray mx-auto mb-3" />
  211. <p className="text-bambu-gray mb-4">{t('virtualPrinter.list.empty')}</p>
  212. <Button variant="primary" onClick={() => setShowAddDialog(true)}>
  213. <Plus className="w-4 h-4 mr-1" />
  214. {t('virtualPrinter.list.addFirst')}
  215. </Button>
  216. </CardContent>
  217. </Card>
  218. ) : (
  219. <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 items-start">
  220. {printers.map((printer) => (
  221. <VirtualPrinterCard key={printer.id} printer={printer} models={models} />
  222. ))}
  223. </div>
  224. )}
  225. {showAddDialog && (
  226. <VirtualPrinterAddDialog onClose={() => setShowAddDialog(false)} />
  227. )}
  228. </div>
  229. );
  230. }