PrinterInfoModal.tsx 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. import { useState, useEffect } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { X, Copy, Check, Signal, Cable } from 'lucide-react';
  4. import { Card, CardContent } from './Card';
  5. import { formatDateOnly } from '../utils/date';
  6. import { getPrinterImage, getWifiStrength } from '../utils/printer';
  7. import type { Printer, PrinterStatus } from '../api/client';
  8. interface PrinterInfoModalProps {
  9. printer: Printer;
  10. status?: PrinterStatus;
  11. totalPrintHours?: number;
  12. onClose: () => void;
  13. }
  14. function CopyButton({ value }: { value: string }) {
  15. const { t } = useTranslation();
  16. const [copied, setCopied] = useState(false);
  17. const handleCopy = async () => {
  18. // navigator.clipboard is gated by the secure-context requirement, so on
  19. // plain-HTTP LAN deployments (#1174) the API is undefined and the previous
  20. // code silently swallowed the failure — the icon never flipped to the tick
  21. // and nothing landed on the user's clipboard. Fall back to the legacy
  22. // execCommand path via an off-screen textarea, matching the pattern used
  23. // by CameraTokensPage's plaintext-token modal.
  24. try {
  25. if (navigator.clipboard && window.isSecureContext) {
  26. await navigator.clipboard.writeText(value);
  27. } else {
  28. const ta = document.createElement('textarea');
  29. ta.value = value;
  30. ta.style.position = 'fixed';
  31. ta.style.opacity = '0';
  32. document.body.appendChild(ta);
  33. try {
  34. ta.select();
  35. const ok = document.execCommand('copy');
  36. if (!ok) return;
  37. } finally {
  38. document.body.removeChild(ta);
  39. }
  40. }
  41. setCopied(true);
  42. setTimeout(() => setCopied(false), 2000);
  43. } catch {
  44. // Both paths failed (no clipboard API, no execCommand). Leave the icon
  45. // unchanged so the user knows nothing was copied.
  46. }
  47. };
  48. return (
  49. <button
  50. onClick={handleCopy}
  51. className="ml-2 p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
  52. title={copied ? t('printers.copied') : t('printers.copyToClipboard')}
  53. >
  54. {copied ? <Check className="w-3.5 h-3.5 text-bambu-green" /> : <Copy className="w-3.5 h-3.5" />}
  55. </button>
  56. );
  57. }
  58. export function PrinterInfoModal({ printer, status, totalPrintHours, onClose }: PrinterInfoModalProps) {
  59. const { t } = useTranslation();
  60. useEffect(() => {
  61. const handleKey = (e: KeyboardEvent) => {
  62. if (e.key === 'Escape') onClose();
  63. };
  64. window.addEventListener('keydown', handleKey);
  65. return () => window.removeEventListener('keydown', handleKey);
  66. }, [onClose]);
  67. const rows: { label: string; value: React.ReactNode }[] = [];
  68. // Model
  69. rows.push({
  70. label: t('printers.model'),
  71. value: printer.model ?? '—',
  72. });
  73. // Connection Status
  74. rows.push({
  75. label: t('common.status'),
  76. value: (
  77. <span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${
  78. status?.connected
  79. ? 'bg-bambu-green/20 text-bambu-green'
  80. : 'bg-red-500/20 text-red-400'
  81. }`}>
  82. <span className={`w-1.5 h-1.5 rounded-full ${status?.connected ? 'bg-bambu-green' : 'bg-red-400'}`} />
  83. {status?.connected ? t('printers.status.available') : t('printers.status.offline')}
  84. </span>
  85. ),
  86. });
  87. // State
  88. if (status?.state) {
  89. const stateMap: Record<string, string> = {
  90. IDLE: 'printers.status.idle',
  91. RUNNING: 'printers.status.printing',
  92. PAUSE: 'printers.status.paused',
  93. FINISH: 'printers.status.finished',
  94. FAILED: 'printers.status.error',
  95. };
  96. rows.push({
  97. label: t('printers.state'),
  98. value: t(stateMap[status.state] ?? 'printers.status.unknown'),
  99. });
  100. }
  101. // IP Address
  102. rows.push({
  103. label: t('printers.ipAddress'),
  104. value: (
  105. <span className="flex items-center">
  106. <span className="font-mono">{printer.ip_address}</span>
  107. <CopyButton value={printer.ip_address} />
  108. </span>
  109. ),
  110. });
  111. // Serial Number
  112. rows.push({
  113. label: t('printers.serialNumber'),
  114. value: (
  115. <span className="flex items-center">
  116. <span className="font-mono truncate">{printer.serial_number}</span>
  117. <CopyButton value={printer.serial_number} />
  118. </span>
  119. ),
  120. });
  121. // Network connection
  122. if (status?.wired_network) {
  123. rows.push({
  124. label: t('printers.networkLabel', 'Network'),
  125. value: (
  126. <span className="flex items-center gap-2">
  127. <Cable className="w-4 h-4 text-bambu-green" />
  128. <span className="text-bambu-green">{t('printers.connection.ethernet', 'Ethernet')}</span>
  129. </span>
  130. ),
  131. });
  132. } else if (status?.wifi_signal != null) {
  133. const wifi = getWifiStrength(status.wifi_signal);
  134. rows.push({
  135. label: t('printers.wifiSignalLabel'),
  136. value: (
  137. <span className="flex items-center gap-2">
  138. <Signal className={`w-4 h-4 ${wifi.color}`} />
  139. <span className={wifi.color}>{t(wifi.labelKey)}</span>
  140. <span className="text-bambu-gray text-xs">({status.wifi_signal} dBm)</span>
  141. </span>
  142. ),
  143. });
  144. }
  145. // Firmware
  146. rows.push({
  147. label: t('printers.firmware'),
  148. value: status?.firmware_version ?? '—',
  149. });
  150. // Developer Mode
  151. if (status?.developer_mode != null) {
  152. rows.push({
  153. label: t('printers.developerMode'),
  154. value: (
  155. <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
  156. status.developer_mode
  157. ? 'bg-bambu-green/20 text-bambu-green'
  158. : 'bg-bambu-dark-tertiary text-bambu-gray'
  159. }`}>
  160. {status.developer_mode ? t('printers.enabled') : t('printers.disabled')}
  161. </span>
  162. ),
  163. });
  164. }
  165. // Nozzle Count
  166. rows.push({
  167. label: t('printers.nozzleCount'),
  168. value: printer.nozzle_count,
  169. });
  170. // Auto-Archive
  171. rows.push({
  172. label: t('printers.autoArchive'),
  173. value: (
  174. <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
  175. printer.auto_archive
  176. ? 'bg-bambu-green/20 text-bambu-green'
  177. : 'bg-bambu-dark-tertiary text-bambu-gray'
  178. }`}>
  179. {printer.auto_archive ? t('printers.enabled') : t('printers.disabled')}
  180. </span>
  181. ),
  182. });
  183. // Total Print Hours
  184. if (totalPrintHours != null && totalPrintHours > 0) {
  185. rows.push({
  186. label: t('printers.totalPrintHours'),
  187. value: `${Math.round(totalPrintHours)}h`,
  188. });
  189. }
  190. // Location
  191. if (printer.location) {
  192. rows.push({
  193. label: t('printers.sort.location'),
  194. value: printer.location,
  195. });
  196. }
  197. // Added date
  198. rows.push({
  199. label: t('printers.addedOn'),
  200. value: formatDateOnly(printer.created_at),
  201. });
  202. return (
  203. <div
  204. className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
  205. role="dialog"
  206. aria-modal="true"
  207. onClick={onClose}
  208. >
  209. <Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
  210. <CardContent>
  211. <div className="flex items-center justify-between mb-4">
  212. <h2 className="text-lg font-semibold text-white">
  213. {printer.name}
  214. </h2>
  215. <button onClick={onClose} className="p-1 hover:bg-bambu-dark rounded flex-shrink-0">
  216. <X className="w-5 h-5 text-bambu-gray" />
  217. </button>
  218. </div>
  219. {/* Printer Image */}
  220. <div className="flex justify-center mb-4">
  221. <img
  222. src={getPrinterImage(printer.model)}
  223. alt={printer.model ?? printer.name}
  224. className="h-24 object-contain"
  225. />
  226. </div>
  227. <div className="space-y-0">
  228. {rows.map((row, i) => (
  229. <div key={i} className="flex items-center justify-between gap-4 py-2.5 border-b border-bambu-dark-tertiary last:border-0">
  230. <span className="text-sm text-bambu-gray whitespace-nowrap">{row.label}</span>
  231. <span className="text-sm text-white text-right">{row.value}</span>
  232. </div>
  233. ))}
  234. </div>
  235. </CardContent>
  236. </Card>
  237. </div>
  238. );
  239. }