Browse Source

[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>
AneoPsy 2 months ago
parent
commit
fa9d3779cc

+ 654 - 0
frontend/src/__tests__/components/FileUploadModal.test.tsx

@@ -0,0 +1,654 @@
+/**
+ * Tests for the FileUploadModal component.
+ * Tests file upload, drag-and-drop, ZIP/3MF/STL detection, and autoUpload mode.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, fireEvent, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { FileUploadModal } from '../../components/FileUploadModal';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+describe('FileUploadModal', () => {
+  const defaultProps = {
+    folderId: null as number | null,
+    onClose: vi.fn(),
+    onUploadComplete: vi.fn(),
+  };
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+
+    server.use(
+      http.post('/api/v1/library/files', () => {
+        return HttpResponse.json({
+          id: 1,
+          filename: 'test.gcode.3mf',
+          file_type: '3mf',
+          file_size: 1048576,
+          thumbnail_path: null,
+          duplicate_of: null,
+          metadata: null,
+        });
+      }),
+      http.post('/api/v1/library/extract-zip', () => {
+        return HttpResponse.json({
+          extracted: 3,
+          errors: [],
+        });
+      })
+    );
+  });
+
+  describe('rendering', () => {
+    it('renders the modal with title', () => {
+      render(<FileUploadModal {...defaultProps} />);
+      expect(screen.getByText('Upload Files')).toBeInTheDocument();
+    });
+
+    it('renders drag and drop zone', () => {
+      render(<FileUploadModal {...defaultProps} />);
+      expect(screen.getByText(/Drag & drop/)).toBeInTheDocument();
+    });
+
+    it('renders click to browse text', () => {
+      render(<FileUploadModal {...defaultProps} />);
+      expect(screen.getByText(/click to browse/i)).toBeInTheDocument();
+    });
+
+    it('renders Cancel button', () => {
+      render(<FileUploadModal {...defaultProps} />);
+      expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
+    });
+
+    it('renders Upload button disabled when no files', () => {
+      render(<FileUploadModal {...defaultProps} />);
+      const uploadButton = screen.getByRole('button', { name: /Upload/i });
+      expect(uploadButton).toBeDisabled();
+    });
+
+    it('shows all file types supported text', () => {
+      render(<FileUploadModal {...defaultProps} />);
+      expect(screen.getByText(/All file types supported/i)).toBeInTheDocument();
+    });
+  });
+
+  describe('file selection', () => {
+    it('shows added file in the list', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const file = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      expect(screen.getByText('model.gcode.3mf')).toBeInTheDocument();
+    });
+
+    it('shows file size in MB', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const file = new File(['x'.repeat(1048576)], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      expect(screen.getByText('1.00 MB')).toBeInTheDocument();
+    });
+
+    it('enables Upload button when files are added', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
+      expect(uploadButton).not.toBeDisabled();
+    });
+
+    it('shows file count in Upload button', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const files = [
+        new File(['a'], 'file1.3mf', { type: 'application/octet-stream' }),
+        new File(['b'], 'file2.stl', { type: 'application/octet-stream' }),
+      ];
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, files);
+
+      expect(screen.getByRole('button', { name: /Upload \(2\)/i })).toBeInTheDocument();
+    });
+
+    it('accepts any file type (not restricted like UploadModal)', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const file = new File(['content'], 'readme.txt', { type: 'text/plain' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      expect(screen.getByText('readme.txt')).toBeInTheDocument();
+    });
+  });
+
+  describe('file removal', () => {
+    it('removes a file when X button is clicked', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      expect(screen.getByText('model.3mf')).toBeInTheDocument();
+
+      const fileRow = screen.getByText('model.3mf').closest('.flex');
+      const removeButton = fileRow?.querySelector('button');
+      if (removeButton) {
+        await user.click(removeButton);
+      }
+
+      await waitFor(() => {
+        expect(screen.queryByText('model.3mf')).not.toBeInTheDocument();
+      });
+    });
+
+    it('disables Upload button after removing all files', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const fileRow = screen.getByText('model.3mf').closest('.flex');
+      const removeButton = fileRow?.querySelector('button');
+      if (removeButton) {
+        await user.click(removeButton);
+      }
+
+      await waitFor(() => {
+        const uploadButton = screen.getByRole('button', { name: /Upload/i });
+        expect(uploadButton).toBeDisabled();
+      });
+    });
+  });
+
+  describe('file type detection', () => {
+    it('shows ZIP options when .zip file is added', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, zipFile);
+
+      await waitFor(() => {
+        expect(screen.getByText('ZIP files detected')).toBeInTheDocument();
+        expect(screen.getByText(/Preserve folder structure/)).toBeInTheDocument();
+        expect(screen.getByText(/Create folder from ZIP/)).toBeInTheDocument();
+      });
+    });
+
+    it('shows 3MF info when .3mf file is added', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const threemfFile = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, threemfFile);
+
+      await waitFor(() => {
+        expect(screen.getByText('3MF files detected')).toBeInTheDocument();
+      });
+    });
+
+    it('shows STL thumbnail option when .stl file is added', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const stlFile = new File(['solid'], 'bracket.stl', { type: 'application/sla' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, stlFile);
+
+      await waitFor(() => {
+        expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument();
+        expect(screen.getByText(/Thumbnails can be generated/i)).toBeInTheDocument();
+      });
+    });
+
+    it('shows STL thumbnail option when ZIP file is added (may contain STLs)', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, zipFile);
+
+      await waitFor(() => {
+        expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument();
+        expect(screen.getByText(/ZIP files may contain STL/i)).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('ZIP options', () => {
+    it('preserve structure checkbox is checked by default', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, zipFile);
+
+      await waitFor(() => {
+        const label = screen.getByText(/Preserve folder structure/).closest('label');
+        const checkbox = label?.querySelector('input[type="checkbox"]') as HTMLInputElement;
+        expect(checkbox).toBeChecked();
+      });
+    });
+
+    it('create folder checkbox is unchecked by default', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, zipFile);
+
+      await waitFor(() => {
+        const label = screen.getByText(/Create folder from ZIP/).closest('label');
+        const checkbox = label?.querySelector('input[type="checkbox"]') as HTMLInputElement;
+        expect(checkbox).not.toBeChecked();
+      });
+    });
+
+    it('can toggle ZIP options', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, zipFile);
+
+      await waitFor(() => {
+        expect(screen.getByText('ZIP files detected')).toBeInTheDocument();
+      });
+
+      const preserveLabel = screen.getByText(/Preserve folder structure/).closest('label');
+      const preserveCheckbox = preserveLabel?.querySelector('input[type="checkbox"]') as HTMLInputElement;
+      await user.click(preserveCheckbox);
+      expect(preserveCheckbox).not.toBeChecked();
+
+      const createFolderLabel = screen.getByText(/Create folder from ZIP/).closest('label');
+      const createFolderCheckbox = createFolderLabel?.querySelector('input[type="checkbox"]') as HTMLInputElement;
+      await user.click(createFolderCheckbox);
+      expect(createFolderCheckbox).toBeChecked();
+    });
+  });
+
+  describe('upload flow', () => {
+    it('calls onUploadComplete after successful upload', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
+      await user.click(uploadButton);
+
+      await waitFor(() => {
+        expect(defaultProps.onUploadComplete).toHaveBeenCalled();
+      });
+    });
+
+    it('calls onFileUploaded with response data for each file', async () => {
+      const onFileUploaded = vi.fn();
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} onFileUploaded={onFileUploaded} />);
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
+      await user.click(uploadButton);
+
+      await waitFor(() => {
+        expect(onFileUploaded).toHaveBeenCalledWith(
+          expect.objectContaining({
+            id: 1,
+            filename: 'test.gcode.3mf',
+          })
+        );
+      });
+    });
+
+    it('shows uploading state while uploading', async () => {
+      // Delay the response to observe uploading state
+      server.use(
+        http.post('/api/v1/library/files', async () => {
+          await new Promise((resolve) => setTimeout(resolve, 100));
+          return HttpResponse.json({
+            id: 1,
+            filename: 'model.3mf',
+            file_type: '3mf',
+            file_size: 1024,
+            thumbnail_path: null,
+            duplicate_of: null,
+            metadata: null,
+          });
+        })
+      );
+
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
+      await user.click(uploadButton);
+
+      // Should show uploading state
+      await waitFor(() => {
+        expect(screen.getByText('Uploading...')).toBeInTheDocument();
+        expect(document.querySelector('.animate-spin')).toBeInTheDocument();
+      });
+    });
+
+    it('shows error state on upload failure', async () => {
+      server.use(
+        http.post('/api/v1/library/files', () => {
+          return HttpResponse.json({ detail: 'File too large' }, { status: 413 });
+        })
+      );
+
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
+      await user.click(uploadButton);
+
+      await waitFor(() => {
+        expect(defaultProps.onUploadComplete).toHaveBeenCalled();
+      });
+    });
+
+    it('closes modal after manual upload completes', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
+      await user.click(uploadButton);
+
+      await waitFor(() => {
+        expect(defaultProps.onUploadComplete).toHaveBeenCalled();
+        expect(defaultProps.onClose).toHaveBeenCalled();
+      });
+    });
+  });
+
+  describe('autoUpload mode', () => {
+    it('uploads immediately when file is added', async () => {
+      const onFileUploaded = vi.fn();
+      const user = userEvent.setup();
+      render(
+        <FileUploadModal
+          {...defaultProps}
+          autoUpload
+          onFileUploaded={onFileUploaded}
+        />
+      );
+
+      const file = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      await waitFor(() => {
+        expect(onFileUploaded).toHaveBeenCalledWith(
+          expect.objectContaining({ id: 1 })
+        );
+      });
+    });
+
+    it('calls onClose after autoUpload completes', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} autoUpload />);
+
+      const file = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      await waitFor(() => {
+        expect(defaultProps.onClose).toHaveBeenCalled();
+        expect(defaultProps.onUploadComplete).toHaveBeenCalled();
+      });
+    });
+  });
+
+  describe('close behavior', () => {
+    it('calls onClose when Cancel button is clicked', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      await user.click(screen.getByRole('button', { name: 'Cancel' }));
+      expect(defaultProps.onClose).toHaveBeenCalled();
+    });
+
+    it('calls onClose when X button is clicked', async () => {
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} />);
+
+      // The X button is the one in the header (not file remove buttons)
+      const headerButtons = screen.getByText('Upload Files').parentElement?.querySelectorAll('button');
+      const closeButton = headerButtons?.[0];
+
+      if (closeButton) {
+        await user.click(closeButton);
+        expect(defaultProps.onClose).toHaveBeenCalled();
+      }
+    });
+
+    it('always shows Cancel button (modal auto-closes after upload)', () => {
+      render(<FileUploadModal {...defaultProps} />);
+      expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
+    });
+  });
+
+  describe('drag and drop', () => {
+    it('highlights drop zone on drag over', () => {
+      render(<FileUploadModal {...defaultProps} />);
+
+      const dropZone = screen.getByText(/Drag & drop/).closest('div[class*="border-dashed"]');
+
+      if (dropZone) {
+        fireEvent.dragOver(dropZone, { dataTransfer: { files: [] } });
+        expect(dropZone.className).toContain('border-bambu-green');
+      }
+    });
+
+    it('removes highlight on drag leave', () => {
+      render(<FileUploadModal {...defaultProps} />);
+
+      const dropZone = screen.getByText(/Drag & drop/).closest('div[class*="border-dashed"]');
+
+      if (dropZone) {
+        fireEvent.dragOver(dropZone, { dataTransfer: { files: [] } });
+        fireEvent.dragLeave(dropZone, { dataTransfer: { files: [] } });
+        expect(dropZone.className).not.toContain('bg-bambu-green');
+      }
+    });
+  });
+
+  describe('folder context', () => {
+    it('accepts folderId prop for uploading to specific folder', () => {
+      render(<FileUploadModal {...defaultProps} folderId={5} />);
+      // Component should render without errors with a folder context
+      expect(screen.getByText('Upload Files')).toBeInTheDocument();
+    });
+  });
+
+  describe('validateFile prop', () => {
+    it('rejects files that fail validation and shows error', async () => {
+      const user = userEvent.setup();
+      render(
+        <FileUploadModal
+          {...defaultProps}
+          validateFile={(file) => {
+            if (!file.name.endsWith('.gcode')) return 'Only .gcode files allowed';
+          }}
+        />
+      );
+
+      const file = new File(['content'], 'model.stl', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      // Error should be shown
+      expect(screen.getByText('Only .gcode files allowed')).toBeInTheDocument();
+      // File should NOT be added to the list
+      expect(screen.queryByText('model.stl')).not.toBeInTheDocument();
+    });
+
+    it('allows files that pass validation', async () => {
+      const user = userEvent.setup();
+      render(
+        <FileUploadModal
+          {...defaultProps}
+          validateFile={(file) => {
+            if (!file.name.endsWith('.gcode')) return 'Only .gcode files allowed';
+          }}
+        />
+      );
+
+      const file = new File(['content'], 'model.gcode', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      expect(screen.getByText('model.gcode')).toBeInTheDocument();
+      expect(screen.queryByText('Only .gcode files allowed')).not.toBeInTheDocument();
+    });
+
+    it('clears validation error when a new file is added', async () => {
+      const user = userEvent.setup();
+      render(
+        <FileUploadModal
+          {...defaultProps}
+          validateFile={(file) => {
+            if (!file.name.endsWith('.gcode')) return 'Only .gcode files allowed';
+          }}
+        />
+      );
+
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+
+      // First add an invalid file
+      const badFile = new File(['content'], 'model.stl', { type: 'application/octet-stream' });
+      await user.upload(fileInput, badFile);
+      expect(screen.getByText('Only .gcode files allowed')).toBeInTheDocument();
+
+      // Then add a valid file — error should clear
+      const goodFile = new File(['content'], 'model.gcode', { type: 'application/octet-stream' });
+      await user.upload(fileInput, goodFile);
+      expect(screen.queryByText('Only .gcode files allowed')).not.toBeInTheDocument();
+    });
+  });
+
+  describe('accept prop', () => {
+    it('sets accept attribute on file input', () => {
+      render(<FileUploadModal {...defaultProps} accept=".gcode,.gcode.3mf" />);
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      expect(fileInput.accept).toBe('.gcode,.gcode.3mf');
+    });
+
+    it('does not set accept attribute when prop is omitted', () => {
+      render(<FileUploadModal {...defaultProps} />);
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      expect(fileInput.accept).toBe('');
+    });
+  });
+
+  describe('onFileUploaded error handling', () => {
+    it('shows error and keeps modal open when onFileUploaded returns a string', async () => {
+      const user = userEvent.setup();
+      render(
+        <FileUploadModal
+          {...defaultProps}
+          onFileUploaded={() => 'This file was sliced for the wrong printer'}
+        />
+      );
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
+      await user.click(uploadButton);
+
+      await waitFor(() => {
+        expect(screen.getByText('This file was sliced for the wrong printer')).toBeInTheDocument();
+      });
+
+      // Modal should NOT close
+      expect(defaultProps.onClose).not.toHaveBeenCalled();
+    });
+
+    it('clears file list when onFileUploaded returns an error', async () => {
+      const user = userEvent.setup();
+      render(
+        <FileUploadModal
+          {...defaultProps}
+          onFileUploaded={() => 'Incompatible printer'}
+        />
+      );
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
+      await user.click(uploadButton);
+
+      await waitFor(() => {
+        expect(screen.getByText('Incompatible printer')).toBeInTheDocument();
+      });
+
+      // File list should be cleared
+      expect(screen.queryByText('model.3mf')).not.toBeInTheDocument();
+    });
+
+    it('closes modal normally when onFileUploaded returns undefined', async () => {
+      const onFileUploaded = vi.fn();
+      const user = userEvent.setup();
+      render(<FileUploadModal {...defaultProps} onFileUploaded={onFileUploaded} />);
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
+      await user.click(uploadButton);
+
+      await waitFor(() => {
+        expect(defaultProps.onClose).toHaveBeenCalled();
+      });
+    });
+  });
+});

+ 112 - 12
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -569,8 +569,8 @@ describe('FileManagerPage', () => {
     });
     });
   });
   });
 
 
-  describe('upload modal with advanced 3MF support', () => {
-    it('opens upload modal', async () => {
+  describe('upload modal (FileUploadModal)', () => {
+    it('opens upload modal when Upload button is clicked', async () => {
       const user = userEvent.setup();
       const user = userEvent.setup();
       render(<FileManagerPage />);
       render(<FileManagerPage />);
 
 
@@ -586,6 +586,27 @@ describe('FileManagerPage', () => {
       });
       });
     });
     });
 
 
+    it('closes upload modal when Cancel is clicked', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Upload'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload Files')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByRole('button', { name: 'Cancel' }));
+
+      await waitFor(() => {
+        expect(screen.queryByText('Upload Files')).not.toBeInTheDocument();
+      });
+    });
+
     it('shows 3MF extraction info when 3MF file is added', async () => {
     it('shows 3MF extraction info when 3MF file is added', async () => {
       const user = userEvent.setup();
       const user = userEvent.setup();
       render(<FileManagerPage />);
       render(<FileManagerPage />);
@@ -600,17 +621,12 @@ describe('FileManagerPage', () => {
         expect(screen.getByText('Upload Files')).toBeInTheDocument();
         expect(screen.getByText('Upload Files')).toBeInTheDocument();
       });
       });
 
 
-      // Create a mock 3MF file
       const threemfFile = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });
       const threemfFile = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });
-
-      // Get the hidden file input
       const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
       const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
       expect(fileInput).toBeInTheDocument();
       expect(fileInput).toBeInTheDocument();
 
 
-      // Simulate file selection
       await user.upload(fileInput, threemfFile);
       await user.upload(fileInput, threemfFile);
 
 
-      // 3MF extraction info should appear
       await waitFor(() => {
       await waitFor(() => {
         expect(screen.getByText('3MF files detected')).toBeInTheDocument();
         expect(screen.getByText('3MF files detected')).toBeInTheDocument();
         expect(screen.getByText(/Printer model.*will be automatically extracted/i)).toBeInTheDocument();
         expect(screen.getByText(/Printer model.*will be automatically extracted/i)).toBeInTheDocument();
@@ -631,22 +647,106 @@ describe('FileManagerPage', () => {
         expect(screen.getByText('Upload Files')).toBeInTheDocument();
         expect(screen.getByText('Upload Files')).toBeInTheDocument();
       });
       });
 
 
-      // Create a mock STL file
       const stlFile = new File(['solid test'], 'model.stl', { type: 'application/sla' });
       const stlFile = new File(['solid test'], 'model.stl', { type: 'application/sla' });
-
-      // Get the hidden file input
       const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
       const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
       expect(fileInput).toBeInTheDocument();
       expect(fileInput).toBeInTheDocument();
 
 
-      // Simulate file selection
       await user.upload(fileInput, stlFile);
       await user.upload(fileInput, stlFile);
 
 
-      // STL thumbnail option should appear
       await waitFor(() => {
       await waitFor(() => {
         expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument();
         expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument();
         expect(screen.getByText(/Thumbnails can be generated/i)).toBeInTheDocument();
         expect(screen.getByText(/Thumbnails can be generated/i)).toBeInTheDocument();
       });
       });
     });
     });
+
+    it('shows ZIP options when ZIP file is added', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Upload'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload Files')).toBeInTheDocument();
+      });
+
+      const zipFile = new File(['pk'], 'models.zip', { type: 'application/zip' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, zipFile);
+
+      await waitFor(() => {
+        expect(screen.getByText('ZIP files detected')).toBeInTheDocument();
+        expect(screen.getByText(/Preserve folder structure/)).toBeInTheDocument();
+      });
+    });
+
+    it('can add a file via the file input', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Upload'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload Files')).toBeInTheDocument();
+      });
+
+      const file = new File(['content'], 'model.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      await waitFor(() => {
+        expect(screen.getByText('model.3mf')).toBeInTheDocument();
+        expect(screen.getByRole('button', { name: /Upload \(1\)/i })).toBeInTheDocument();
+      });
+    });
+
+    it('uploads file and refreshes file list', async () => {
+      server.use(
+        http.post('/api/v1/library/files', () => {
+          return HttpResponse.json({
+            id: 10,
+            filename: 'uploaded.3mf',
+            file_type: '3mf',
+            file_size: 1024,
+            thumbnail_path: null,
+            duplicate_of: null,
+            metadata: null,
+          });
+        })
+      );
+
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Upload'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload Files')).toBeInTheDocument();
+      });
+
+      const file = new File(['content'], 'uploaded.3mf', { type: 'application/octet-stream' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, file);
+
+      const uploadButton = screen.getByRole('button', { name: /Upload \(1\)/i });
+      await user.click(uploadButton);
+
+      // Modal should auto-close after upload completes
+      await waitFor(() => {
+        expect(screen.queryByText('Upload Files')).not.toBeInTheDocument();
+      });
+    });
   });
   });
 
 
   describe('authentication-based UI changes', () => {
   describe('authentication-based UI changes', () => {

+ 352 - 0
frontend/src/components/FileUploadModal.tsx

@@ -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
frontend/src/components/PrintModal/index.tsx

@@ -43,6 +43,7 @@ export function PrintModal({
   libraryFileId,
   libraryFileId,
   archiveName,
   archiveName,
   queueItem,
   queueItem,
+  initialSelectedPrinterIds,
   onClose,
   onClose,
   onSuccess,
   onSuccess,
 }: PrintModalProps) {
 }: PrintModalProps) {
@@ -60,6 +61,9 @@ export function PrintModal({
     if (mode === 'edit-queue-item' && queueItem?.printer_id) {
     if (mode === 'edit-queue-item' && queueItem?.printer_id) {
       return [queueItem.printer_id];
       return [queueItem.printer_id];
     }
     }
+    if (initialSelectedPrinterIds?.length) {
+      return initialSelectedPrinterIds;
+    }
     return [];
     return [];
   });
   });
 
 
@@ -695,26 +699,28 @@ export function PrintModal({
               onSelect={setSelectedPlate}
               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 */}
             {/* Filament override - shown in model mode when filament requirements are available */}
             {assignmentMode === 'model' && targetModel && effectiveFilamentReqs && availableFilaments && availableFilaments.length > 0 && (
             {assignmentMode === 'model' && targetModel && effectiveFilamentReqs && availableFilaments && availableFilaments.length > 0 && (
@@ -759,7 +765,7 @@ export function PrintModal({
                 filamentReqs={effectiveFilamentReqs}
                 filamentReqs={effectiveFilamentReqs}
                 manualMappings={manualMappings}
                 manualMappings={manualMappings}
                 onManualMappingChange={setManualMappings}
                 onManualMappingChange={setManualMappings}
-                defaultExpanded={settings?.per_printer_mapping_expanded ?? false}
+                defaultExpanded={!!initialSelectedPrinterIds?.length || (settings?.per_printer_mapping_expanded ?? false)}
                 currencySymbol={currencySymbol}
                 currencySymbol={currencySymbol}
                 defaultCostPerKg={defaultCostPerKg}
                 defaultCostPerKg={defaultCostPerKg}
               />
               />
@@ -767,7 +773,7 @@ export function PrintModal({
 
 
             {/* Print options */}
             {/* Print options */}
             {(mode === 'reprint' || effectivePrinterCount > 0 || (assignmentMode === 'model' && targetModel)) && (
             {(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 */}
             {/* Schedule options - only for queue modes */}

+ 2 - 0
frontend/src/components/PrintModal/types.ts

@@ -26,6 +26,8 @@ export interface PrintModalProps {
   archiveName: string;
   archiveName: string;
   /** Existing queue item (only for edit-queue-item mode) */
   /** Existing queue item (only for edit-queue-item mode) */
   queueItem?: PrintQueueItem;
   queueItem?: PrintQueueItem;
+  /** Pre-select specific printers when opening the modal */
+  initialSelectedPrinterIds?: number[];
   /** Handler for closing the modal */
   /** Handler for closing the modal */
   onClose: () => void;
   onClose: () => void;
   /** Handler for successful operation */
   /** Handler for successful operation */

+ 246 - 0
frontend/src/components/PrinterInfoModal.tsx

@@ -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>
+  );
+}

+ 18 - 1
frontend/src/i18n/locales/de.ts

@@ -201,6 +201,7 @@ export default {
     chamberLightOn: 'Kammerbeleuchtung einschalten',
     chamberLightOn: 'Kammerbeleuchtung einschalten',
     chamberLightOff: 'Kammerbeleuchtung ausschalten',
     chamberLightOff: 'Kammerbeleuchtung ausschalten',
     // Files
     // Files
+    files: 'Dateien',
     browseFiles: 'Druckerdateien durchsuchen',
     browseFiles: 'Druckerdateien durchsuchen',
     // Smart plug
     // Smart plug
     autoOffAfterPrint: 'Automatisches Ausschalten nach Druck',
     autoOffAfterPrint: 'Automatisches Ausschalten nach Druck',
@@ -216,6 +217,19 @@ export default {
     skipObject: 'Objekt überspringen',
     skipObject: 'Objekt überspringen',
     reconnect: 'Neu verbinden',
     reconnect: 'Neu verbinden',
     mqttDebug: 'MQTT-Debug',
     mqttDebug: 'MQTT-Debug',
+    printerInformation: 'Druckerinformationen',
+    copyToClipboard: 'Kopieren',
+    copied: 'Kopiert!',
+    state: 'Zustand',
+    wifiSignalLabel: 'WLAN-Signal',
+    developerMode: 'Entwicklermodus',
+    enabled: 'Aktiviert',
+    disabled: 'Deaktiviert',
+    addedOn: 'Hinzugefügt',
+    sdCard: 'SD-Karte',
+    inserted: 'Eingelegt',
+    notInserted: 'Nicht eingelegt',
+    totalPrintHours: 'Druckstunden',
     activeNozzle: 'Aktiv: {{nozzle}} Düse',
     activeNozzle: 'Aktiv: {{nozzle}} Düse',
     nozzleRack: 'Düsenhalter',
     nozzleRack: 'Düsenhalter',
     nozzleDocked: 'Angedockt',
     nozzleDocked: 'Angedockt',
@@ -464,6 +478,8 @@ export default {
     },
     },
     developerModeWarning: 'Der Entwickler-LAN-Modus ist nicht aktiviert auf: {{names}}. Einige Funktionen funktionieren möglicherweise nicht.',
     developerModeWarning: 'Der Entwickler-LAN-Modus ist nicht aktiviert auf: {{names}}. Einige Funktionen funktionieren möglicherweise nicht.',
     howToEnable: 'Aktivieren',
     howToEnable: 'Aktivieren',
+    incompatibleFile: 'Diese Datei wurde für {{slicedFor}} geslicet, aber dieser Drucker ist ein {{printerModel}}',
+    dropNotPrintable: 'Nur .gcode- und .gcode.3mf-Dateien können gedruckt werden',
     dropToPrint: 'Zum Drucken ablegen',
     dropToPrint: 'Zum Drucken ablegen',
     cannotPrint: 'Drucker beschäftigt',
     cannotPrint: 'Drucker beschäftigt',
   },
   },
@@ -2245,7 +2261,8 @@ export default {
     willBeExtracted: 'Wird extrahiert',
     willBeExtracted: 'Wird extrahiert',
     filesExtracted: '{{count}} Dateien extrahiert',
     filesExtracted: '{{count}} Dateien extrahiert',
     uploadComplete: 'Upload abgeschlossen: {{succeeded}} erfolgreich',
     uploadComplete: 'Upload abgeschlossen: {{succeeded}} erfolgreich',
-    uploadFailed: '{{count}} fehlgeschlagen',
+    uploadFailed: 'Hochladen fehlgeschlagen',
+    zipFilesFailed: '{{count}} Dateien fehlgeschlagen',
     uploading: 'Hochladen...',
     uploading: 'Hochladen...',
     changeLink: 'Verknüpfung ändern...',
     changeLink: 'Verknüpfung ändern...',
     linkTo: 'Verknüpfen mit...',
     linkTo: 'Verknüpfen mit...',

+ 18 - 1
frontend/src/i18n/locales/en.ts

@@ -201,6 +201,7 @@ export default {
     chamberLightOn: 'Turn on chamber light',
     chamberLightOn: 'Turn on chamber light',
     chamberLightOff: 'Turn off chamber light',
     chamberLightOff: 'Turn off chamber light',
     // Files
     // Files
+    files: 'Files',
     browseFiles: 'Browse printer files',
     browseFiles: 'Browse printer files',
     // Smart plug
     // Smart plug
     autoOffAfterPrint: 'Auto power-off after print',
     autoOffAfterPrint: 'Auto power-off after print',
@@ -216,6 +217,19 @@ export default {
     skipObject: 'Skip Object',
     skipObject: 'Skip Object',
     reconnect: 'Reconnect',
     reconnect: 'Reconnect',
     mqttDebug: 'MQTT Debug',
     mqttDebug: 'MQTT Debug',
+    printerInformation: 'Printer Information',
+    copyToClipboard: 'Copy',
+    copied: 'Copied!',
+    state: 'State',
+    wifiSignalLabel: 'WiFi Signal',
+    developerMode: 'Developer Mode',
+    enabled: 'Enabled',
+    disabled: 'Disabled',
+    addedOn: 'Added',
+    sdCard: 'SD Card',
+    inserted: 'Inserted',
+    notInserted: 'Not inserted',
+    totalPrintHours: 'Print Hours',
     activeNozzle: 'Active: {{nozzle}} nozzle',
     activeNozzle: 'Active: {{nozzle}} nozzle',
     nozzleRack: 'Nozzle Rack',
     nozzleRack: 'Nozzle Rack',
     nozzleDocked: 'Docked',
     nozzleDocked: 'Docked',
@@ -464,6 +478,8 @@ export default {
     },
     },
     developerModeWarning: 'Developer LAN mode is not enabled on: {{names}}. Some features may not work.',
     developerModeWarning: 'Developer LAN mode is not enabled on: {{names}}. Some features may not work.',
     howToEnable: 'How to enable',
     howToEnable: 'How to enable',
+    incompatibleFile: 'This file was sliced for {{slicedFor}}, but this printer is a {{printerModel}}',
+    dropNotPrintable: 'Only .gcode and .gcode.3mf files can be printed',
     dropToPrint: 'Drop to print',
     dropToPrint: 'Drop to print',
     cannotPrint: 'Printer busy',
     cannotPrint: 'Printer busy',
   },
   },
@@ -2245,7 +2261,8 @@ export default {
     willBeExtracted: 'Will be extracted',
     willBeExtracted: 'Will be extracted',
     filesExtracted: '{{count}} files extracted',
     filesExtracted: '{{count}} files extracted',
     uploadComplete: 'Upload complete: {{succeeded}} succeeded',
     uploadComplete: 'Upload complete: {{succeeded}} succeeded',
-    uploadFailed: '{{count}} failed',
+    uploadFailed: 'Upload failed',
+    zipFilesFailed: '{{count}} files failed',
     uploading: 'Uploading...',
     uploading: 'Uploading...',
     changeLink: 'Change Link...',
     changeLink: 'Change Link...',
     linkTo: 'Link to...',
     linkTo: 'Link to...',

+ 18 - 1
frontend/src/i18n/locales/fr.ts

@@ -201,6 +201,7 @@ export default {
     chamberLightOn: 'Allumer la lumière de la chambre',
     chamberLightOn: 'Allumer la lumière de la chambre',
     chamberLightOff: 'Éteindre la lumière de la chambre',
     chamberLightOff: 'Éteindre la lumière de la chambre',
     // Files
     // Files
+    files: 'Fichiers',
     browseFiles: 'Parcourir les fichiers de l\'imprimante',
     browseFiles: 'Parcourir les fichiers de l\'imprimante',
     // Smart plug
     // Smart plug
     autoOffAfterPrint: 'Extinction auto après impression',
     autoOffAfterPrint: 'Extinction auto après impression',
@@ -216,6 +217,19 @@ export default {
     skipObject: 'Sauter l\'objet',
     skipObject: 'Sauter l\'objet',
     reconnect: 'Reconnecter',
     reconnect: 'Reconnecter',
     mqttDebug: 'Débogage MQTT',
     mqttDebug: 'Débogage MQTT',
+    printerInformation: 'Informations imprimante',
+    copyToClipboard: 'Copier',
+    copied: 'Copié !',
+    state: 'État',
+    wifiSignalLabel: 'Signal WiFi',
+    developerMode: 'Mode développeur',
+    enabled: 'Activé',
+    disabled: 'Désactivé',
+    addedOn: 'Ajoutée le',
+    sdCard: 'Carte SD',
+    inserted: 'Insérée',
+    notInserted: 'Non insérée',
+    totalPrintHours: 'Heures d\'impression',
     activeNozzle: 'Active : buse {{nozzle}}',
     activeNozzle: 'Active : buse {{nozzle}}',
     nozzleRack: 'Rack à buses',
     nozzleRack: 'Rack à buses',
     nozzleDocked: 'Rangée',
     nozzleDocked: 'Rangée',
@@ -464,6 +478,8 @@ export default {
     },
     },
     developerModeWarning: 'Le mode développeur LAN n\'est pas activé sur : {{names}}. Certaines fonctionnalités peuvent ne pas fonctionner.',
     developerModeWarning: 'Le mode développeur LAN n\'est pas activé sur : {{names}}. Certaines fonctionnalités peuvent ne pas fonctionner.',
     howToEnable: 'Comment activer',
     howToEnable: 'Comment activer',
+    incompatibleFile: 'Ce fichier a été tranché pour {{slicedFor}}, mais cette imprimante est une {{printerModel}}',
+    dropNotPrintable: 'Seuls les fichiers .gcode et .gcode.3mf peuvent être imprimés',
     dropToPrint: 'Déposer pour imprimer',
     dropToPrint: 'Déposer pour imprimer',
     cannotPrint: 'Imprimante occupée',
     cannotPrint: 'Imprimante occupée',
   },
   },
@@ -2233,7 +2249,8 @@ export default {
     willBeExtracted: 'Sera extrait',
     willBeExtracted: 'Sera extrait',
     filesExtracted: '{{count}} fichiers extraits',
     filesExtracted: '{{count}} fichiers extraits',
     uploadComplete: 'Terminé : {{succeeded}} succès',
     uploadComplete: 'Terminé : {{succeeded}} succès',
-    uploadFailed: '{{count}} échecs',
+    uploadFailed: 'Échec du téléversement',
+    zipFilesFailed: '{{count}} fichiers échoués',
     uploading: 'Téléversement...',
     uploading: 'Téléversement...',
     changeLink: 'Modifier lien...',
     changeLink: 'Modifier lien...',
     linkTo: 'Lier à...',
     linkTo: 'Lier à...',

+ 18 - 1
frontend/src/i18n/locales/it.ts

@@ -198,6 +198,7 @@ export default {
     chamberLightOn: 'Accendi luce camera',
     chamberLightOn: 'Accendi luce camera',
     chamberLightOff: 'Spegni luce camera',
     chamberLightOff: 'Spegni luce camera',
     // Files
     // Files
+    files: 'File',
     browseFiles: 'Sfoglia file stampante',
     browseFiles: 'Sfoglia file stampante',
     // Smart plug
     // Smart plug
     autoOffAfterPrint: 'Spegnimento automatico dopo stampa',
     autoOffAfterPrint: 'Spegnimento automatico dopo stampa',
@@ -213,6 +214,19 @@ export default {
     skipObject: 'Salta Oggetto',
     skipObject: 'Salta Oggetto',
     reconnect: 'Riconnetti',
     reconnect: 'Riconnetti',
     mqttDebug: 'Debug MQTT',
     mqttDebug: 'Debug MQTT',
+    printerInformation: 'Informazioni stampante',
+    copyToClipboard: 'Copia',
+    copied: 'Copiato!',
+    state: 'Stato',
+    wifiSignalLabel: 'Segnale WiFi',
+    developerMode: 'Modalità sviluppatore',
+    enabled: 'Attivato',
+    disabled: 'Disattivato',
+    addedOn: 'Aggiunta il',
+    sdCard: 'Scheda SD',
+    inserted: 'Inserita',
+    notInserted: 'Non inserita',
+    totalPrintHours: 'Ore di stampa',
     activeNozzle: 'Attivo: ugello {{nozzle}}',
     activeNozzle: 'Attivo: ugello {{nozzle}}',
     nozzleRack: 'Rack Ugelli',
     nozzleRack: 'Rack Ugelli',
     nozzleDocked: 'Agganciato',
     nozzleDocked: 'Agganciato',
@@ -455,6 +469,8 @@ export default {
     },
     },
     developerModeWarning: 'La modalità sviluppatore LAN non è attivata su: {{names}}. Alcune funzionalità potrebbero non funzionare.',
     developerModeWarning: 'La modalità sviluppatore LAN non è attivata su: {{names}}. Alcune funzionalità potrebbero non funzionare.',
     howToEnable: 'Come attivare',
     howToEnable: 'Come attivare',
+    incompatibleFile: 'Questo file è stato preparato per {{slicedFor}}, ma questa stampante è una {{printerModel}}',
+    dropNotPrintable: 'Solo i file .gcode e .gcode.3mf possono essere stampati',
     dropToPrint: 'Rilascia per stampare',
     dropToPrint: 'Rilascia per stampare',
     cannotPrint: 'Stampante occupata',
     cannotPrint: 'Stampante occupata',
   },
   },
@@ -2050,7 +2066,8 @@ export default {
     willBeExtracted: 'Sara estratto',
     willBeExtracted: 'Sara estratto',
     filesExtracted: '{{count}} file estratti',
     filesExtracted: '{{count}} file estratti',
     uploadComplete: 'Caricamento completato: {{succeeded}} riusciti',
     uploadComplete: 'Caricamento completato: {{succeeded}} riusciti',
-    uploadFailed: '{{count}} falliti',
+    uploadFailed: 'Caricamento fallito',
+    zipFilesFailed: '{{count}} file falliti',
     uploading: 'Caricamento...',
     uploading: 'Caricamento...',
     changeLink: 'Cambia collegamento...',
     changeLink: 'Cambia collegamento...',
     linkTo: 'Collega a...',
     linkTo: 'Collega a...',

+ 17 - 0
frontend/src/i18n/locales/ja.ts

@@ -191,6 +191,7 @@ export default {
     maintenanceUpToDate: 'すべてのメンテナンスが最新です',
     maintenanceUpToDate: 'すべてのメンテナンスが最新です',
     chamberLightOn: 'チャンバーライトをオンにしました',
     chamberLightOn: 'チャンバーライトをオンにしました',
     chamberLightOff: 'チャンバーライトをオフにしました',
     chamberLightOff: 'チャンバーライトをオフにしました',
+    files: 'ファイル',
     browseFiles: 'プリンターのファイルを参照',
     browseFiles: 'プリンターのファイルを参照',
     hmsErrors: 'クリックしてHMSエラーを表示',
     hmsErrors: 'クリックしてHMSエラーを表示',
     resume: '再開',
     resume: '再開',
@@ -198,6 +199,19 @@ export default {
     stop: '停止',
     stop: '停止',
     reconnect: '再接続',
     reconnect: '再接続',
     mqttDebug: 'MQTTデバッグ',
     mqttDebug: 'MQTTデバッグ',
+    printerInformation: 'プリンター情報',
+    copyToClipboard: 'コピー',
+    copied: 'コピーしました!',
+    state: '状態',
+    wifiSignalLabel: 'WiFi信号',
+    developerMode: '開発者モード',
+    enabled: '有効',
+    disabled: '無効',
+    addedOn: '追加日',
+    sdCard: 'SDカード',
+    inserted: '挿入済み',
+    notInserted: '未挿入',
+    totalPrintHours: '印刷時間',
     activeNozzle: 'アクティブ: {{side}}ノズル',
     activeNozzle: 'アクティブ: {{side}}ノズル',
     nozzleRack: 'ノズルラック',
     nozzleRack: 'ノズルラック',
     nozzleDocked: 'ドッキング中',
     nozzleDocked: 'ドッキング中',
@@ -471,6 +485,8 @@ export default {
     clickToViewHmsErrors: 'クリックしてHMSエラーを表示',
     clickToViewHmsErrors: 'クリックしてHMSエラーを表示',
     developerModeWarning: '開発者LANモードが有効になっていません: {{names}}。一部の機能が動作しない可能性があります。',
     developerModeWarning: '開発者LANモードが有効になっていません: {{names}}。一部の機能が動作しない可能性があります。',
     howToEnable: '有効化方法',
     howToEnable: '有効化方法',
+    incompatibleFile: 'このファイルは{{slicedFor}}用にスライスされていますが、このプリンターは{{printerModel}}です',
+    dropNotPrintable: '.gcodeおよび.gcode.3mfファイルのみ印刷できます',
     dropToPrint: 'ドロップして印刷',
     dropToPrint: 'ドロップして印刷',
     cannotPrint: 'プリンター使用中',
     cannotPrint: 'プリンター使用中',
   },
   },
@@ -2176,6 +2192,7 @@ export default {
     filesExtracted: '• {{count}}個のファイルを展開済み',
     filesExtracted: '• {{count}}個のファイルを展開済み',
     uploadComplete: 'アップロード完了: {{count}}個成功',
     uploadComplete: 'アップロード完了: {{count}}個成功',
     uploadFailed: 'アップロード失敗',
     uploadFailed: 'アップロード失敗',
+    zipFilesFailed: '{{count}}個のファイルが失敗',
     uploading: 'アップロード中...',
     uploading: 'アップロード中...',
     changeLink: 'リンクを変更...',
     changeLink: 'リンクを変更...',
     linkTo: 'リンク先...',
     linkTo: 'リンク先...',

+ 18 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -201,6 +201,7 @@ export default {
     chamberLightOn: 'Ligar luz da câmara',
     chamberLightOn: 'Ligar luz da câmara',
     chamberLightOff: 'Desligar luz da câmara',
     chamberLightOff: 'Desligar luz da câmara',
     // Files
     // Files
+    files: 'Arquivos',
     browseFiles: 'Procurar arquivos da impressora',
     browseFiles: 'Procurar arquivos da impressora',
     // Smart plug
     // Smart plug
     autoOffAfterPrint: 'Desligamento automático após impressão',
     autoOffAfterPrint: 'Desligamento automático após impressão',
@@ -216,6 +217,19 @@ export default {
     skipObject: 'Ignorar objeto',
     skipObject: 'Ignorar objeto',
     reconnect: 'Reconectar',
     reconnect: 'Reconectar',
     mqttDebug: 'Depuração MQTT',
     mqttDebug: 'Depuração MQTT',
+    printerInformation: 'Informações da impressora',
+    copyToClipboard: 'Copiar',
+    copied: 'Copiado!',
+    state: 'Estado',
+    wifiSignalLabel: 'Sinal WiFi',
+    developerMode: 'Modo desenvolvedor',
+    enabled: 'Ativado',
+    disabled: 'Desativado',
+    addedOn: 'Adicionada em',
+    sdCard: 'Cartão SD',
+    inserted: 'Inserido',
+    notInserted: 'Não inserido',
+    totalPrintHours: 'Horas de impressão',
     activeNozzle: 'Ativo: {{nozzle}} bico',
     activeNozzle: 'Ativo: {{nozzle}} bico',
     nozzleRack: 'Suporte de bicos',
     nozzleRack: 'Suporte de bicos',
     nozzleDocked: 'Acoplado',
     nozzleDocked: 'Acoplado',
@@ -464,6 +478,8 @@ export default {
     },
     },
     developerModeWarning: 'O modo desenvolvedor LAN não está ativado em: {{names}}. Alguns recursos podem não funcionar.',
     developerModeWarning: 'O modo desenvolvedor LAN não está ativado em: {{names}}. Alguns recursos podem não funcionar.',
     howToEnable: 'Como ativar',
     howToEnable: 'Como ativar',
+    incompatibleFile: 'Este arquivo foi fatiado para {{slicedFor}}, mas esta impressora é uma {{printerModel}}',
+    dropNotPrintable: 'Apenas arquivos .gcode e .gcode.3mf podem ser impressos',
     dropToPrint: 'Solte para imprimir',
     dropToPrint: 'Solte para imprimir',
     cannotPrint: 'Impressora ocupada',
     cannotPrint: 'Impressora ocupada',
   },
   },
@@ -2245,7 +2261,8 @@ export default {
     willBeExtracted: 'Será extraído',
     willBeExtracted: 'Será extraído',
     filesExtracted: '{{count}} arquivos extraídos',
     filesExtracted: '{{count}} arquivos extraídos',
     uploadComplete: 'Upload concluído: {{succeeded}} bem-sucedidos',
     uploadComplete: 'Upload concluído: {{succeeded}} bem-sucedidos',
-    uploadFailed: '{{count}} falhou',
+    uploadFailed: 'Falha no envio',
+    zipFilesFailed: '{{count}} arquivos falharam',
     uploading: 'Enviando...',
     uploading: 'Enviando...',
     changeLink: 'Alterar link...',
     changeLink: 'Alterar link...',
     linkTo: 'Vincular a...',
     linkTo: 'Vincular a...',

+ 3 - 331
frontend/src/pages/FileManagerPage.tsx

@@ -1,4 +1,4 @@
-import { useState, useRef, useCallback, useMemo, useEffect, type DragEvent } from 'react';
+import { useState, useRef, useCallback, useMemo, useEffect } from 'react';
 import { useSearchParams } from 'react-router-dom';
 import { useSearchParams } from 'react-router-dom';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
@@ -27,8 +27,6 @@ import {
   AlertTriangle,
   AlertTriangle,
   Filter,
   Filter,
   X,
   X,
-  CheckCircle,
-  XCircle,
   Link2,
   Link2,
   Unlink,
   Unlink,
   Archive as ArchiveIcon,
   Archive as ArchiveIcon,
@@ -54,6 +52,7 @@ import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { PrintModal } from '../components/PrintModal';
 import { PrintModal } from '../components/PrintModal';
 import { ModelViewerModal } from '../components/ModelViewerModal';
 import { ModelViewerModal } from '../components/ModelViewerModal';
+import { FileUploadModal } from '../components/FileUploadModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
@@ -404,332 +403,6 @@ function LinkFolderModal({ folder, onClose, onLink, isLoading, t }: LinkFolderMo
   );
   );
 }
 }
 
 
-// Upload Modal with Drag & Drop
-interface UploadModalProps {
-  folderId: number | null;
-  onClose: () => void;
-  onUploadComplete: () => void;
-  t: TFunction;
-}
-
-interface UploadFile {
-  file: File;
-  status: 'pending' | 'uploading' | 'success' | 'error';
-  error?: string;
-  isZip?: boolean;
-  is3mf?: boolean;
-  extractedCount?: number;
-}
-
-function UploadModal({ folderId, onClose, onUploadComplete, t }: UploadModalProps) {
-  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 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);
-    const droppedFiles = Array.from(e.dataTransfer.files);
-    addFiles(droppedFiles);
-  };
-
-  const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
-    if (e.target.files) {
-      addFiles(Array.from(e.target.files));
-    }
-  };
-
-  const addFiles = (newFiles: File[]) => {
-    const uploadFiles: UploadFile[] = newFiles.map((file) => ({
-      file,
-      status: 'pending',
-      isZip: file.name.toLowerCase().endsWith('.zip'),
-      is3mf: file.name.toLowerCase().endsWith('.3mf'),
-    }));
-    setFiles((prev) => [...prev, ...uploadFiles]);
-  };
-
-  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 handleUpload = async () => {
-    if (files.length === 0) return;
-
-    setIsUploading(true);
-
-    // Handle all files with library upload (ZIP and regular files including .3mf)
-    for (let i = 0; i < files.length; i++) {
-      if (files[i].status !== 'pending') continue;
-
-      setFiles((prev) =>
-        prev.map((f, idx) => (idx === i ? { ...f, status: 'uploading' } : f))
-      );
-
-      try {
-        if (files[i].isZip) {
-          // Extract ZIP file
-          const result = await api.extractZipFile(files[i].file, folderId, preserveZipStructure, createFolderFromZip, generateStlThumbnails);
-          setFiles((prev) =>
-            prev.map((f, idx) =>
-              idx === i
-                ? {
-                    ...f,
-                    status: result.errors.length > 0 && result.extracted === 0 ? 'error' : 'success',
-                    extractedCount: result.extracted,
-                    error: result.errors.length > 0 ? `${result.errors.length} files failed` : undefined,
-                  }
-                : f
-            )
-          );
-        } else {
-          // Regular file upload (STL, .3mf, etc.) - .3mf files automatically get metadata extracted
-          await api.uploadLibraryFile(files[i].file, folderId, generateStlThumbnails);
-          setFiles((prev) =>
-            prev.map((f, idx) => (idx === i ? { ...f, status: 'success' } : f))
-          );
-        }
-      } catch (err) {
-        setFiles((prev) =>
-          prev.map((f, idx) =>
-            idx === i
-              ? { ...f, status: 'error', error: err instanceof Error ? err.message : 'Upload failed' }
-              : f
-          )
-        );
-      }
-    }
-
-    setIsUploading(false);
-    onUploadComplete();
-    // Auto-close modal after upload completes
-    onClose();
-  };
-
-  const pendingCount = files.filter((f) => f.status === 'pending').length;
-  const successCount = files.filter((f) => f.status === 'success').length;
-  const errorCount = files.filter((f) => f.status === 'error').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
-            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 - Advanced Extraction */}
-          {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 - show for STL files or ZIP files (which may contain STLs) */}
-          {(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>
-          )}
-
-          {/* Summary */}
-          {allDone && (
-            <div className="p-3 bg-bambu-dark rounded-lg">
-              <p className="text-sm text-white">
-                {t('fileManager.uploadComplete', { succeeded: successCount })}
-                {errorCount > 0 && <span className="text-red-400">, {t('fileManager.uploadFailed', { count: errorCount })}</span>}
-              </p>
-            </div>
-          )}
-        </div>
-
-        <div className="p-4 border-t border-bambu-dark-tertiary flex justify-end gap-2">
-          <Button variant="secondary" onClick={onClose}>
-            {allDone ? t('common.close') : t('common.cancel')}
-          </Button>
-          {!allDone && (
-            <Button
-              onClick={handleUpload}
-              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>
-  );
-}
-
 // Folder Tree Item
 // Folder Tree Item
 interface FolderTreeItemProps {
 interface FolderTreeItemProps {
   folder: LibraryFolderTree;
   folder: LibraryFolderTree;
@@ -2228,11 +1901,10 @@ export function FileManagerPage() {
       )}
       )}
 
 
       {showUploadModal && (
       {showUploadModal && (
-        <UploadModal
+        <FileUploadModal
           folderId={selectedFolderId}
           folderId={selectedFolderId}
           onClose={() => setShowUploadModal(false)}
           onClose={() => setShowUploadModal(false)}
           onUploadComplete={handleUploadComplete}
           onUploadComplete={handleUploadComplete}
-          t={t}
         />
         />
       )}
       )}
 
 

+ 189 - 43
frontend/src/pages/PrintersPage.tsx

@@ -42,6 +42,8 @@ import {
   XCircle,
   XCircle,
   User,
   User,
   Home,
   Home,
+  Printer as PrinterIcon,
+  Info,
 } from 'lucide-react';
 } from 'lucide-react';
 
 
 import { useNavigate } from 'react-router-dom';
 import { useNavigate } from 'react-router-dom';
@@ -64,7 +66,11 @@ import { ConfigureAmsSlotModal } from '../components/ConfigureAmsSlotModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { ChamberLight } from '../components/icons/ChamberLight';
 import { ChamberLight } from '../components/icons/ChamberLight';
 import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModal';
 import { SkipObjectsModal, SkipObjectsIcon } from '../components/SkipObjectsModal';
+import { FileUploadModal } from '../components/FileUploadModal';
+import { PrintModal } from '../components/PrintModal';
+import { PrinterInfoModal } from '../components/PrinterInfoModal';
 import { getGlobalTrayId } from '../utils/amsHelpers';
 import { getGlobalTrayId } from '../utils/amsHelpers';
+import { getPrinterImage, getWifiStrength } from '../utils/printer';
 
 
 // Complete Bambu Lab filament color mapping by tray_id_name
 // Complete Bambu Lab filament color mapping by tray_id_name
 // Source: https://github.com/queengooborg/Bambu-Lab-RFID-Library
 // Source: https://github.com/queengooborg/Bambu-Lab-RFID-Library
@@ -1088,36 +1094,6 @@ function getSpoolmanFillLevel(
   ));
   ));
 }
 }
 
 
