[Feature]: Printer Page - Add a print button & Drop zone on the printer card (#569)

* Add printer card print flow with drag-drop and reusable LibraryUploadModal

Extract inline upload modal from FileManagerPage into a reusable
LibraryUploadModal component. Add a Print button and drag-drop zone
to printer cards that upload files to the library and open PrintModal
with the printer pre-selected. Only .gcode/.gcode.3mf files are
accepted for printing. PrintModal hides printer selector when a
printer is provided via initialSelectedPrinterIds prop.

* Add printer compatibility check for drag-drop and upload-to-print flows

Validates sliced_for_model from file metadata against the target printer model
before opening the print modal, preventing users from printing with incompatible files.

* Improve FileUploadModal: auto-close on success, inline errors, file validation, and i18n

- Close modal automatically after successful upload instead of showing summary
- Show printer compatibility errors inline in the modal instead of closing
- Add validateFile and accept props to restrict file types for print flow
- Add onFileUploaded error return to prevent modal close on incompatible files
- Internationalize all hardcoded error/warning strings across 6 locales

* Clean up incompatible files from library and fix print button i18n

- Delete uploaded file from library when printer compatibility check fails
  in both drag-drop and modal upload flows
- Fix print button tooltip to use existing common.print i18n key
- Fix accept attribute to use .gcode,.3mf for proper browser support

* Add printer information modal accessible from card 3-dot menu

Replace inline IP/serial display at bottom of printer card with a
dedicated "Printer Information" modal opened via the card menu. The
modal shows model, status, state, IP address (copyable), serial number
(copyable), WiFi signal, firmware, developer mode, nozzle count, SD
card, auto-archive, total print hours, location, and added date, along
with the printer image. Includes full i18n support across all 6 locales
and accessibility attributes (role="dialog", aria-modal).

* Extract getPrinterImage and getWifiStrength into shared printer utils

---------

Co-authored-by: MartinNYHC <mz@v8w.de>
This commit is contained in:
AneoPsy
2026-03-03 10:26:30 +01:00
committed by GitHub
co-authored by MartinNYHC
parent 0918c57928
commit fa9d3779cc
18 changed files with 10417 additions and 414 deletions
+352
View File
@@ -0,0 +1,352 @@
import { useState, useRef, type DragEvent } from 'react';
import { useTranslation } from 'react-i18next';
import {
Upload,
X,
File,
Loader2,
CheckCircle,
XCircle,
Archive as ArchiveIcon,
Printer,
Image,
} from 'lucide-react';
import { api } from '../api/client';
import type { LibraryFileUploadResponse } from '../api/client';
import { Button } from './Button';
interface UploadFile {
file: File;
status: 'pending' | 'uploading' | 'success' | 'error';
error?: string;
isZip?: boolean;
is3mf?: boolean;
extractedCount?: number;
}
interface FileUploadModalProps {
folderId: number | null;
onClose: () => void;
onUploadComplete: () => void;
/** Called after each file is successfully uploaded with its response data. Return a string to show an error and prevent modal from closing. */
onFileUploaded?: (file: LibraryFileUploadResponse) => string | void;
/** When true, automatically uploads the file as soon as it's added and closes the modal */
autoUpload?: boolean;
/** Validate files before adding. Return a string to reject with an error message. */
validateFile?: (file: File) => string | undefined;
/** Restrict file picker to specific file types (e.g. ".gcode,.gcode.3mf") */
accept?: string;
}
export function FileUploadModal({ folderId, onClose, onUploadComplete, onFileUploaded, autoUpload, validateFile, accept }: FileUploadModalProps) {
const { t } = useTranslation();
const [files, setFiles] = useState<UploadFile[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [preserveZipStructure, setPreserveZipStructure] = useState(true);
const [createFolderFromZip, setCreateFolderFromZip] = useState(false);
const [generateStlThumbnails, setGenerateStlThumbnails] = useState(true);
const [uploadError, setUploadError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
addFiles(Array.from(e.dataTransfer.files));
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
addFiles(Array.from(e.target.files));
}
};
const updateFileStatus = (file: File, update: Partial<UploadFile>) => {
setFiles((prev) => prev.map((f) => (f.file === file ? { ...f, ...update } : f)));
};
const uploadFiles = async (filesToUpload: UploadFile[]) => {
setIsUploading(true);
for (const uf of filesToUpload) {
if (uf.status !== 'pending') continue;
updateFileStatus(uf.file, { status: 'uploading' });
try {
if (uf.isZip) {
const result = await api.extractZipFile(uf.file, folderId, preserveZipStructure, createFolderFromZip, generateStlThumbnails);
updateFileStatus(uf.file, {
status: result.errors.length > 0 && result.extracted === 0 ? 'error' : 'success',
extractedCount: result.extracted,
error: result.errors.length > 0 ? t('fileManager.zipFilesFailed', '{{count}} files failed', { count: result.errors.length }) : undefined,
});
} else {
const result = await api.uploadLibraryFile(uf.file, folderId, generateStlThumbnails);
updateFileStatus(uf.file, { status: 'success' });
const error = onFileUploaded?.(result);
if (error) {
setUploadError(error);
setFiles([]);
setIsUploading(false);
return;
}
}
} catch (err) {
updateFileStatus(uf.file, {
status: 'error',
error: err instanceof Error ? err.message : t('fileManager.uploadFailed', 'Upload failed'),
});
}
}
setIsUploading(false);
onUploadComplete();
onClose();
};
const addFiles = (newFiles: File[]) => {
setUploadError(null);
if (validateFile) {
for (const file of newFiles) {
const error = validateFile(file);
if (error) {
setUploadError(error);
return;
}
}
}
const toUpload: UploadFile[] = newFiles.map((file) => ({
file,
status: 'pending' as const,
isZip: file.name.toLowerCase().endsWith('.zip'),
is3mf: file.name.toLowerCase().endsWith('.3mf'),
}));
setFiles((prev) => [...prev, ...toUpload]);
if (autoUpload && newFiles.length > 0) {
uploadFiles(toUpload);
}
};
const removeFile = (index: number) => {
setFiles((prev) => prev.filter((_, i) => i !== index));
};
const hasZipFiles = files.some((f) => f.isZip && f.status === 'pending');
const hasStlFiles = files.some((f) => f.file.name.toLowerCase().endsWith('.stl') && f.status === 'pending');
const has3mfFiles = files.some((f) => f.is3mf && f.status === 'pending');
const pendingCount = files.filter((f) => f.status === 'pending').length;
const allDone = files.length > 0 && pendingCount === 0 && !isUploading;
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-bambu-dark-secondary rounded-lg w-full max-w-lg border border-bambu-dark-tertiary">
<div className="p-4 border-b border-bambu-dark-tertiary flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">{t('fileManager.uploadFiles')}</h2>
<button onClick={onClose} className="p-1 hover:bg-bambu-dark rounded">
<X className="w-5 h-5 text-bambu-gray" />
</button>
</div>
<div className="p-4 space-y-4">
{/* Drop Zone */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
isDragging
? 'border-bambu-green bg-bambu-green/10'
: 'border-bambu-dark-tertiary hover:border-bambu-green/50'
}`}
>
<Upload className={`w-10 h-10 mx-auto mb-3 ${isDragging ? 'text-bambu-green' : 'text-bambu-gray'}`} />
<p className="text-white font-medium">
{isDragging ? t('fileManager.dropFilesHere') : t('fileManager.dragDropFiles')}
</p>
<p className="text-sm text-bambu-gray mt-1">{t('fileManager.orClickToBrowse')}</p>
<p className="text-xs text-bambu-gray/70 mt-2">{t('fileManager.allFileTypesSupported')}</p>
</div>
<input
ref={fileInputRef}
type="file"
multiple
accept={accept}
className="hidden"
onChange={handleFileSelect}
/>
{/* ZIP Options */}
{hasZipFiles && (
<div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
<div className="flex items-start gap-3">
<ArchiveIcon className="w-5 h-5 text-blue-400 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm text-blue-300 font-medium">{t('fileManager.zipFilesDetected')}</p>
<p className="text-xs text-blue-300/70 mt-1">
{t('fileManager.zipExtractOptions')}
</p>
<label className="flex items-center gap-2 mt-2 cursor-pointer">
<input
type="checkbox"
checked={preserveZipStructure}
onChange={(e) => setPreserveZipStructure(e.target.checked)}
className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
<span className="text-sm text-white">{t('fileManager.preserveZipStructure')}</span>
</label>
<label className="flex items-center gap-2 mt-2 cursor-pointer">
<input
type="checkbox"
checked={createFolderFromZip}
onChange={(e) => setCreateFolderFromZip(e.target.checked)}
className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
<span className="text-sm text-white">{t('fileManager.createFolderFromZip')}</span>
</label>
</div>
</div>
</div>
)}
{/* 3MF File Info */}
{has3mfFiles && (
<div className="p-3 bg-purple-500/10 border border-purple-500/30 rounded-lg">
<div className="flex items-start gap-3">
<Printer className="w-5 h-5 text-purple-400 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm text-purple-300 font-medium">{t('fileManager.threemfDetected')}</p>
<p className="text-xs text-purple-300/70 mt-1">
{t('fileManager.threemfExtractionInfo')}
</p>
</div>
</div>
</div>
)}
{/* STL Thumbnail Options */}
{(hasStlFiles || hasZipFiles) && (
<div className="p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg">
<div className="flex items-start gap-3">
<Image className="w-5 h-5 text-bambu-green mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm text-bambu-green font-medium">{t('fileManager.stlThumbnailGeneration')}</p>
<p className="text-xs text-bambu-green/70 mt-1">
{hasZipFiles && !hasStlFiles
? t('fileManager.zipMayContainStl')
: t('fileManager.thumbnailsCanBeGenerated')}
</p>
<label className="flex items-center gap-2 mt-2 cursor-pointer">
<input
type="checkbox"
checked={generateStlThumbnails}
onChange={(e) => setGenerateStlThumbnails(e.target.checked)}
className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
<span className="text-sm text-white">{t('fileManager.generateThumbnailsForStl')}</span>
</label>
</div>
</div>
</div>
)}
{/* File List */}
{files.length > 0 && (
<div className="max-h-48 overflow-y-auto space-y-2">
{files.map((uploadFile, index) => (
<div
key={index}
className="flex items-center gap-3 p-2 bg-bambu-dark rounded-lg"
>
{uploadFile.isZip ? (
<ArchiveIcon className="w-4 h-4 text-blue-400 flex-shrink-0" />
) : (
<File className="w-4 h-4 text-bambu-gray flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{uploadFile.file.name}</p>
<p className="text-xs text-bambu-gray">
{(uploadFile.file.size / 1024 / 1024).toFixed(2)} MB
{uploadFile.isZip && uploadFile.status === 'pending' && (
<span className="text-blue-400 ml-2">• {t('fileManager.willBeExtracted')}</span>
)}
{uploadFile.extractedCount !== undefined && (
<span className="text-green-400 ml-2">• {t('fileManager.filesExtracted', { count: uploadFile.extractedCount })}</span>
)}
</p>
</div>
{uploadFile.status === 'pending' && (
<button
onClick={() => removeFile(index)}
className="p-1 hover:bg-bambu-dark-tertiary rounded"
>
<X className="w-4 h-4 text-bambu-gray" />
</button>
)}
{uploadFile.status === 'uploading' && (
<Loader2 className="w-4 h-4 text-bambu-green animate-spin" />
)}
{uploadFile.status === 'success' && (
<CheckCircle className="w-4 h-4 text-green-500" />
)}
{uploadFile.status === 'error' && (
<span title={uploadFile.error}>
<XCircle className="w-4 h-4 text-red-500" />
</span>
)}
</div>
))}
</div>
)}
{/* Compatibility Error */}
{uploadError && (
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<div className="flex items-start gap-3">
<XCircle className="w-5 h-5 text-red-400 mt-0.5 flex-shrink-0" />
<p className="text-sm text-red-300">{uploadError}</p>
</div>
</div>
)}
</div>
<div className="p-4 border-t border-bambu-dark-tertiary flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>
{t('common.cancel')}
</Button>
{!allDone && (
<Button
onClick={() => uploadFiles(files)}
disabled={pendingCount === 0 || isUploading}
>
{isUploading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{t('fileManager.uploading')}
</>
) : (
<>
<Upload className="w-4 h-4 mr-2" />
{t('common.upload')} {pendingCount > 0 ? `(${pendingCount})` : ''}
</>
)}
</Button>
)}
</div>
</div>
</div>
);
}
+28 -22
View File
@@ -43,6 +43,7 @@ export function PrintModal({
libraryFileId,
archiveName,
queueItem,
initialSelectedPrinterIds,
onClose,
onSuccess,
}: PrintModalProps) {
@@ -60,6 +61,9 @@ export function PrintModal({
if (mode === 'edit-queue-item' && queueItem?.printer_id) {
return [queueItem.printer_id];
}
if (initialSelectedPrinterIds?.length) {
return initialSelectedPrinterIds;
}
return [];
});
@@ -695,26 +699,28 @@ export function PrintModal({
onSelect={setSelectedPlate}
/>
{/* Printer selection with per-printer mapping */}
<PrinterSelector
printers={printers || []}
selectedPrinterIds={selectedPrinters}
onMultiSelect={setSelectedPrinters}
isLoading={loadingPrinters}
allowMultiple={true}
showInactive={mode === 'edit-queue-item'}
printerMappingResults={multiPrinterMapping.printerResults}
filamentReqs={effectiveFilamentReqs}
onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig}
assignmentMode={mode === 'reprint' ? 'printer' : assignmentMode}
onAssignmentModeChange={mode !== 'reprint' ? setAssignmentMode : undefined}
targetModel={targetModel}
onTargetModelChange={mode !== 'reprint' ? setTargetModel : undefined}
targetLocation={targetLocation}
onTargetLocationChange={mode !== 'reprint' ? setTargetLocation : undefined}
slicedForModel={slicedForModel}
/>
{/* Printer selection with per-printer mapping — hidden when printer is pre-selected via props */}
{!initialSelectedPrinterIds?.length && (
<PrinterSelector
printers={printers || []}
selectedPrinterIds={selectedPrinters}
onMultiSelect={setSelectedPrinters}
isLoading={loadingPrinters}
allowMultiple={true}
showInactive={mode === 'edit-queue-item'}
printerMappingResults={multiPrinterMapping.printerResults}
filamentReqs={effectiveFilamentReqs}
onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig}
assignmentMode={mode === 'reprint' ? 'printer' : assignmentMode}
onAssignmentModeChange={mode !== 'reprint' ? setAssignmentMode : undefined}
targetModel={targetModel}
onTargetModelChange={mode !== 'reprint' ? setTargetModel : undefined}
targetLocation={targetLocation}
onTargetLocationChange={mode !== 'reprint' ? setTargetLocation : undefined}
slicedForModel={slicedForModel}
/>
)}
{/* Filament override - shown in model mode when filament requirements are available */}
{assignmentMode === 'model' && targetModel && effectiveFilamentReqs && availableFilaments && availableFilaments.length > 0 && (
@@ -759,7 +765,7 @@ export function PrintModal({
filamentReqs={effectiveFilamentReqs}
manualMappings={manualMappings}
onManualMappingChange={setManualMappings}
defaultExpanded={settings?.per_printer_mapping_expanded ?? false}
defaultExpanded={!!initialSelectedPrinterIds?.length || (settings?.per_printer_mapping_expanded ?? false)}
currencySymbol={currencySymbol}
defaultCostPerKg={defaultCostPerKg}
/>
@@ -767,7 +773,7 @@ export function PrintModal({
{/* Print options */}
{(mode === 'reprint' || effectivePrinterCount > 0 || (assignmentMode === 'model' && targetModel)) && (
<PrintOptionsPanel options={printOptions} onChange={setPrintOptions} />
<PrintOptionsPanel options={printOptions} onChange={setPrintOptions} defaultExpanded={!!initialSelectedPrinterIds?.length} />
)}
{/* Schedule options - only for queue modes */}
@@ -26,6 +26,8 @@ export interface PrintModalProps {
archiveName: string;
/** Existing queue item (only for edit-queue-item mode) */
queueItem?: PrintQueueItem;
/** Pre-select specific printers when opening the modal */
initialSelectedPrinterIds?: number[];
/** Handler for closing the modal */
onClose: () => void;
/** Handler for successful operation */
@@ -0,0 +1,246 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { X, Copy, Check, Signal } 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 () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard may not be available in non-secure contexts
}
};
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>
),
});
// WiFi Signal
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,
});
// SD Card
if (status?.sdcard != null) {
rows.push({
label: t('printers.sdCard'),
value: (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
status.sdcard
? 'bg-bambu-green/20 text-bambu-green'
: 'bg-bambu-dark-tertiary text-bambu-gray'
}`}>
{status.sdcard ? t('printers.inserted') : t('printers.notInserted')}
</span>
),
});
}
// 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>
);
}