mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 07:33:18 +02:00
[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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user