-function getPrinterImage(model: string | null | undefined): string {
-  if (!model) return '/img/printers/default.png';
-
-  const modelLower = model.toLowerCase().replace(/\s+/g, '');
-
-  // Map model names to image files
-  if (modelLower.includes('x1e')) return '/img/printers/x1e.png';
-  if (modelLower.includes('x1c') || modelLower.includes('x1carbon')) return '/img/printers/x1c.png';
-  if (modelLower.includes('x1')) return '/img/printers/x1c.png';
-  if (modelLower.includes('h2dpro') || modelLower.includes('h2d-pro')) return '/img/printers/h2dpro.png';
-  if (modelLower.includes('h2d')) return '/img/printers/h2d.png';
-  if (modelLower.includes('h2c')) return '/img/printers/h2c.png';
-  if (modelLower.includes('h2s')) return '/img/printers/h2d.png';
-  if (modelLower.includes('p2s')) return '/img/printers/p1s.png';
-  if (modelLower.includes('p1s')) return '/img/printers/p1s.png';
-  if (modelLower.includes('p1p')) return '/img/printers/p1p.png';
-  if (modelLower.includes('a1mini')) return '/img/printers/a1mini.png';
-  if (modelLower.includes('a1')) return '/img/printers/a1.png';
-
-  return '/img/printers/default.png';
-}
-
-function getWifiStrength(rssi: number): { labelKey: string; color: string; bars: number } {
-  if (rssi >= -50) return { labelKey: 'printers.wifiSignal.excellent', color: 'text-bambu-green', bars: 4 };
-  if (rssi >= -60) return { labelKey: 'printers.wifiSignal.good', color: 'text-bambu-green', bars: 3 };
-  if (rssi >= -70) return { labelKey: 'printers.wifiSignal.fair', color: 'text-yellow-400', bars: 2 };
-  if (rssi >= -80) return { labelKey: 'printers.wifiSignal.weak', color: 'text-orange-400', bars: 1 };
-  return { labelKey: 'printers.wifiSignal.veryWeak', color: 'text-red-400', bars: 1 };
-}
-
 /**
 /**
  * Check if a tray contains a Bambu Lab spool (RFID-tagged).
  * Check if a tray contains a Bambu Lab spool (RFID-tagged).
  * Only checks hardware identifiers (tray_uuid, tag_uid) — NOT tray_info_idx,
  * Only checks hardware identifiers (tray_uuid, tag_uid) — NOT tray_info_idx,
@@ -1452,6 +1428,13 @@ function PrinterCard({
   const [showPauseConfirm, setShowPauseConfirm] = useState(false);
   const [showPauseConfirm, setShowPauseConfirm] = useState(false);
   const [showResumeConfirm, setShowResumeConfirm] = useState(false);
   const [showResumeConfirm, setShowResumeConfirm] = useState(false);
   const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false);
   const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false);
+  const [showUploadForPrint, setShowUploadForPrint] = useState(false);
+  const [showPrinterInfo, setShowPrinterInfo] = useState(false);
+  const closePrinterInfo = useCallback(() => setShowPrinterInfo(false), []);
+  const [printAfterUpload, setPrintAfterUpload] = useState<{ id: number; filename: string } | null>(null);
+  const [isDraggingFile, setIsDraggingFile] = useState(false);
+  const [isDropUploading, setIsDropUploading] = useState(false);
+  const dragCounterRef = useRef(0);
   const [amsHistoryModal, setAmsHistoryModal] = useState<{
   const [amsHistoryModal, setAmsHistoryModal] = useState<{
     amsId: number;
     amsId: number;
     amsLabel: string;
     amsLabel: string;
@@ -2108,8 +2091,106 @@ function PrinterCard({
     }
     }
   };
   };
 
 
+  const canDrop = isConnected && status?.state !== 'RUNNING' && status?.state !== 'PAUSE' && hasPermission('printers:control');
+
+  const handleCardDragEnter = (e: React.DragEvent) => {
+    e.preventDefault();
+    dragCounterRef.current++;
+    if (dragCounterRef.current === 1) setIsDraggingFile(true);
+  };
+
+  const handleCardDragOver = (e: React.DragEvent) => {
+    e.preventDefault();
+    e.dataTransfer.dropEffect = canDrop ? 'copy' : 'none';
+  };
+
+  const handleCardDragLeave = (e: React.DragEvent) => {
+    e.preventDefault();
+    dragCounterRef.current--;
+    if (dragCounterRef.current === 0) setIsDraggingFile(false);
+  };
+
+  const handleCardDrop = async (e: React.DragEvent) => {
+    e.preventDefault();
+    dragCounterRef.current = 0;
+    setIsDraggingFile(false);
+
+    if (!canDrop) return;
+
+    const droppedFiles = Array.from(e.dataTransfer.files);
+    const file = droppedFiles[0];
+    if (!file) return;
+
+    // Only accept sliced/printable files (.gcode, .gcode.3mf, etc.)
+    const lower = file.name.toLowerCase();
+    if (!lower.endsWith('.gcode') && !lower.includes('.gcode.')) {
+      showToast(t('printers.dropNotPrintable', 'Only .gcode and .gcode.3mf files can be printed'), 'error');
+      return;
+    }
+
+    setIsDropUploading(true);
+    try {
+      const result = await api.uploadLibraryFile(file, null);
+
+      // Check printer compatibility if sliced_for_model is available in metadata
+      const slicedFor = (result.metadata as Record<string, unknown>)?.sliced_for_model as string | undefined;
+      const printerModel = mapModelCode(printer.model);
+      if (slicedFor && printerModel && slicedFor.toLowerCase() !== printerModel.toLowerCase()) {
+        await api.deleteLibraryFile(result.id).catch(() => {});
+        showToast(
+          t('printers.incompatibleFile', 'This file was sliced for {{slicedFor}}, but this printer is a {{printerModel}}', { slicedFor, printerModel }),
+          'error'
+        );
+        return;
+      }
+
+      setPrintAfterUpload({ id: result.id, filename: result.filename });
+    } catch {
+      showToast(t('common.uploadFailed', 'Upload failed'), 'error');
+    } finally {
+      setIsDropUploading(false);
+    }
+  };
+
   return (
   return (
-    <Card className="relative">
+    <Card
+      className="relative"
+      onDragEnter={handleCardDragEnter}
+      onDragOver={handleCardDragOver}
+      onDragLeave={handleCardDragLeave}
+      onDrop={handleCardDrop}
+    >
+      {/* Drop zone overlay */}
+      {(isDraggingFile || isDropUploading) && (
+        <div
+          className={`absolute inset-0 z-10 rounded-xl border-2 border-dashed flex items-center justify-center transition-colors ${
+            isDropUploading
+              ? 'bg-bambu-green/10 border-bambu-green/50'
+              : canDrop
+                ? 'bg-bambu-green/10 border-bambu-green'
+                : 'bg-red-500/10 border-red-500/50'
+          }`}
+        >
+          <div className="text-center">
+            {isDropUploading ? (
+              <>
+                <Loader2 className="w-8 h-8 mx-auto mb-2 text-bambu-green animate-spin" />
+                <p className="text-sm font-medium text-bambu-green">{t('common.uploading', 'Uploading...')}</p>
+              </>
+            ) : canDrop ? (
+              <>
+                <PrinterIcon className="w-8 h-8 mx-auto mb-2 text-bambu-green" />
+                <p className="text-sm font-medium text-bambu-green">{t('printers.dropToPrint', 'Drop to print')}</p>
+              </>
+            ) : (
+              <>
+                <X className="w-8 h-8 mx-auto mb-2 text-red-400" />
+                <p className="text-sm font-medium text-red-400">{t('printers.cannotPrint', 'Printer busy')}</p>
+              </>
+            )}
+          </div>
+        </div>
+      )}
       <CardContent className={cardSize >= 3 ? 'p-5' : ''}>
       <CardContent className={cardSize >= 3 ? 'p-5' : ''}>
         {/* Header */}
         {/* Header */}
         <div className={getSpacing()}>
         <div className={getSpacing()}>
