Files
bambuddy/frontend/src/components/PrinterInfoModal.tsx
T
maziggy 59c58362ba fix(printers): copy buttons in Printer Info modal work on plain HTTP (#1174)
PrinterInfoModal's CopyButton only tried navigator.clipboard.writeText(),
  which is gated by the secure-context requirement (HTTPS or localhost).
  On the typical Bambuddy deployment shape — bare-IP HTTP on the LAN —
  navigator.clipboard is undefined; the existing try/catch swallowed the
  TypeError, the icon never flipped to the tick, and nothing landed on
  the user's clipboard.

  Fixed by adding the same off-screen-textarea + document.execCommand('copy')
  fallback that CameraTokensPage's plaintext-token modal already uses for
  plain-HTTP LAN deployments. Gate on `navigator.clipboard && window.isSecureContext`,
  fall back to the legacy path otherwise, and surface the success-tick only
  when the copy actually landed (return early without flipping `copied` if
  execCommand returns false). The try/finally around the textarea guarantees
  DOM cleanup even when the browser throws on a restricted context.

  3 new component tests in PrinterInfoModal.test.tsx cover the secure-context
  happy path (navigator.clipboard.writeText is called with the correct value),
  the plain-HTTP fallback path (execCommand is invoked, no leaked textarea
  left in the DOM), and the finally cleanup when execCommand throws
  synthetically.
2026-05-01 08:21:37 +02:00

263 lines
8.0 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { X, Copy, Check, Signal, Cable } from 'lucide-react';
import { Card, CardContent } from './Card';
import { formatDateOnly } from '../utils/date';
import { getPrinterImage, getWifiStrength } from '../utils/printer';
import type { Printer, PrinterStatus } from '../api/client';
interface PrinterInfoModalProps {
printer: Printer;
status?: PrinterStatus;
totalPrintHours?: number;
onClose: () => void;
}
function CopyButton({ value }: { value: string }) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
// navigator.clipboard is gated by the secure-context requirement, so on
// plain-HTTP LAN deployments (#1174) the API is undefined and the previous
// code silently swallowed the failure — the icon never flipped to the tick
// and nothing landed on the user's clipboard. Fall back to the legacy
// execCommand path via an off-screen textarea, matching the pattern used
// by CameraTokensPage's plaintext-token modal.
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(value);
} else {
const ta = document.createElement('textarea');
ta.value = value;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
try {
ta.select();
const ok = document.execCommand('copy');
if (!ok) return;
} finally {
document.body.removeChild(ta);
}
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Both paths failed (no clipboard API, no execCommand). Leave the icon
// unchanged so the user knows nothing was copied.
}
};
return (
<button
onClick={handleCopy}
className="ml-2 p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
title={copied ? t('printers.copied') : t('printers.copyToClipboard')}
>
{copied ? <Check className="w-3.5 h-3.5 text-bambu-green" /> : <Copy className="w-3.5 h-3.5" />}
</button>
);
}
export function PrinterInfoModal({ printer, status, totalPrintHours, onClose }: PrinterInfoModalProps) {
const { t } = useTranslation();
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [onClose]);
const rows: { label: string; value: React.ReactNode }[] = [];
// Model
rows.push({
label: t('printers.model'),
value: printer.model ?? '—',
});
// Connection Status
rows.push({
label: t('common.status'),
value: (
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${
status?.connected
? 'bg-bambu-green/20 text-bambu-green'
: 'bg-red-500/20 text-red-400'
}`}>
<span className={`w-1.5 h-1.5 rounded-full ${status?.connected ? 'bg-bambu-green' : 'bg-red-400'}`} />
{status?.connected ? t('printers.status.available') : t('printers.status.offline')}
</span>
),
});
// State
if (status?.state) {
const stateMap: Record<string, string> = {
IDLE: 'printers.status.idle',
RUNNING: 'printers.status.printing',
PAUSE: 'printers.status.paused',
FINISH: 'printers.status.finished',
FAILED: 'printers.status.error',
};
rows.push({
label: t('printers.state'),
value: t(stateMap[status.state] ?? 'printers.status.unknown'),
});
}
// IP Address
rows.push({
label: t('printers.ipAddress'),
value: (
<span className="flex items-center">
<span className="font-mono">{printer.ip_address}</span>
<CopyButton value={printer.ip_address} />
</span>
),
});
// Serial Number
rows.push({
label: t('printers.serialNumber'),
value: (
<span className="flex items-center">
<span className="font-mono truncate">{printer.serial_number}</span>
<CopyButton value={printer.serial_number} />
</span>
),
});
// Network connection
if (status?.wired_network) {
rows.push({
label: t('printers.networkLabel', 'Network'),
value: (
<span className="flex items-center gap-2">
<Cable className="w-4 h-4 text-bambu-green" />
<span className="text-bambu-green">{t('printers.connection.ethernet', 'Ethernet')}</span>
</span>
),
});
} else if (status?.wifi_signal != null) {
const wifi = getWifiStrength(status.wifi_signal);
rows.push({
label: t('printers.wifiSignalLabel'),
value: (
<span className="flex items-center gap-2">
<Signal className={`w-4 h-4 ${wifi.color}`} />
<span className={wifi.color}>{t(wifi.labelKey)}</span>
<span className="text-bambu-gray text-xs">({status.wifi_signal} dBm)</span>
</span>
),
});
}
// Firmware
rows.push({
label: t('printers.firmware'),
value: status?.firmware_version ?? '—',
});
// Developer Mode
if (status?.developer_mode != null) {
rows.push({
label: t('printers.developerMode'),
value: (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
status.developer_mode
? 'bg-bambu-green/20 text-bambu-green'
: 'bg-bambu-dark-tertiary text-bambu-gray'
}`}>
{status.developer_mode ? t('printers.enabled') : t('printers.disabled')}
</span>
),
});
}
// Nozzle Count
rows.push({
label: t('printers.nozzleCount'),
value: printer.nozzle_count,
});
// Auto-Archive
rows.push({
label: t('printers.autoArchive'),
value: (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
printer.auto_archive
? 'bg-bambu-green/20 text-bambu-green'
: 'bg-bambu-dark-tertiary text-bambu-gray'
}`}>
{printer.auto_archive ? t('printers.enabled') : t('printers.disabled')}
</span>
),
});
// Total Print Hours
if (totalPrintHours != null && totalPrintHours > 0) {
rows.push({
label: t('printers.totalPrintHours'),
value: `${Math.round(totalPrintHours)}h`,
});
}
// Location
if (printer.location) {
rows.push({
label: t('printers.sort.location'),
value: printer.location,
});
}
// Added date
rows.push({
label: t('printers.addedOn'),
value: formatDateOnly(printer.created_at),
});
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
role="dialog"
aria-modal="true"
onClick={onClose}
>
<Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<CardContent>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white">
{printer.name}
</h2>
<button onClick={onClose} className="p-1 hover:bg-bambu-dark rounded flex-shrink-0">
<X className="w-5 h-5 text-bambu-gray" />
</button>
</div>
{/* Printer Image */}
<div className="flex justify-center mb-4">
<img
src={getPrinterImage(printer.model)}
alt={printer.model ?? printer.name}
className="h-24 object-contain"
/>
</div>
<div className="space-y-0">
{rows.map((row, i) => (
<div key={i} className="flex items-center justify-between gap-4 py-2.5 border-b border-bambu-dark-tertiary last:border-0">
<span className="text-sm text-bambu-gray whitespace-nowrap">{row.label}</span>
<span className="text-sm text-white text-right">{row.value}</span>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}