瀏覽代碼

[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 月之前
父節點
當前提交
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();
       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 () => {
       const user = userEvent.setup();
       render(<FileManagerPage />);
@@ -600,17 +621,12 @@ describe('FileManagerPage', () => {
         expect(screen.getByText('Upload Files')).toBeInTheDocument();
       });
 
-      // Create a mock 3MF file
       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;
       expect(fileInput).toBeInTheDocument();
 
-      // Simulate file selection
       await user.upload(fileInput, threemfFile);
 
-      // 3MF extraction info should appear
       await waitFor(() => {
         expect(screen.getByText('3MF files detected')).toBeInTheDocument();
         expect(screen.getByText(/Printer model.*will be automatically extracted/i)).toBeInTheDocument();
@@ -631,22 +647,106 @@ describe('FileManagerPage', () => {
         expect(screen.getByText('Upload Files')).toBeInTheDocument();
       });
 
-      // Create a mock STL file
       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;
       expect(fileInput).toBeInTheDocument();
 
-      // Simulate file selection
       await user.upload(fileInput, stlFile);
 
-      // STL thumbnail option should appear
       await waitFor(() => {
         expect(screen.getByText('STL thumbnail generation')).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', () => {

+ 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,
   archiveName,
   queueItem,
+  initialSelectedPrinterIds,
   onClose,
   onSuccess,
 }: PrintModalProps) {
@@ -60,6 +61,9 @@ export function PrintModal({
     if (mode === 'edit-queue-item' && queueItem?.printer_id) {
       return [queueItem.printer_id];
     }
+    if (initialSelectedPrinterIds?.length) {
+      return initialSelectedPrinterIds;
+    }
     return [];
   });
 
@@ -695,26 +699,28 @@ export function PrintModal({
               onSelect={setSelectedPlate}
             />
 
-            {/* Printer selection with per-printer mapping */}
-            <PrinterSelector
-              printers={printers || []}
-              selectedPrinterIds={selectedPrinters}
-              onMultiSelect={setSelectedPrinters}
-              isLoading={loadingPrinters}
-              allowMultiple={true}
-              showInactive={mode === 'edit-queue-item'}
-              printerMappingResults={multiPrinterMapping.printerResults}
-              filamentReqs={effectiveFilamentReqs}
-              onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
-              onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig}
-              assignmentMode={mode === 'reprint' ? 'printer' : assignmentMode}
-              onAssignmentModeChange={mode !== 'reprint' ? setAssignmentMode : undefined}
-              targetModel={targetModel}
-              onTargetModelChange={mode !== 'reprint' ? setTargetModel : undefined}
-              targetLocation={targetLocation}
-              onTargetLocationChange={mode !== 'reprint' ? setTargetLocation : undefined}
-              slicedForModel={slicedForModel}
-            />
+            {/* Printer selection with per-printer mapping — hidden when printer is pre-selected via props */}
+            {!initialSelectedPrinterIds?.length && (
+              <PrinterSelector
+                printers={printers || []}
+                selectedPrinterIds={selectedPrinters}
+                onMultiSelect={setSelectedPrinters}
+                isLoading={loadingPrinters}
+                allowMultiple={true}
+                showInactive={mode === 'edit-queue-item'}
+                printerMappingResults={multiPrinterMapping.printerResults}
+                filamentReqs={effectiveFilamentReqs}
+                onAutoConfigurePrinter={multiPrinterMapping.autoConfigurePrinter}
+                onUpdatePrinterConfig={multiPrinterMapping.updatePrinterConfig}
+                assignmentMode={mode === 'reprint' ? 'printer' : assignmentMode}
+                onAssignmentModeChange={mode !== 'reprint' ? setAssignmentMode : undefined}
+                targetModel={targetModel}
+                onTargetModelChange={mode !== 'reprint' ? setTargetModel : undefined}
+                targetLocation={targetLocation}
+                onTargetLocationChange={mode !== 'reprint' ? setTargetLocation : undefined}
+                slicedForModel={slicedForModel}
+              />
+            )}
 
             {/* Filament override - shown in model mode when filament requirements are available */}
             {assignmentMode === 'model' && targetModel && effectiveFilamentReqs && availableFilaments && availableFilaments.length > 0 && (
@@ -759,7 +765,7 @@ export function PrintModal({
                 filamentReqs={effectiveFilamentReqs}
                 manualMappings={manualMappings}
                 onManualMappingChange={setManualMappings}
-                defaultExpanded={settings?.per_printer_mapping_expanded ?? false}
+                defaultExpanded={!!initialSelectedPrinterIds?.length || (settings?.per_printer_mapping_expanded ?? false)}
                 currencySymbol={currencySymbol}
                 defaultCostPerKg={defaultCostPerKg}
               />
@@ -767,7 +773,7 @@ export function PrintModal({
 
             {/* Print options */}
             {(mode === 'reprint' || effectivePrinterCount > 0 || (assignmentMode === 'model' && targetModel)) && (
-              <PrintOptionsPanel options={printOptions} onChange={setPrintOptions} />
+              <PrintOptionsPanel options={printOptions} onChange={setPrintOptions} defaultExpanded={!!initialSelectedPrinterIds?.length} />
             )}
 
             {/* Schedule options - only for queue modes */}

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

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

+ 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',
     chamberLightOff: 'Kammerbeleuchtung ausschalten',
     // Files
+    files: 'Dateien',
     browseFiles: 'Druckerdateien durchsuchen',
     // Smart plug
     autoOffAfterPrint: 'Automatisches Ausschalten nach Druck',
@@ -216,6 +217,19 @@ export default {
     skipObject: 'Objekt überspringen',
     reconnect: 'Neu verbinden',
     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',
     nozzleRack: 'Düsenhalter',
     nozzleDocked: 'Angedockt',
@@ -464,6 +478,8 @@ export default {
     },
     developerModeWarning: 'Der Entwickler-LAN-Modus ist nicht aktiviert auf: {{names}}. Einige Funktionen funktionieren möglicherweise nicht.',
     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',
     cannotPrint: 'Drucker beschäftigt',
   },
@@ -2245,7 +2261,8 @@ export default {
     willBeExtracted: 'Wird extrahiert',
     filesExtracted: '{{count}} Dateien extrahiert',
     uploadComplete: 'Upload abgeschlossen: {{succeeded}} erfolgreich',
-    uploadFailed: '{{count}} fehlgeschlagen',
+    uploadFailed: 'Hochladen fehlgeschlagen',
+    zipFilesFailed: '{{count}} Dateien fehlgeschlagen',
     uploading: 'Hochladen...',
     changeLink: 'Verknüpfung ändern...',
     linkTo: 'Verknüpfen mit...',

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

@@ -201,6 +201,7 @@ export default {
     chamberLightOn: 'Turn on chamber light',
     chamberLightOff: 'Turn off chamber light',
     // Files
+    files: 'Files',
     browseFiles: 'Browse printer files',
     // Smart plug
     autoOffAfterPrint: 'Auto power-off after print',
@@ -216,6 +217,19 @@ export default {
     skipObject: 'Skip Object',
     reconnect: 'Reconnect',
     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',
     nozzleRack: 'Nozzle Rack',
     nozzleDocked: 'Docked',
@@ -464,6 +478,8 @@ export default {
     },
     developerModeWarning: 'Developer LAN mode is not enabled on: {{names}}. Some features may not work.',
     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',
     cannotPrint: 'Printer busy',
   },
@@ -2245,7 +2261,8 @@ export default {
     willBeExtracted: 'Will be extracted',
     filesExtracted: '{{count}} files extracted',
     uploadComplete: 'Upload complete: {{succeeded}} succeeded',
-    uploadFailed: '{{count}} failed',
+    uploadFailed: 'Upload failed',
+    zipFilesFailed: '{{count}} files failed',
     uploading: 'Uploading...',
     changeLink: 'Change Link...',
     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',
     chamberLightOff: 'Éteindre la lumière de la chambre',
     // Files
+    files: 'Fichiers',
     browseFiles: 'Parcourir les fichiers de l\'imprimante',
     // Smart plug
     autoOffAfterPrint: 'Extinction auto après impression',
@@ -216,6 +217,19 @@ export default {
     skipObject: 'Sauter l\'objet',
     reconnect: 'Reconnecter',
     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}}',
     nozzleRack: 'Rack à buses',
     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.',
     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',
     cannotPrint: 'Imprimante occupée',
   },
@@ -2233,7 +2249,8 @@ export default {
     willBeExtracted: 'Sera extrait',
     filesExtracted: '{{count}} fichiers extraits',
     uploadComplete: 'Terminé : {{succeeded}} succès',
-    uploadFailed: '{{count}} échecs',
+    uploadFailed: 'Échec du téléversement',
+    zipFilesFailed: '{{count}} fichiers échoués',
     uploading: 'Téléversement...',
     changeLink: 'Modifier lien...',
     linkTo: 'Lier à...',

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

@@ -198,6 +198,7 @@ export default {
     chamberLightOn: 'Accendi luce camera',
     chamberLightOff: 'Spegni luce camera',
     // Files
+    files: 'File',
     browseFiles: 'Sfoglia file stampante',
     // Smart plug
     autoOffAfterPrint: 'Spegnimento automatico dopo stampa',
@@ -213,6 +214,19 @@ export default {
     skipObject: 'Salta Oggetto',
     reconnect: 'Riconnetti',
     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}}',
     nozzleRack: 'Rack Ugelli',
     nozzleDocked: 'Agganciato',
@@ -455,6 +469,8 @@ export default {
     },
     developerModeWarning: 'La modalità sviluppatore LAN non è attivata su: {{names}}. Alcune funzionalità potrebbero non funzionare.',
     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',
     cannotPrint: 'Stampante occupata',
   },
@@ -2050,7 +2066,8 @@ export default {
     willBeExtracted: 'Sara estratto',
     filesExtracted: '{{count}} file estratti',
     uploadComplete: 'Caricamento completato: {{succeeded}} riusciti',
-    uploadFailed: '{{count}} falliti',
+    uploadFailed: 'Caricamento fallito',
+    zipFilesFailed: '{{count}} file falliti',
     uploading: 'Caricamento...',
     changeLink: 'Cambia collegamento...',
     linkTo: 'Collega a...',

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

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

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

@@ -201,6 +201,7 @@ export default {
     chamberLightOn: 'Ligar luz da câmara',
     chamberLightOff: 'Desligar luz da câmara',
     // Files
+    files: 'Arquivos',
     browseFiles: 'Procurar arquivos da impressora',
     // Smart plug
     autoOffAfterPrint: 'Desligamento automático após impressão',
@@ -216,6 +217,19 @@ export default {
     skipObject: 'Ignorar objeto',
     reconnect: 'Reconectar',
     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',
     nozzleRack: 'Suporte de bicos',
     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.',
     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',
     cannotPrint: 'Impressora ocupada',
   },
@@ -2245,7 +2261,8 @@ export default {
     willBeExtracted: 'Será extraído',
     filesExtracted: '{{count}} arquivos extraídos',
     uploadComplete: 'Upload concluído: {{succeeded}} bem-sucedidos',
-    uploadFailed: '{{count}} falhou',
+    uploadFailed: 'Falha no envio',
+    zipFilesFailed: '{{count}} arquivos falharam',
     uploading: 'Enviando...',
     changeLink: 'Alterar link...',
     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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
@@ -27,8 +27,6 @@ import {
   AlertTriangle,
   Filter,
   X,
-  CheckCircle,
-  XCircle,
   Link2,
   Unlink,
   Archive as ArchiveIcon,
@@ -54,6 +52,7 @@ import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { PrintModal } from '../components/PrintModal';
 import { ModelViewerModal } from '../components/ModelViewerModal';
+import { FileUploadModal } from '../components/FileUploadModal';
 import { useToast } from '../contexts/ToastContext';
 import { useIsMobile } from '../hooks/useIsMobile';
 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
 interface FolderTreeItemProps {
   folder: LibraryFolderTree;
@@ -2228,11 +1901,10 @@ export function FileManagerPage() {
       )}
 
       {showUploadModal && (
-        <UploadModal
+        <FileUploadModal
           folderId={selectedFolderId}
           onClose={() => setShowUploadModal(false)}
           onUploadComplete={handleUploadComplete}
-          t={t}
         />
       )}
 

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

@@ -42,6 +42,8 @@ import {
   XCircle,
   User,
   Home,
+  Printer as PrinterIcon,
+  Info,
 } from 'lucide-react';
 
 import { useNavigate } from 'react-router-dom';
@@ -64,7 +66,11 @@ import { ConfigureAmsSlotModal } from '../components/ConfigureAmsSlotModal';
 import { useToast } from '../contexts/ToastContext';
 import { ChamberLight } from '../components/icons/ChamberLight';
 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 { getPrinterImage, getWifiStrength } from '../utils/printer';
 
 // Complete Bambu Lab filament color mapping by tray_id_name
 // 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).
  * Only checks hardware identifiers (tray_uuid, tag_uid) — NOT tray_info_idx,
@@ -1452,6 +1428,13 @@ function PrinterCard({
   const [showPauseConfirm, setShowPauseConfirm] = useState(false);
   const [showResumeConfirm, setShowResumeConfirm] = 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<{
     amsId: number;
     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 (
-    <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' : ''}>
         {/* Header */}
         <div className={getSpacing()}>
@@ -2179,6 +2260,16 @@ function PrinterCard({
                     <Pencil className="w-4 h-4" />
                     {t('common.edit')}
                   </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
                     className="w-full px-4 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2"
                     onClick={() => {
@@ -2687,6 +2778,7 @@ function PrinterCard({
                           {chamberFan ?? 0}%
                         </span>
                       </div>
+
                     </div>
 
                     {/* Right: Print Control Buttons */}
@@ -3572,22 +3664,17 @@ function PrinterCard({
 
         {/* Connection Info & Actions - hidden in compact mode */}
         {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
                 variant="secondary"
                 size="sm"
                 onClick={() => chamberLightMutation.mutate(!status?.chamber_light)}
                 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'))}
-                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>
               {/* Camera Button */}
               <Button
@@ -3650,13 +3737,24 @@ function PrinterCard({
                 variant="secondary"
                 size="sm"
                 onClick={() => setShowFileManager(true)}
-                disabled={!hasPermission('printers:files')}
+                disabled={!isConnected || !hasPermission('printers:files')}
                 title={!hasPermission('printers:files') ? t('printers.permission.noFiles') : t('printers.browseFiles')}
               >
                 <HardDrive className="w-4 h-4" />
-                Files
+                {t('printers.files')}
               </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>
         )}
       </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 */}
       {showMQTTDebug && (
         <MQTTDebugModal
@@ -3679,6 +3816,15 @@ function PrinterCard({
         />
       )}
 
+      {showPrinterInfo && (
+        <PrinterInfoModal
+          printer={printer}
+          status={status}
+          totalPrintHours={maintenanceInfo?.total_print_hours}
+          onClose={closePrinterInfo}
+        />
+      )}
+
       {/* Plate Check Result Modal */}
       {plateCheckResult && (
         <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 };
+}

文件差異過大導致無法顯示
+ 0 - 0
static/assets/index-C8l0CpBG.css


文件差異過大導致無法顯示
+ 0 - 0
static/assets/index-DCSTzx1f.css


文件差異過大導致無法顯示
+ 0 - 0
static/assets/index-DweJfV_v.js


部分文件因文件數量過多而無法顯示