mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
Merge branch '0.2.2b1' into fix-ams-slot
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* Tests for low stock threshold functionality in InventoryPage.
|
||||
*
|
||||
* Tests that the low stock threshold:
|
||||
* - Is loaded from backend settings API
|
||||
* - Can be updated via the UI
|
||||
* - Persists changes to the backend
|
||||
* - Does not use localStorage
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render } from '../utils';
|
||||
import InventoryPageRouter from '../../pages/InventoryPage';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../mocks/server';
|
||||
|
||||
const mockSettings = {
|
||||
auto_archive: true,
|
||||
save_thumbnails: true,
|
||||
capture_finish_photo: true,
|
||||
default_filament_cost: 25.0,
|
||||
currency: 'USD',
|
||||
energy_cost_per_kwh: 0.15,
|
||||
energy_tracking_mode: 'total',
|
||||
spoolman_enabled: false,
|
||||
spoolman_url: '',
|
||||
spoolman_sync_mode: 'auto',
|
||||
spoolman_disable_weight_sync: false,
|
||||
spoolman_report_partial_usage: true,
|
||||
check_updates: true,
|
||||
check_printer_firmware: true,
|
||||
include_beta_updates: false,
|
||||
language: 'en',
|
||||
notification_language: 'en',
|
||||
bed_cooled_threshold: 35,
|
||||
ams_humidity_good: 40,
|
||||
ams_humidity_fair: 60,
|
||||
ams_temp_good: 28,
|
||||
ams_temp_fair: 35,
|
||||
ams_history_retention_days: 30,
|
||||
per_printer_mapping_expanded: false,
|
||||
date_format: 'system',
|
||||
time_format: 'system',
|
||||
default_printer_id: null,
|
||||
virtual_printer_enabled: false,
|
||||
virtual_printer_access_code: '',
|
||||
virtual_printer_mode: 'immediate',
|
||||
dark_style: 'classic',
|
||||
dark_background: 'neutral',
|
||||
dark_accent: 'green',
|
||||
light_style: 'classic',
|
||||
light_background: 'neutral',
|
||||
light_accent: 'green',
|
||||
ftp_retry_enabled: true,
|
||||
ftp_retry_count: 3,
|
||||
ftp_retry_delay: 2,
|
||||
ftp_timeout: 30,
|
||||
mqtt_enabled: false,
|
||||
mqtt_broker: '',
|
||||
mqtt_port: 1883,
|
||||
mqtt_username: '',
|
||||
mqtt_password: '',
|
||||
mqtt_topic_prefix: 'bambuddy',
|
||||
mqtt_use_tls: false,
|
||||
external_url: '',
|
||||
ha_enabled: false,
|
||||
ha_url: '',
|
||||
ha_token: '',
|
||||
ha_url_from_env: false,
|
||||
ha_token_from_env: false,
|
||||
ha_env_managed: false,
|
||||
library_archive_mode: 'ask',
|
||||
library_disk_warning_gb: 5.0,
|
||||
camera_view_mode: 'window',
|
||||
preferred_slicer: 'bambu_studio',
|
||||
prometheus_enabled: false,
|
||||
prometheus_token: '',
|
||||
low_stock_threshold: 20.0,
|
||||
};
|
||||
|
||||
const mockSpools = [
|
||||
{
|
||||
id: 1,
|
||||
material: 'PLA',
|
||||
subtype: null,
|
||||
brand: 'Polymaker',
|
||||
color_name: 'Red',
|
||||
rgba: 'FF0000FF',
|
||||
label_weight: 1000,
|
||||
core_weight: 250,
|
||||
weight_used: 900, // 10% remaining - low stock
|
||||
slicer_filament: null,
|
||||
slicer_filament_name: null,
|
||||
nozzle_temp_min: null,
|
||||
nozzle_temp_max: null,
|
||||
note: null,
|
||||
added_full: null,
|
||||
last_used: null,
|
||||
encode_time: null,
|
||||
tag_uid: null,
|
||||
tray_uuid: null,
|
||||
data_origin: null,
|
||||
tag_type: null,
|
||||
archived_at: null,
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
k_profiles: [],
|
||||
cost_per_kg: null,
|
||||
last_scale_weight: null,
|
||||
last_weighed_at: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
material: 'PETG',
|
||||
subtype: null,
|
||||
brand: 'eSun',
|
||||
color_name: 'Blue',
|
||||
rgba: '0000FFFF',
|
||||
label_weight: 1000,
|
||||
core_weight: 250,
|
||||
weight_used: 200, // 80% remaining - not low stock
|
||||
slicer_filament: null,
|
||||
slicer_filament_name: null,
|
||||
nozzle_temp_min: null,
|
||||
nozzle_temp_max: null,
|
||||
note: null,
|
||||
added_full: null,
|
||||
last_used: null,
|
||||
encode_time: null,
|
||||
tag_uid: null,
|
||||
tray_uuid: null,
|
||||
data_origin: null,
|
||||
tag_type: null,
|
||||
archived_at: null,
|
||||
created_at: '2025-01-02T00:00:00Z',
|
||||
updated_at: '2025-01-02T00:00:00Z',
|
||||
k_profiles: [],
|
||||
cost_per_kg: null,
|
||||
last_scale_weight: null,
|
||||
last_weighed_at: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
material: 'ABS',
|
||||
subtype: null,
|
||||
brand: 'Hatchbox',
|
||||
color_name: 'Black',
|
||||
rgba: '000000FF',
|
||||
label_weight: 1000,
|
||||
core_weight: 250,
|
||||
weight_used: 850, // 15% remaining - low stock
|
||||
slicer_filament: null,
|
||||
slicer_filament_name: null,
|
||||
nozzle_temp_min: null,
|
||||
nozzle_temp_max: null,
|
||||
note: null,
|
||||
added_full: null,
|
||||
last_used: null,
|
||||
encode_time: null,
|
||||
tag_uid: null,
|
||||
tray_uuid: null,
|
||||
data_origin: null,
|
||||
tag_type: null,
|
||||
archived_at: null,
|
||||
created_at: '2025-01-03T00:00:00Z',
|
||||
updated_at: '2025-01-03T00:00:00Z',
|
||||
k_profiles: [],
|
||||
cost_per_kg: null,
|
||||
last_scale_weight: null,
|
||||
last_weighed_at: null,
|
||||
},
|
||||
];
|
||||
|
||||
describe('InventoryPage - Low Stock Threshold', () => {
|
||||
beforeEach(() => {
|
||||
// Clear localStorage to ensure we're not relying on it
|
||||
localStorage.clear();
|
||||
|
||||
server.use(
|
||||
http.get('/api/v1/settings/', () => {
|
||||
return HttpResponse.json(mockSettings);
|
||||
}),
|
||||
http.put('/api/v1/settings/', async ({ request }) => {
|
||||
const body = (await request.json()) as Partial<typeof mockSettings>;
|
||||
return HttpResponse.json({ ...mockSettings, ...body });
|
||||
}),
|
||||
http.get('/api/v1/inventory/spools', () => {
|
||||
return HttpResponse.json(mockSpools);
|
||||
}),
|
||||
http.get('/api/v1/inventory/assignments', () => {
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
http.get('/api/v1/spoolman/settings', () => {
|
||||
return HttpResponse.json({ spoolman_enabled: 'false' });
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe('default threshold from backend', () => {
|
||||
it('loads the default threshold of 20% from backend settings', async () => {
|
||||
render(<InventoryPageRouter />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Find the low stock stat showing the threshold
|
||||
expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calculates low stock count based on default threshold', async () => {
|
||||
render(<InventoryPageRouter />);
|
||||
|
||||
await waitFor(() => {
|
||||
// With default 20% threshold, spools with 10% and 15% remaining should be counted (2 spools)
|
||||
const lowStockSection = screen.getByText(/low stock/i).closest('div');
|
||||
expect(lowStockSection).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not use localStorage for threshold', async () => {
|
||||
// Set a value in localStorage that should be ignored
|
||||
localStorage.setItem('bambuddy-low-stock-threshold', '50');
|
||||
|
||||
render(<InventoryPageRouter />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show backend value (20%), not localStorage value (50%)
|
||||
expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updating threshold via UI', () => {
|
||||
it('shows edit button for threshold', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<InventoryPageRouter />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find the edit button within the low stock threshold section
|
||||
const thresholdText = screen.getByText(/< 20%/i);
|
||||
const editButton = thresholdText.parentElement!.querySelector('button[title]') as HTMLElement;
|
||||
expect(editButton).toBeInTheDocument();
|
||||
|
||||
await user.click(editButton);
|
||||
|
||||
// Input field should appear with default threshold value
|
||||
await waitFor(() => {
|
||||
const input = screen.getByDisplayValue('20');
|
||||
expect(input).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('updates threshold and persists to backend', async () => {
|
||||
const user = userEvent.setup();
|
||||
let updatedSettings: Partial<typeof mockSettings> | null = null;
|
||||
|
||||
server.use(
|
||||
http.put('/api/v1/settings/', async ({ request }) => {
|
||||
const body = (await request.json()) as Partial<typeof mockSettings>;
|
||||
updatedSettings = body;
|
||||
return HttpResponse.json({ ...mockSettings, ...body });
|
||||
})
|
||||
);
|
||||
|
||||
render(<InventoryPageRouter />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click edit button within the low stock threshold section
|
||||
const thresholdText = screen.getByText(/< 20%/i);
|
||||
const editButton = thresholdText.parentElement!.querySelector('button[title]') as HTMLElement;
|
||||
await user.click(editButton);
|
||||
|
||||
// Enter new value
|
||||
const input = screen.getByDisplayValue('20');
|
||||
await user.clear(input);
|
||||
await user.type(input, '15.5');
|
||||
|
||||
// Submit form
|
||||
const saveButton = screen.getByRole('button', { name: /save/i });
|
||||
await user.click(saveButton);
|
||||
|
||||
// Verify API was called with correct value
|
||||
await waitFor(() => {
|
||||
expect(updatedSettings).toEqual({ low_stock_threshold: 15.5 });
|
||||
});
|
||||
});
|
||||
|
||||
it('validates threshold input range', async () => {
|
||||
const user = userEvent.setup();
|
||||
let updatedSettings: Partial<typeof mockSettings> | null = null;
|
||||
|
||||
server.use(
|
||||
http.put('/api/v1/settings/', async ({ request }) => {
|
||||
const body = (await request.json()) as Partial<typeof mockSettings>;
|
||||
updatedSettings = body;
|
||||
return HttpResponse.json({ ...mockSettings, ...body });
|
||||
})
|
||||
);
|
||||
|
||||
render(<InventoryPageRouter />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click edit button within the low stock threshold section
|
||||
const thresholdText = screen.getByText(/< 20%/i);
|
||||
const editButton = thresholdText.parentElement!.querySelector('button[title]') as HTMLElement;
|
||||
await user.click(editButton);
|
||||
|
||||
// Try invalid values
|
||||
const input = screen.getByDisplayValue('20');
|
||||
|
||||
// Too low (0 is below the 0.1 minimum)
|
||||
await user.clear(input);
|
||||
await user.type(input, '0');
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /save/i });
|
||||
await user.click(saveButton);
|
||||
|
||||
// Should show error and NOT call the PUT endpoint
|
||||
await waitFor(() => {
|
||||
expect(updatedSettings).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('allows canceling threshold edit', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<InventoryPageRouter />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click edit button within the low stock threshold section
|
||||
const thresholdText = screen.getByText(/< 20%/i);
|
||||
const editButton = thresholdText.parentElement!.querySelector('button[title]') as HTMLElement;
|
||||
await user.click(editButton);
|
||||
|
||||
// Change value
|
||||
const input = screen.getByDisplayValue('20');
|
||||
await user.clear(input);
|
||||
await user.type(input, '30');
|
||||
|
||||
// Cancel
|
||||
const cancelButton = screen.getByRole('button', { name: /cancel/i });
|
||||
await user.click(cancelButton);
|
||||
|
||||
// Should revert to original display
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom threshold from backend', () => {
|
||||
it('loads custom threshold value from backend', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/settings/', () => {
|
||||
return HttpResponse.json({ ...mockSettings, low_stock_threshold: 25.0 });
|
||||
})
|
||||
);
|
||||
|
||||
render(<InventoryPageRouter />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/< 25%/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('applies custom threshold to low stock filtering', async () => {
|
||||
// With threshold at 30%, all 3 test spools should be low stock (10%, 15%, and we'd need to check 80%)
|
||||
server.use(
|
||||
http.get('/api/v1/settings/', () => {
|
||||
return HttpResponse.json({ ...mockSettings, low_stock_threshold: 30.0 });
|
||||
})
|
||||
);
|
||||
|
||||
render(<InventoryPageRouter />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/< 30%/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The low stock count should reflect the new threshold
|
||||
// Implementation would show appropriate count based on 30% threshold
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -112,12 +112,16 @@ describe('PrintersPage', () => {
|
||||
});
|
||||
|
||||
describe('printer info', () => {
|
||||
it('shows IP address', async () => {
|
||||
it('shows IP address in printer info modal', async () => {
|
||||
render(<PrintersPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('192.168.1.100')).toBeInTheDocument();
|
||||
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// IP address is shown in the PrinterInfoModal (accessed via 3-dot menu),
|
||||
// not directly on the card. Verify the printer data loaded correctly.
|
||||
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows location when set', async () => {
|
||||
|
||||
@@ -837,6 +837,8 @@ export interface AppSettings {
|
||||
prometheus_token: string;
|
||||
// Bed cooled threshold
|
||||
bed_cooled_threshold: number;
|
||||
// Inventory low stock threshold
|
||||
low_stock_threshold: number;
|
||||
}
|
||||
|
||||
export type AppSettingsUpdate = Partial<AppSettings>;
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
import { useState, useRef, type DragEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Upload,
|
||||
X,
|
||||
File,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Archive as ArchiveIcon,
|
||||
Printer,
|
||||
Image,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { LibraryFileUploadResponse } from '../api/client';
|
||||
import { Button } from './Button';
|
||||
|
||||
interface UploadFile {
|
||||
file: File;
|
||||
status: 'pending' | 'uploading' | 'success' | 'error';
|
||||
error?: string;
|
||||
isZip?: boolean;
|
||||
is3mf?: boolean;
|
||||
extractedCount?: number;
|
||||
}
|
||||
|
||||
interface FileUploadModalProps {
|
||||
folderId: number | null;
|
||||
onClose: () => void;
|
||||
onUploadComplete: () => void;
|
||||
/** Called after each file is successfully uploaded with its response data. Return a string to show an error and prevent modal from closing. */
|
||||
onFileUploaded?: (file: LibraryFileUploadResponse) => string | void;
|
||||
/** When true, automatically uploads the file as soon as it's added and closes the modal */
|
||||
autoUpload?: boolean;
|
||||
/** Validate files before adding. Return a string to reject with an error message. */
|
||||
validateFile?: (file: File) => string | undefined;
|
||||
/** Restrict file picker to specific file types (e.g. ".gcode,.gcode.3mf") */
|
||||
accept?: string;
|
||||
}
|
||||
|
||||
export function FileUploadModal({ folderId, onClose, onUploadComplete, onFileUploaded, autoUpload, validateFile, accept }: FileUploadModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [files, setFiles] = useState<UploadFile[]>([]);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [preserveZipStructure, setPreserveZipStructure] = useState(true);
|
||||
const [createFolderFromZip, setCreateFolderFromZip] = useState(false);
|
||||
const [generateStlThumbnails, setGenerateStlThumbnails] = useState(true);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
addFiles(Array.from(e.dataTransfer.files));
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
addFiles(Array.from(e.target.files));
|
||||
}
|
||||
};
|
||||
|
||||
const updateFileStatus = (file: File, update: Partial<UploadFile>) => {
|
||||
setFiles((prev) => prev.map((f) => (f.file === file ? { ...f, ...update } : f)));
|
||||
};
|
||||
|
||||
const uploadFiles = async (filesToUpload: UploadFile[]) => {
|
||||
setIsUploading(true);
|
||||
|
||||
for (const uf of filesToUpload) {
|
||||
if (uf.status !== 'pending') continue;
|
||||
|
||||
updateFileStatus(uf.file, { status: 'uploading' });
|
||||
|
||||
try {
|
||||
if (uf.isZip) {
|
||||
const result = await api.extractZipFile(uf.file, folderId, preserveZipStructure, createFolderFromZip, generateStlThumbnails);
|
||||
updateFileStatus(uf.file, {
|
||||
status: result.errors.length > 0 && result.extracted === 0 ? 'error' : 'success',
|
||||
extractedCount: result.extracted,
|
||||
error: result.errors.length > 0 ? t('fileManager.zipFilesFailed', '{{count}} files failed', { count: result.errors.length }) : undefined,
|
||||
});
|
||||
} else {
|
||||
const result = await api.uploadLibraryFile(uf.file, folderId, generateStlThumbnails);
|
||||
updateFileStatus(uf.file, { status: 'success' });
|
||||
const error = onFileUploaded?.(result);
|
||||
if (error) {
|
||||
setUploadError(error);
|
||||
setFiles([]);
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
updateFileStatus(uf.file, {
|
||||
status: 'error',
|
||||
error: err instanceof Error ? err.message : t('fileManager.uploadFailed', 'Upload failed'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setIsUploading(false);
|
||||
onUploadComplete();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const addFiles = (newFiles: File[]) => {
|
||||
setUploadError(null);
|
||||
if (validateFile) {
|
||||
for (const file of newFiles) {
|
||||
const error = validateFile(file);
|
||||
if (error) {
|
||||
setUploadError(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
const toUpload: UploadFile[] = newFiles.map((file) => ({
|
||||
file,
|
||||
status: 'pending' as const,
|
||||
isZip: file.name.toLowerCase().endsWith('.zip'),
|
||||
is3mf: file.name.toLowerCase().endsWith('.3mf'),
|
||||
}));
|
||||
setFiles((prev) => [...prev, ...toUpload]);
|
||||
|
||||
if (autoUpload && newFiles.length > 0) {
|
||||
uploadFiles(toUpload);
|
||||
}
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const hasZipFiles = files.some((f) => f.isZip && f.status === 'pending');
|
||||
const hasStlFiles = files.some((f) => f.file.name.toLowerCase().endsWith('.stl') && f.status === 'pending');
|
||||
const has3mfFiles = files.some((f) => f.is3mf && f.status === 'pending');
|
||||
const pendingCount = files.filter((f) => f.status === 'pending').length;
|
||||
const allDone = files.length > 0 && pendingCount === 0 && !isUploading;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-bambu-dark-secondary rounded-lg w-full max-w-lg border border-bambu-dark-tertiary">
|
||||
<div className="p-4 border-b border-bambu-dark-tertiary flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-white">{t('fileManager.uploadFiles')}</h2>
|
||||
<button onClick={onClose} className="p-1 hover:bg-bambu-dark rounded">
|
||||
<X className="w-5 h-5 text-bambu-gray" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Drop Zone */}
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
|
||||
isDragging
|
||||
? 'border-bambu-green bg-bambu-green/10'
|
||||
: 'border-bambu-dark-tertiary hover:border-bambu-green/50'
|
||||
}`}
|
||||
>
|
||||
<Upload className={`w-10 h-10 mx-auto mb-3 ${isDragging ? 'text-bambu-green' : 'text-bambu-gray'}`} />
|
||||
<p className="text-white font-medium">
|
||||
{isDragging ? t('fileManager.dropFilesHere') : t('fileManager.dragDropFiles')}
|
||||
</p>
|
||||
<p className="text-sm text-bambu-gray mt-1">{t('fileManager.orClickToBrowse')}</p>
|
||||
<p className="text-xs text-bambu-gray/70 mt-2">{t('fileManager.allFileTypesSupported')}</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={accept}
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
|
||||
{/* ZIP Options */}
|
||||
{hasZipFiles && (
|
||||
<div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<ArchiveIcon className="w-5 h-5 text-blue-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-blue-300 font-medium">{t('fileManager.zipFilesDetected')}</p>
|
||||
<p className="text-xs text-blue-300/70 mt-1">
|
||||
{t('fileManager.zipExtractOptions')}
|
||||
</p>
|
||||
<label className="flex items-center gap-2 mt-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preserveZipStructure}
|
||||
onChange={(e) => setPreserveZipStructure(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
|
||||
/>
|
||||
<span className="text-sm text-white">{t('fileManager.preserveZipStructure')}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 mt-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createFolderFromZip}
|
||||
onChange={(e) => setCreateFolderFromZip(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
|
||||
/>
|
||||
<span className="text-sm text-white">{t('fileManager.createFolderFromZip')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3MF File Info */}
|
||||
{has3mfFiles && (
|
||||
<div className="p-3 bg-purple-500/10 border border-purple-500/30 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<Printer className="w-5 h-5 text-purple-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-purple-300 font-medium">{t('fileManager.threemfDetected')}</p>
|
||||
<p className="text-xs text-purple-300/70 mt-1">
|
||||
{t('fileManager.threemfExtractionInfo')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* STL Thumbnail Options */}
|
||||
{(hasStlFiles || hasZipFiles) && (
|
||||
<div className="p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<Image className="w-5 h-5 text-bambu-green mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-bambu-green font-medium">{t('fileManager.stlThumbnailGeneration')}</p>
|
||||
<p className="text-xs text-bambu-green/70 mt-1">
|
||||
{hasZipFiles && !hasStlFiles
|
||||
? t('fileManager.zipMayContainStl')
|
||||
: t('fileManager.thumbnailsCanBeGenerated')}
|
||||
</p>
|
||||
<label className="flex items-center gap-2 mt-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={generateStlThumbnails}
|
||||
onChange={(e) => setGenerateStlThumbnails(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
|
||||
/>
|
||||
<span className="text-sm text-white">{t('fileManager.generateThumbnailsForStl')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File List */}
|
||||
{files.length > 0 && (
|
||||
<div className="max-h-48 overflow-y-auto space-y-2">
|
||||
{files.map((uploadFile, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-3 p-2 bg-bambu-dark rounded-lg"
|
||||
>
|
||||
{uploadFile.isZip ? (
|
||||
<ArchiveIcon className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
||||
) : (
|
||||
<File className="w-4 h-4 text-bambu-gray flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-white truncate">{uploadFile.file.name}</p>
|
||||
<p className="text-xs text-bambu-gray">
|
||||
{(uploadFile.file.size / 1024 / 1024).toFixed(2)} MB
|
||||
{uploadFile.isZip && uploadFile.status === 'pending' && (
|
||||
<span className="text-blue-400 ml-2">• {t('fileManager.willBeExtracted')}</span>
|
||||
)}
|
||||
{uploadFile.extractedCount !== undefined && (
|
||||
<span className="text-green-400 ml-2">• {t('fileManager.filesExtracted', { count: uploadFile.extractedCount })}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{uploadFile.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => removeFile(index)}
|
||||
className="p-1 hover:bg-bambu-dark-tertiary rounded"
|
||||
>
|
||||
<X className="w-4 h-4 text-bambu-gray" />
|
||||
</button>
|
||||
)}
|
||||
{uploadFile.status === 'uploading' && (
|
||||
<Loader2 className="w-4 h-4 text-bambu-green animate-spin" />
|
||||
)}
|
||||
{uploadFile.status === 'success' && (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
)}
|
||||
{uploadFile.status === 'error' && (
|
||||
<span title={uploadFile.error}>
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Compatibility Error */}
|
||||
{uploadError && (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<XCircle className="w-5 h-5 text-red-400 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-red-300">{uploadError}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-bambu-dark-tertiary flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
{!allDone && (
|
||||
<Button
|
||||
onClick={() => uploadFiles(files)}
|
||||
disabled={pendingCount === 0 || isUploading}
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{t('fileManager.uploading')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{t('common.upload')} {pendingCount > 0 ? `(${pendingCount})` : ''}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,7 @@ export function PrintModal({
|
||||
libraryFileId,
|
||||
archiveName,
|
||||
queueItem,
|
||||
initialSelectedPrinterIds,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: PrintModalProps) {
|
||||
@@ -60,6 +61,9 @@ export function PrintModal({
|
||||
if (mode === 'edit-queue-item' && queueItem?.printer_id) {
|
||||
return [queueItem.printer_id];
|
||||
}
|
||||
if (initialSelectedPrinterIds?.length) {
|
||||
return initialSelectedPrinterIds;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
@@ -677,7 +681,10 @@ export function PrintModal({
|
||||
<p className={`text-sm text-bambu-gray ${mode === 'reprint' ? 'mb-4' : ''}`}>
|
||||
{mode === 'reprint' ? (
|
||||
<>
|
||||
Send <span className="text-white">{archiveName}</span> to printer(s)
|
||||
Send <span className="text-white">{archiveName}</span> to{' '}
|
||||
{initialSelectedPrinterIds?.length === 1 && printers
|
||||
? <span className="text-white">{printers.find(p => p.id === initialSelectedPrinterIds[0])?.name ?? 'printer(s)'}</span>
|
||||
: 'printer(s)'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -695,26 +702,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 +768,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 +776,7 @@ export function PrintModal({
|
||||
|
||||
{/* Print options */}
|
||||
{(mode === 'reprint' || effectivePrinterCount > 0 || (assignmentMode === 'model' && targetModel)) && (
|
||||
<PrintOptionsPanel options={printOptions} onChange={setPrintOptions} />
|
||||
<PrintOptionsPanel options={printOptions} onChange={setPrintOptions} defaultExpanded={!!initialSelectedPrinterIds?.length} />
|
||||
)}
|
||||
|
||||
{/* Schedule options - only for queue modes */}
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface PrintModalProps {
|
||||
archiveName: string;
|
||||
/** Existing queue item (only for edit-queue-item mode) */
|
||||
queueItem?: PrintQueueItem;
|
||||
/** Pre-select specific printers when opening the modal */
|
||||
initialSelectedPrinterIds?: number[];
|
||||
/** Handler for closing the modal */
|
||||
onClose: () => void;
|
||||
/** Handler for successful operation */
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Copy, Check, Signal } from 'lucide-react';
|
||||
import { Card, CardContent } from './Card';
|
||||
import { formatDateOnly } from '../utils/date';
|
||||
import { getPrinterImage, getWifiStrength } from '../utils/printer';
|
||||
import type { Printer, PrinterStatus } from '../api/client';
|
||||
|
||||
interface PrinterInfoModalProps {
|
||||
printer: Printer;
|
||||
status?: PrinterStatus;
|
||||
totalPrintHours?: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function CopyButton({ value }: { value: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard may not be available in non-secure contexts
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="ml-2 p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
|
||||
title={copied ? t('printers.copied') : t('printers.copyToClipboard')}
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5 text-bambu-green" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrinterInfoModal({ printer, status, totalPrintHours, onClose }: PrinterInfoModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, [onClose]);
|
||||
|
||||
const rows: { label: string; value: React.ReactNode }[] = [];
|
||||
|
||||
// Model
|
||||
rows.push({
|
||||
label: t('printers.model'),
|
||||
value: printer.model ?? '—',
|
||||
});
|
||||
|
||||
// Connection Status
|
||||
rows.push({
|
||||
label: t('common.status'),
|
||||
value: (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
status?.connected
|
||||
? 'bg-bambu-green/20 text-bambu-green'
|
||||
: 'bg-red-500/20 text-red-400'
|
||||
}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${status?.connected ? 'bg-bambu-green' : 'bg-red-400'}`} />
|
||||
{status?.connected ? t('printers.status.available') : t('printers.status.offline')}
|
||||
</span>
|
||||
),
|
||||
});
|
||||
|
||||
// State
|
||||
if (status?.state) {
|
||||
const stateMap: Record<string, string> = {
|
||||
IDLE: 'printers.status.idle',
|
||||
RUNNING: 'printers.status.printing',
|
||||
PAUSE: 'printers.status.paused',
|
||||
FINISH: 'printers.status.finished',
|
||||
FAILED: 'printers.status.error',
|
||||
};
|
||||
rows.push({
|
||||
label: t('printers.state'),
|
||||
value: t(stateMap[status.state] ?? 'printers.status.unknown'),
|
||||
});
|
||||
}
|
||||
|
||||
// IP Address
|
||||
rows.push({
|
||||
label: t('printers.ipAddress'),
|
||||
value: (
|
||||
<span className="flex items-center">
|
||||
<span className="font-mono">{printer.ip_address}</span>
|
||||
<CopyButton value={printer.ip_address} />
|
||||
</span>
|
||||
),
|
||||
});
|
||||
|
||||
// Serial Number
|
||||
rows.push({
|
||||
label: t('printers.serialNumber'),
|
||||
value: (
|
||||
<span className="flex items-center">
|
||||
<span className="font-mono truncate">{printer.serial_number}</span>
|
||||
<CopyButton value={printer.serial_number} />
|
||||
</span>
|
||||
),
|
||||
});
|
||||
|
||||
// WiFi Signal
|
||||
if (status?.wifi_signal != null) {
|
||||
const wifi = getWifiStrength(status.wifi_signal);
|
||||
rows.push({
|
||||
label: t('printers.wifiSignalLabel'),
|
||||
value: (
|
||||
<span className="flex items-center gap-2">
|
||||
<Signal className={`w-4 h-4 ${wifi.color}`} />
|
||||
<span className={wifi.color}>{t(wifi.labelKey)}</span>
|
||||
<span className="text-bambu-gray text-xs">({status.wifi_signal} dBm)</span>
|
||||
</span>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Firmware
|
||||
rows.push({
|
||||
label: t('printers.firmware'),
|
||||
value: status?.firmware_version ?? '—',
|
||||
});
|
||||
|
||||
// Developer Mode
|
||||
if (status?.developer_mode != null) {
|
||||
rows.push({
|
||||
label: t('printers.developerMode'),
|
||||
value: (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
status.developer_mode
|
||||
? 'bg-bambu-green/20 text-bambu-green'
|
||||
: 'bg-bambu-dark-tertiary text-bambu-gray'
|
||||
}`}>
|
||||
{status.developer_mode ? t('printers.enabled') : t('printers.disabled')}
|
||||
</span>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Nozzle Count
|
||||
rows.push({
|
||||
label: t('printers.nozzleCount'),
|
||||
value: printer.nozzle_count,
|
||||
});
|
||||
|
||||
// SD Card
|
||||
if (status?.sdcard != null) {
|
||||
rows.push({
|
||||
label: t('printers.sdCard'),
|
||||
value: (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
status.sdcard
|
||||
? 'bg-bambu-green/20 text-bambu-green'
|
||||
: 'bg-bambu-dark-tertiary text-bambu-gray'
|
||||
}`}>
|
||||
{status.sdcard ? t('printers.inserted') : t('printers.notInserted')}
|
||||
</span>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-Archive
|
||||
rows.push({
|
||||
label: t('printers.autoArchive'),
|
||||
value: (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
printer.auto_archive
|
||||
? 'bg-bambu-green/20 text-bambu-green'
|
||||
: 'bg-bambu-dark-tertiary text-bambu-gray'
|
||||
}`}>
|
||||
{printer.auto_archive ? t('printers.enabled') : t('printers.disabled')}
|
||||
</span>
|
||||
),
|
||||
});
|
||||
|
||||
// Total Print Hours
|
||||
if (totalPrintHours != null && totalPrintHours > 0) {
|
||||
rows.push({
|
||||
label: t('printers.totalPrintHours'),
|
||||
value: `${Math.round(totalPrintHours)}h`,
|
||||
});
|
||||
}
|
||||
|
||||
// Location
|
||||
if (printer.location) {
|
||||
rows.push({
|
||||
label: t('printers.sort.location'),
|
||||
value: printer.location,
|
||||
});
|
||||
}
|
||||
|
||||
// Added date
|
||||
rows.push({
|
||||
label: t('printers.addedOn'),
|
||||
value: formatDateOnly(printer.created_at),
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={onClose}
|
||||
>
|
||||
<Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{printer.name}
|
||||
</h2>
|
||||
<button onClick={onClose} className="p-1 hover:bg-bambu-dark rounded flex-shrink-0">
|
||||
<X className="w-5 h-5 text-bambu-gray" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Printer Image */}
|
||||
<div className="flex justify-center mb-4">
|
||||
<img
|
||||
src={getPrinterImage(printer.model)}
|
||||
alt={printer.model ?? printer.name}
|
||||
className="h-24 object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0">
|
||||
{rows.map((row, i) => (
|
||||
<div key={i} className="flex items-center justify-between gap-4 py-2.5 border-b border-bambu-dark-tertiary last:border-0">
|
||||
<span className="text-sm text-bambu-gray whitespace-nowrap">{row.label}</span>
|
||||
<span className="text-sm text-white text-right">{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import fr from './locales/fr';
|
||||
import ja from './locales/ja';
|
||||
import it from './locales/it';
|
||||
import ptBR from './locales/pt-BR';
|
||||
import zhCN from './locales/zh-CN';
|
||||
|
||||
const resources = {
|
||||
en: { translation: en },
|
||||
@@ -17,6 +18,7 @@ const resources = {
|
||||
ja: { translation: ja },
|
||||
it: { translation: it },
|
||||
'pt-BR': { translation: ptBR },
|
||||
'zh-CN': { translation: zhCN },
|
||||
};
|
||||
|
||||
i18n
|
||||
@@ -25,7 +27,7 @@ i18n
|
||||
.init({
|
||||
resources,
|
||||
fallbackLng: 'en',
|
||||
supportedLngs: ['en', 'de', 'fr', 'ja', 'it', 'pt-BR'],
|
||||
supportedLngs: ['en', 'de', 'fr', 'ja', 'it', 'pt-BR', 'zh-CN'],
|
||||
|
||||
detection: {
|
||||
// Order of detection methods
|
||||
@@ -55,4 +57,5 @@ export const availableLanguages = [
|
||||
{ code: 'ja', name: 'Japanese', nativeName: '日本語' },
|
||||
{ code: 'it', name: 'Italian', nativeName: 'Italiano' },
|
||||
{ code: 'pt-BR', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)' },
|
||||
{ code: 'zh-CN', name: 'Chinese (Simplified)', nativeName: '简体中文' },
|
||||
];
|
||||
|
||||
@@ -54,6 +54,8 @@ export default {
|
||||
refresh: 'Aktualisieren',
|
||||
download: 'Herunterladen',
|
||||
upload: 'Hochladen',
|
||||
uploading: 'Hochladen...',
|
||||
uploadFailed: 'Hochladen fehlgeschlagen',
|
||||
actions: 'Aktionen',
|
||||
status: 'Status',
|
||||
name: 'Name',
|
||||
@@ -199,6 +201,7 @@ export default {
|
||||
chamberLightOn: 'Kammerbeleuchtung einschalten',
|
||||
chamberLightOff: 'Kammerbeleuchtung ausschalten',
|
||||
// Files
|
||||
files: 'Dateien',
|
||||
browseFiles: 'Druckerdateien durchsuchen',
|
||||
// Smart plug
|
||||
autoOffAfterPrint: 'Automatisches Ausschalten nach Druck',
|
||||
@@ -214,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',
|
||||
@@ -462,6 +478,10 @@ 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',
|
||||
},
|
||||
|
||||
// Archives page
|
||||
@@ -2241,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...',
|
||||
@@ -2691,7 +2712,6 @@ export default {
|
||||
sinceTracking: 'Seit Beginn der Erfassung',
|
||||
loadedInAms: 'Im AMS/Ext geladen',
|
||||
remaining: 'Verbleibend',
|
||||
lowStockThreshold: '<20% verbleibend',
|
||||
weightCheck: 'Gewichtskontrolle',
|
||||
lastWeighed: 'Zuletzt gewogen',
|
||||
neverWeighed: 'Nie gewogen',
|
||||
@@ -2744,6 +2764,7 @@ export default {
|
||||
clearHistory: 'Löschen',
|
||||
historyCleared: 'Verbrauchshistorie gelöscht',
|
||||
fillSourceLabel: '(Inv)',
|
||||
lowStockThresholdError: 'Der Schwellenwert muss zwischen 0.1 und 99.9 liegen',
|
||||
},
|
||||
|
||||
// Timelapse
|
||||
|
||||
@@ -54,6 +54,8 @@ export default {
|
||||
refresh: 'Refresh',
|
||||
download: 'Download',
|
||||
upload: 'Upload',
|
||||
uploading: 'Uploading...',
|
||||
uploadFailed: 'Upload failed',
|
||||
actions: 'Actions',
|
||||
status: 'Status',
|
||||
name: 'Name',
|
||||
@@ -199,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',
|
||||
@@ -214,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',
|
||||
@@ -462,6 +478,10 @@ 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',
|
||||
},
|
||||
|
||||
// Archives page
|
||||
@@ -2241,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...',
|
||||
@@ -2695,7 +2716,6 @@ export default {
|
||||
sinceTracking: 'Since tracking started',
|
||||
loadedInAms: 'Loaded in AMS/Ext',
|
||||
remaining: 'Remaining',
|
||||
lowStockThreshold: '<20% remaining',
|
||||
weightCheck: 'Weight Check',
|
||||
lastWeighed: 'Last weighed',
|
||||
neverWeighed: 'Never weighed',
|
||||
@@ -2748,6 +2768,7 @@ export default {
|
||||
clearHistory: 'Clear',
|
||||
historyCleared: 'Usage history cleared',
|
||||
fillSourceLabel: '(Inv)',
|
||||
lowStockThresholdError: 'Threshold must be between 0.1 and 99.9',
|
||||
},
|
||||
|
||||
// Timelapse
|
||||
|
||||
@@ -54,6 +54,8 @@ export default {
|
||||
refresh: 'Actualiser',
|
||||
download: 'Télécharger',
|
||||
upload: 'Téléverser',
|
||||
uploading: 'Téléversement...',
|
||||
uploadFailed: 'Échec du téléversement',
|
||||
actions: 'Actions',
|
||||
status: 'Statut',
|
||||
name: 'Nom',
|
||||
@@ -199,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',
|
||||
@@ -214,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',
|
||||
@@ -462,6 +478,10 @@ 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',
|
||||
},
|
||||
|
||||
// Archives page
|
||||
@@ -2229,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 à...',
|
||||
@@ -2683,7 +2704,6 @@ export default {
|
||||
sinceTracking: 'Depuis le début du suivi',
|
||||
loadedInAms: 'Chargé dans AMS/Ext',
|
||||
remaining: 'Restant',
|
||||
lowStockThreshold: '<20% restant',
|
||||
weightCheck: 'Vérification poids',
|
||||
lastWeighed: 'Dernière pesée',
|
||||
neverWeighed: 'Jamais pesé',
|
||||
@@ -2732,6 +2752,7 @@ export default {
|
||||
clearHistory: 'Effacer',
|
||||
historyCleared: 'Historique effacé',
|
||||
fillSourceLabel: '(Inv)',
|
||||
lowStockThresholdError: 'Le seuil doit être compris entre 0.1 et 99.9',
|
||||
},
|
||||
|
||||
// Timelapse
|
||||
|
||||
@@ -53,6 +53,8 @@ export default {
|
||||
refresh: 'Aggiorna',
|
||||
download: 'Scarica',
|
||||
upload: 'Carica',
|
||||
uploading: 'Caricamento...',
|
||||
uploadFailed: 'Caricamento fallito',
|
||||
actions: 'Azioni',
|
||||
status: 'Stato',
|
||||
name: 'Nome',
|
||||
@@ -196,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',
|
||||
@@ -211,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',
|
||||
@@ -453,6 +469,10 @@ 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',
|
||||
},
|
||||
|
||||
// Archives page
|
||||
@@ -2046,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...',
|
||||
@@ -2454,6 +2475,7 @@ export default {
|
||||
spoolRestored: 'Bobina ripristinata',
|
||||
deleteConfirm: 'Sei sicuro di voler eliminare questa bobina? Questa azione non può essere annullata.',
|
||||
advancedSettings: 'Impostazioni Avanzate',
|
||||
lowStockThresholdError: 'La soglia deve essere tra 0.1 e 99.9',
|
||||
weightCheck: 'Controllo Peso',
|
||||
lastWeighed: 'Ultima pesatura',
|
||||
neverWeighed: 'Mai pesato',
|
||||
|
||||
@@ -51,6 +51,8 @@ export default {
|
||||
refresh: '更新',
|
||||
download: 'ダウンロード',
|
||||
upload: 'アップロード',
|
||||
uploading: 'アップロード中...',
|
||||
uploadFailed: 'アップロード失敗',
|
||||
actions: '操作',
|
||||
status: 'ステータス',
|
||||
name: '名前',
|
||||
@@ -189,6 +191,7 @@ export default {
|
||||
maintenanceUpToDate: 'すべてのメンテナンスが最新です',
|
||||
chamberLightOn: 'チャンバーライトをオンにしました',
|
||||
chamberLightOff: 'チャンバーライトをオフにしました',
|
||||
files: 'ファイル',
|
||||
browseFiles: 'プリンターのファイルを参照',
|
||||
hmsErrors: 'クリックしてHMSエラーを表示',
|
||||
resume: '再開',
|
||||
@@ -196,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: 'ドッキング中',
|
||||
@@ -469,6 +485,10 @@ export default {
|
||||
clickToViewHmsErrors: 'クリックしてHMSエラーを表示',
|
||||
developerModeWarning: '開発者LANモードが有効になっていません: {{names}}。一部の機能が動作しない可能性があります。',
|
||||
howToEnable: '有効化方法',
|
||||
incompatibleFile: 'このファイルは{{slicedFor}}用にスライスされていますが、このプリンターは{{printerModel}}です',
|
||||
dropNotPrintable: '.gcodeおよび.gcode.3mfファイルのみ印刷できます',
|
||||
dropToPrint: 'ドロップして印刷',
|
||||
cannotPrint: 'プリンター使用中',
|
||||
},
|
||||
archives: {
|
||||
title: '印刷アーカイブ',
|
||||
@@ -2172,6 +2192,7 @@ export default {
|
||||
filesExtracted: '• {{count}}個のファイルを展開済み',
|
||||
uploadComplete: 'アップロード完了: {{count}}個成功',
|
||||
uploadFailed: 'アップロード失敗',
|
||||
zipFilesFailed: '{{count}}個のファイルが失敗',
|
||||
uploading: 'アップロード中...',
|
||||
changeLink: 'リンクを変更...',
|
||||
linkTo: 'リンク先...',
|
||||
@@ -2617,7 +2638,6 @@ export default {
|
||||
sinceTracking: '追跡開始以降',
|
||||
loadedInAms: 'AMS/Extに装填中',
|
||||
remaining: '残り',
|
||||
lowStockThreshold: '残り20%未満',
|
||||
weightCheck: '重量チェック',
|
||||
lastWeighed: '最終計量',
|
||||
neverWeighed: '未計量',
|
||||
@@ -2668,6 +2688,7 @@ export default {
|
||||
clearHistory: 'クリア',
|
||||
historyCleared: '使用履歴がクリアされました',
|
||||
fillSourceLabel: '(Inv)',
|
||||
lowStockThresholdError: 'しきい値は0.1から99.9の間でなければなりません',
|
||||
},
|
||||
timelapse: {
|
||||
download: 'ダウンロード',
|
||||
|
||||
@@ -54,6 +54,8 @@ export default {
|
||||
refresh: 'Atualizar',
|
||||
download: 'Baixar',
|
||||
upload: 'Enviar',
|
||||
uploading: 'Enviando...',
|
||||
uploadFailed: 'Falha no envio',
|
||||
actions: 'Ações',
|
||||
status: 'Status',
|
||||
name: 'Nome',
|
||||
@@ -199,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',
|
||||
@@ -214,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',
|
||||
@@ -462,6 +478,10 @@ 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',
|
||||
},
|
||||
|
||||
// Archives page
|
||||
@@ -2241,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...',
|
||||
@@ -2695,7 +2716,6 @@ export default {
|
||||
sinceTracking: 'Desde o início do rastreamento',
|
||||
loadedInAms: 'Carregado no AMS/Ext',
|
||||
remaining: 'Restante',
|
||||
lowStockThreshold: '<20% restante',
|
||||
weightCheck: 'Verificação de Peso',
|
||||
lastWeighed: 'Última pesagem',
|
||||
neverWeighed: 'Nunca pesado',
|
||||
@@ -2744,6 +2764,7 @@ export default {
|
||||
clearHistory: 'Limpar',
|
||||
historyCleared: 'Histórico de uso limpo',
|
||||
fillSourceLabel: '(Inv)',
|
||||
lowStockThresholdError: 'O limite deve estar entre 0.1 e 99.9',
|
||||
},
|
||||
|
||||
// Timelapse
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import {
|
||||
Trash2,
|
||||
Clock,
|
||||
Package,
|
||||
Coins,
|
||||
Layers,
|
||||
Search,
|
||||
Filter,
|
||||
@@ -48,7 +49,7 @@ import {
|
||||
User,
|
||||
Play,
|
||||
ClipboardList,
|
||||
Coins,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { openInSlicer, type SlicerType } from '../utils/slicer';
|
||||
@@ -923,10 +924,20 @@ function ArchiveCard({
|
||||
{archive.filament_used_grams.toFixed(1)}g
|
||||
</div>
|
||||
)}
|
||||
{archive.cost != null && (
|
||||
<div className="flex items-center gap-1.5 text-bambu-gray">
|
||||
<Coins className="w-3 h-3" />
|
||||
{currency}{archive.cost.toFixed(2)}
|
||||
{(archive.cost != null || archive.energy_cost != null) && (
|
||||
<div className="flex items-center gap-3 text-bambu-gray">
|
||||
{archive.cost != null && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Coins className="w-3 h-3" />
|
||||
{currency}{archive.cost.toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
{archive.energy_cost != null && (
|
||||
<div className="flex items-center gap-1.5" title={`${t('stats.energyUsed')}: ${archive.energy_kwh?.toFixed(3) || 'N/A'} kWh`}>
|
||||
<Zap className="w-3 h-3" />
|
||||
{currency}{archive.energy_cost.toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(archive.layer_height || archive.total_layers) && (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo, type ReactNode } from 'react';
|
||||
import { useState, useMemo, useEffect, type ReactNode } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -20,7 +20,7 @@ import { formatDateInput, parseUTCDate, type DateFormat } from '../utils/date';
|
||||
import { formatSlotLabel } from '../utils/amsHelpers';
|
||||
|
||||
type ArchiveFilter = 'active' | 'archived';
|
||||
type UsageFilter = 'all' | 'used' | 'new';
|
||||
type UsageFilter = 'all' | 'used' | 'new' | 'lowstock';
|
||||
type ViewMode = 'table' | 'cards';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
type SortState = { column: string; direction: SortDirection } | null;
|
||||
@@ -513,6 +513,30 @@ function InventoryPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Low stock threshold from backend settings
|
||||
const lowStockThreshold = settings?.low_stock_threshold ?? 20;
|
||||
const [showThresholdInput, setShowThresholdInput] = useState(false);
|
||||
const [thresholdInput, setThresholdInput] = useState(lowStockThreshold.toString());
|
||||
|
||||
// Sync thresholdInput when lowStockThreshold changes and input is not shown
|
||||
useEffect(() => {
|
||||
if (!showThresholdInput) {
|
||||
setThresholdInput(lowStockThreshold.toString());
|
||||
}
|
||||
}, [lowStockThreshold, showThresholdInput]);
|
||||
|
||||
const updateThresholdMutation = useMutation({
|
||||
mutationFn: (threshold: number) => api.updateSettings({ low_stock_threshold: threshold }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||
showToast(t('common.saved'), 'success');
|
||||
setShowThresholdInput(false);
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t('inventory.lowStockThresholdError'), 'error');
|
||||
},
|
||||
});
|
||||
|
||||
// Stats calculation (active spools only)
|
||||
const stats = useMemo(() => {
|
||||
if (!spools) return null;
|
||||
@@ -528,14 +552,14 @@ function InventoryPage() {
|
||||
totalWeight += remaining;
|
||||
totalConsumed += s.weight_used;
|
||||
const pct = s.label_weight > 0 ? (remaining / s.label_weight) * 100 : 0;
|
||||
if (pct < 20) lowStock++;
|
||||
if (pct < lowStockThreshold) lowStock++;
|
||||
const mat = s.material || 'Unknown';
|
||||
if (!byMaterial[mat]) byMaterial[mat] = { count: 0, weight: 0 };
|
||||
byMaterial[mat].count++;
|
||||
byMaterial[mat].weight += remaining;
|
||||
}
|
||||
return { totalWeight, totalConsumed, lowStock, byMaterial, totalSpools: activeCount };
|
||||
}, [spools]);
|
||||
}, [spools, lowStockThreshold]);
|
||||
|
||||
const inPrinterCount = assignments?.length ?? 0;
|
||||
|
||||
@@ -574,6 +598,12 @@ function InventoryPage() {
|
||||
filtered = filtered.filter((s) => s.weight_used > 0);
|
||||
} else if (usageFilter === 'new') {
|
||||
filtered = filtered.filter((s) => s.weight_used === 0);
|
||||
} else if (usageFilter === 'lowstock') {
|
||||
filtered = filtered.filter((s) => {
|
||||
const remaining = Math.max(0, s.label_weight - s.weight_used);
|
||||
const pct = s.label_weight > 0 ? (remaining / s.label_weight) * 100 : 0;
|
||||
return pct < lowStockThreshold;
|
||||
});
|
||||
}
|
||||
|
||||
// Material dropdown
|
||||
@@ -607,7 +637,7 @@ function InventoryPage() {
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, stockFilter, search]);
|
||||
}, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, stockFilter, search, lowStockThreshold]);
|
||||
|
||||
// Reset page on filter changes
|
||||
const resetPage = () => setPageIndex(0);
|
||||
@@ -818,7 +848,59 @@ function InventoryPage() {
|
||||
<span className="text-xs text-bambu-gray font-medium uppercase tracking-wide">{t('inventory.lowStock')}</span>
|
||||
</div>
|
||||
<div className={`text-xl font-bold ${stats.lowStock > 0 ? 'text-yellow-400' : 'text-white'}`}>{stats.lowStock}</div>
|
||||
<div className="text-xs text-bambu-gray mt-1">{t('inventory.lowStockThreshold')}</div>
|
||||
<div className="text-xs text-bambu-gray mt-1 flex items-center gap-2">
|
||||
{showThresholdInput ? (
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
const val = parseFloat(thresholdInput);
|
||||
if (!isNaN(val) && val >= 0.1 && val <= 99.9) {
|
||||
updateThresholdMutation.mutate(val);
|
||||
} else {
|
||||
showToast(t('inventory.lowStockThresholdError'), 'error');
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span className="text-xs text-bambu-gray">{'<'}</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="^\d{0,2}(\.\d?)?$"
|
||||
maxLength={4}
|
||||
value={thresholdInput}
|
||||
onChange={e => {
|
||||
// Only allow up to 2 digits before decimal and 1 after
|
||||
const val = e.target.value.replace(/[^\d.]/g, '');
|
||||
if (/^\d{0,2}(\.\d?)?$/.test(val)) {
|
||||
setThresholdInput(val);
|
||||
}
|
||||
}}
|
||||
className="px-1.5 py-1 rounded border border-bambu-dark-tertiary text-xs text-white bg-bambu-dark-secondary focus:outline-none focus:border-bambu-green w-14 text-center"
|
||||
onWheel={e => e.currentTarget.blur()}
|
||||
disabled={updateThresholdMutation.isPending}
|
||||
/>
|
||||
|
||||
<span className="text-xs text-bambu-gray">%</span>
|
||||
<Button type="submit" size="sm" disabled={updateThresholdMutation.isPending}>{t('common.save')}</Button>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => setShowThresholdInput(false)} disabled={updateThresholdMutation.isPending}>{t('common.cancel')}</Button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-bambu-gray">{'< '}{lowStockThreshold}%</span>
|
||||
<button
|
||||
className="p-1.5 text-bambu-gray hover:text-white rounded transition-colors"
|
||||
title={t('common.edit')}
|
||||
onClick={() => {
|
||||
setThresholdInput(lowStockThreshold.toString());
|
||||
setShowThresholdInput(true);
|
||||
}}
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -960,6 +1042,17 @@ function InventoryPage() {
|
||||
>
|
||||
{t('inventory.new')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setUsageFilter('lowstock'); resetPage(); }}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
usageFilter === 'lowstock'
|
||||
? 'bg-yellow-500/20 text-yellow-400'
|
||||
: 'text-bambu-gray hover:bg-bambu-dark-tertiary'
|
||||
}`}
|
||||
>
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
{t('inventory.lowStock')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stock filter chips */}
|
||||
@@ -1541,7 +1634,7 @@ function SpoolTableRow({
|
||||
))}
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center justify-end gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={onEdit} className="p-1.5 text-bambu-gray hover:text-white rounded transition-colors" title={t('inventory.editSpool')}>
|
||||
<button onClick={onEdit} className="p-1.5 text-bambu-gray hover:text-white rounded transition-colors" title={t('common.edit')}>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
{spool.archived_at ? (
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user