mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-05 13:41:36 +02:00
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.
263 lines
8.0 KiB
TypeScript
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>
|
|
);
|
|
}
|