@@ -2179,6 +2260,16 @@ function PrinterCard({
                     <Pencil className="w-4 h-4" />
                     <Pencil className="w-4 h-4" />
                     {t('common.edit')}
                     {t('common.edit')}
                   </button>
                   </button>
+                  <button
+                    className="w-full px-4 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2"
+                    onClick={() => {
+                      setShowPrinterInfo(true);
+                      setShowMenu(false);
+                    }}
+                  >
+                    <Info className="w-4 h-4" />
+                    {t('printers.printerInformation')}
+                  </button>
                   <button
                   <button
                     className="w-full px-4 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2"
                     className="w-full px-4 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2"
                     onClick={() => {
                     onClick={() => {
@@ -2687,6 +2778,7 @@ function PrinterCard({
                           {chamberFan ?? 0}%
                           {chamberFan ?? 0}%
                         </span>
                         </span>
                       </div>
                       </div>
+
                     </div>
                     </div>
 
 
                     {/* Right: Print Control Buttons */}
                     {/* Right: Print Control Buttons */}
@@ -3572,22 +3664,17 @@ function PrinterCard({
 
 
         {/* Connection Info & Actions - hidden in compact mode */}
         {/* Connection Info & Actions - hidden in compact mode */}
         {viewMode === 'expanded' && (
         {viewMode === 'expanded' && (
-          <div className="mt-4 pt-4 border-t border-bambu-dark-tertiary flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
-            <div className="text-xs text-bambu-gray">
-              <p>{printer.ip_address}</p>
-              <p className="truncate">{printer.serial_number}</p>
-            </div>
-            <div className="flex items-center gap-2 flex-wrap">
-              {/* Chamber Light Toggle */}
+          <div className="mt-4 pt-4 border-t border-bambu-dark-tertiary flex items-center justify-end gap-2 flex-wrap">
+              {/* Chamber Light */}
               <Button
               <Button
                 variant="secondary"
                 variant="secondary"
                 size="sm"
                 size="sm"
                 onClick={() => chamberLightMutation.mutate(!status?.chamber_light)}
                 onClick={() => chamberLightMutation.mutate(!status?.chamber_light)}
                 disabled={!status?.connected || chamberLightMutation.isPending || !hasPermission('printers:control')}
                 disabled={!status?.connected || chamberLightMutation.isPending || !hasPermission('printers:control')}
                 title={!hasPermission('printers:control') ? t('printers.permission.noControl') : (status?.chamber_light ? t('printers.chamberLightOff') : t('printers.chamberLightOn'))}
                 title={!hasPermission('printers:control') ? t('printers.permission.noControl') : (status?.chamber_light ? t('printers.chamberLightOff') : t('printers.chamberLightOn'))}
-                className={status?.chamber_light ? 'bg-yellow-500/20 hover:bg-yellow-500/30 border-yellow-500/30' : ''}
+                className={status?.chamber_light ? '!border-yellow-500 !text-yellow-400 hover:!bg-yellow-500/20' : ''}
               >
               >
-                <ChamberLight on={status?.chamber_light ?? false} className="w-4 h-4" />
+                <ChamberLight on={status?.chamber_light ?? false} className={`w-4 h-4 ${status?.chamber_light ? 'text-yellow-400' : ''}`} />
               </Button>
               </Button>
               {/* Camera Button */}
               {/* Camera Button */}
               <Button
               <Button
@@ -3650,13 +3737,24 @@ function PrinterCard({
                 variant="secondary"
                 variant="secondary"
                 size="sm"
                 size="sm"
                 onClick={() => setShowFileManager(true)}
                 onClick={() => setShowFileManager(true)}
-                disabled={!hasPermission('printers:files')}
+                disabled={!isConnected || !hasPermission('printers:files')}
                 title={!hasPermission('printers:files') ? t('printers.permission.noFiles') : t('printers.browseFiles')}
                 title={!hasPermission('printers:files') ? t('printers.permission.noFiles') : t('printers.browseFiles')}
               >
               >
                 <HardDrive className="w-4 h-4" />
                 <HardDrive className="w-4 h-4" />
-                Files
+                {t('printers.files')}
               </Button>
               </Button>
-            </div>
+              {isConnected && status?.state !== 'RUNNING' && status?.state !== 'PAUSE' && (
+                <Button
+                  size="sm"
+                  onClick={() => setShowUploadForPrint(true)}
+                  disabled={!hasPermission('printers:control')}
+                  title={!hasPermission('printers:control') ? t('printers.permission.noControl') : t('common.print')}
+                  className="!bg-bambu-green hover:!bg-bambu-green/80 !text-white"
+                >
+                  <PrinterIcon className="w-4 h-4" />
+                  {t('common.print')}
+                </Button>
+              )}
           </div>
           </div>
         )}
         )}
       </CardContent>
       </CardContent>
@@ -3670,6 +3768,45 @@ function PrinterCard({
         />
         />
       )}
       )}
 
 
+      {/* Upload for Print Modal */}
+      {showUploadForPrint && (
+        <FileUploadModal
+          folderId={null}
+          onClose={() => setShowUploadForPrint(false)}
+          onUploadComplete={() => {}}
+          autoUpload
+          accept=".gcode,.3mf"
+          validateFile={(file) => {
+            const lower = file.name.toLowerCase();
+            if (!lower.endsWith('.gcode') && !lower.includes('.gcode.')) {
+              return t('printers.dropNotPrintable', 'Only .gcode and .gcode.3mf files can be printed');
+            }
+          }}
+          onFileUploaded={(uploadedFile) => {
+            // Check printer compatibility if sliced_for_model is available in metadata
+            const slicedFor = (uploadedFile.metadata as Record<string, unknown>)?.sliced_for_model as string | undefined;
+            const printerModel = mapModelCode(printer.model);
+            if (slicedFor && printerModel && slicedFor.toLowerCase() !== printerModel.toLowerCase()) {
+              api.deleteLibraryFile(uploadedFile.id).catch(() => {});
+              return t('printers.incompatibleFile', 'This file was sliced for {{slicedFor}}, but this printer is a {{printerModel}}', { slicedFor, printerModel });
+            }
+            setPrintAfterUpload({ id: uploadedFile.id, filename: uploadedFile.filename });
+          }}
+        />
+      )}
+
+      {/* Print Modal (after upload) */}
+      {printAfterUpload && (
+        <PrintModal
+          mode="reprint"
+          libraryFileId={printAfterUpload.id}
+          archiveName={printAfterUpload.filename}
+          initialSelectedPrinterIds={[printer.id]}
+          onClose={() => setPrintAfterUpload(null)}
+          onSuccess={() => setPrintAfterUpload(null)}
+        />
+      )}
+
       {/* MQTT Debug Modal */}
       {/* MQTT Debug Modal */}
       {showMQTTDebug && (
       {showMQTTDebug && (
         <MQTTDebugModal
         <MQTTDebugModal
@@ -3679,6 +3816,15 @@ function PrinterCard({
         />
         />
       )}
       )}
 
 
+      {showPrinterInfo && (
+        <PrinterInfoModal
+          printer={printer}
+          status={status}
+          totalPrintHours={maintenanceInfo?.total_print_hours}
+          onClose={closePrinterInfo}
+        />
+      )}
+
       {/* Plate Check Result Modal */}
       {/* Plate Check Result Modal */}
       {plateCheckResult && (
       {plateCheckResult && (
         <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={() => closePlateCheckModal()}>
         <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={() => closePlateCheckModal()}>

+ 25 - 0
frontend/src/utils/printer.ts

@@ -0,0 +1,25 @@
+export function getPrinterImage(model: string | null | undefined): string {
+  if (!model) return '/img/printers/default.png';
+  const m = model.toLowerCase().replace(/\s+/g, '');
+  if (m.includes('x1e')) return '/img/printers/x1e.png';
+  if (m.includes('x1c') || m.includes('x1carbon')) return '/img/printers/x1c.png';
+  if (m.includes('x1')) return '/img/printers/x1c.png';
+  if (m.includes('h2dpro') || m.includes('h2d-pro')) return '/img/printers/h2dpro.png';
+  if (m.includes('h2d')) return '/img/printers/h2d.png';
+  if (m.includes('h2c')) return '/img/printers/h2c.png';
+  if (m.includes('h2s')) return '/img/printers/h2d.png';
+  if (m.includes('p2s')) return '/img/printers/p1s.png';
+  if (m.includes('p1s')) return '/img/printers/p1s.png';
+  if (m.includes('p1p')) return '/img/printers/p1p.png';
+  if (m.includes('a1mini')) return '/img/printers/a1mini.png';
+  if (m.includes('a1')) return '/img/printers/a1.png';
+  return '/img/printers/default.png';
+}
+
+export function getWifiStrength(rssi: number): { labelKey: string; color: string; bars: number } {
+  if (rssi >= -50) return { labelKey: 'printers.wifiSignal.excellent', color: 'text-bambu-green', bars: 4 };
+  if (rssi >= -60) return { labelKey: 'printers.wifiSignal.good', color: 'text-bambu-green', bars: 3 };
+  if (rssi >= -70) return { labelKey: 'printers.wifiSignal.fair', color: 'text-yellow-400', bars: 2 };
+  if (rssi >= -80) return { labelKey: 'printers.wifiSignal.weak', color: 'text-orange-400', bars: 1 };
+  return { labelKey: 'printers.wifiSignal.veryWeak', color: 'text-red-400', bars: 1 };
+}

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-C8l0CpBG.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DCSTzx1f.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DweJfV_v.js


Some files were not shown because too many files changed in this diff