Merge branch '0.1.6-final' into feature/164

This commit is contained in:
MartinNYHC
2026-01-31 15:10:27 +01:00
committed by GitHub
79 changed files with 16247 additions and 1790 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
globalIgnores(['dist', 'coverage']),
{
files: ['**/*.{ts,tsx}'],
extends: [
@@ -2,7 +2,7 @@
* Tests for the GitHub Backup API client functions.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { describe, it, expect } from 'vitest';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import type {
@@ -38,7 +38,11 @@ describe('EditArchiveModal', () => {
return HttpResponse.json(mockProjects);
}),
http.get('/api/v1/archives/tags', () => {
return HttpResponse.json(['test', 'calibration', 'functional']);
return HttpResponse.json([
{ name: 'test', count: 2 },
{ name: 'calibration', count: 1 },
{ name: 'functional', count: 3 },
]);
}),
http.patch('/api/v1/archives/:id', async ({ request }) => {
const body = await request.json();
@@ -63,6 +63,14 @@ const createMockProvider = (
on_ams_temperature_high: false,
on_ams_ht_humidity_high: false,
on_ams_ht_temperature_high: false,
on_plate_not_empty: true,
on_queue_job_added: false,
on_queue_job_assigned: false,
on_queue_job_started: false,
on_queue_job_waiting: true,
on_queue_job_skipped: true,
on_queue_job_failed: true,
on_queue_completed: false,
quiet_hours_enabled: false,
quiet_hours_start: null,
quiet_hours_end: null,
@@ -282,3 +290,83 @@ describe('NotificationProviderCard AMS toggles', () => {
});
});
});
describe('NotificationProviderCard Queue notifications', () => {
describe('queue job notifications', () => {
it('includes on_queue_job_added in provider data', () => {
const provider = createMockProvider({ on_queue_job_added: true });
expect(provider.on_queue_job_added).toBe(true);
});
it('includes on_queue_job_assigned in provider data', () => {
const provider = createMockProvider({ on_queue_job_assigned: true });
expect(provider.on_queue_job_assigned).toBe(true);
});
it('includes on_queue_job_started in provider data', () => {
const provider = createMockProvider({ on_queue_job_started: true });
expect(provider.on_queue_job_started).toBe(true);
});
it('includes on_queue_job_waiting in provider data', () => {
const provider = createMockProvider({ on_queue_job_waiting: true });
expect(provider.on_queue_job_waiting).toBe(true);
});
it('includes on_queue_job_skipped in provider data', () => {
const provider = createMockProvider({ on_queue_job_skipped: true });
expect(provider.on_queue_job_skipped).toBe(true);
});
it('includes on_queue_job_failed in provider data', () => {
const provider = createMockProvider({ on_queue_job_failed: true });
expect(provider.on_queue_job_failed).toBe(true);
});
it('includes on_queue_completed in provider data', () => {
const provider = createMockProvider({ on_queue_completed: true });
expect(provider.on_queue_completed).toBe(true);
});
});
describe('queue notification defaults', () => {
it('defaults actionable notifications to true', () => {
const provider = createMockProvider();
// These should default to true (actionable - user needs to do something)
expect(provider.on_queue_job_waiting).toBe(true);
expect(provider.on_queue_job_skipped).toBe(true);
expect(provider.on_queue_job_failed).toBe(true);
});
it('defaults informational notifications to false', () => {
const provider = createMockProvider();
// These should default to false (informational only)
expect(provider.on_queue_job_added).toBe(false);
expect(provider.on_queue_job_assigned).toBe(false);
expect(provider.on_queue_job_started).toBe(false);
expect(provider.on_queue_completed).toBe(false);
});
});
describe('queue notification combinations', () => {
it('supports all queue toggles independently', () => {
const provider = createMockProvider({
on_queue_job_added: true,
on_queue_job_assigned: false,
on_queue_job_started: true,
on_queue_job_waiting: false,
on_queue_job_skipped: true,
on_queue_job_failed: false,
on_queue_completed: true,
});
expect(provider.on_queue_job_added).toBe(true);
expect(provider.on_queue_job_assigned).toBe(false);
expect(provider.on_queue_job_started).toBe(true);
expect(provider.on_queue_job_waiting).toBe(false);
expect(provider.on_queue_job_skipped).toBe(true);
expect(provider.on_queue_job_failed).toBe(false);
expect(provider.on_queue_completed).toBe(true);
});
});
});
@@ -21,6 +21,24 @@ const createMockPlug = (overrides: Partial<SmartPlug> = {}): SmartPlug => ({
plug_type: 'tasmota',
ip_address: '192.168.1.100',
ha_entity_id: null,
ha_power_entity: null,
ha_energy_today_entity: null,
ha_energy_total_entity: null,
// MQTT fields (legacy)
mqtt_topic: null,
mqtt_multiplier: 1.0,
// MQTT power fields
mqtt_power_topic: null,
mqtt_power_path: null,
mqtt_power_multiplier: 1.0,
// MQTT energy fields
mqtt_energy_topic: null,
mqtt_energy_path: null,
mqtt_energy_multiplier: 1.0,
// MQTT state fields
mqtt_state_topic: null,
mqtt_state_path: null,
mqtt_state_on_value: null,
printer_id: 1,
enabled: true,
auto_on: true,
@@ -272,4 +290,73 @@ describe('SmartPlugCard', () => {
expect(buttons.length).toBeGreaterThan(0);
});
});
describe('MQTT plugs', () => {
it('renders MQTT plug with topic instead of IP', () => {
const plug = createMockPlug({
plug_type: 'mqtt',
ip_address: null,
mqtt_topic: 'zigbee2mqtt/shelly-power',
mqtt_power_path: 'power_l1',
});
render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
// Should show topic, not IP
expect(screen.getByText('zigbee2mqtt/shelly-power')).toBeInTheDocument();
expect(screen.queryByText('192.168.1.100')).not.toBeInTheDocument();
});
it('renders MQTT plug name correctly', () => {
const plug = createMockPlug({
name: 'MQTT Energy Monitor',
plug_type: 'mqtt',
ip_address: null,
mqtt_topic: 'sensors/power',
mqtt_power_path: 'power',
});
render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
expect(screen.getByText('MQTT Energy Monitor')).toBeInTheDocument();
});
it('shows Monitor Only badge for MQTT plug', () => {
const plug = createMockPlug({
plug_type: 'mqtt',
ip_address: null,
mqtt_topic: 'test/topic',
mqtt_power_path: 'power',
});
render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
expect(screen.getByText('Monitor Only')).toBeInTheDocument();
});
it('does not show power control buttons for MQTT plug', () => {
const plug = createMockPlug({
plug_type: 'mqtt',
ip_address: null,
mqtt_topic: 'test/topic',
mqtt_power_path: 'power',
});
render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
// On/Off buttons should not be present for monitor-only plugs
expect(screen.queryByRole('button', { name: /^on$/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^off$/i })).not.toBeInTheDocument();
});
it('shows Settings instead of Automation Settings for MQTT plug', async () => {
const plug = createMockPlug({
plug_type: 'mqtt',
ip_address: null,
mqtt_topic: 'test/topic',
mqtt_power_path: 'power',
});
render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
// Should show "Settings" not "Automation Settings"
expect(screen.getByText('Settings')).toBeInTheDocument();
expect(screen.queryByText('Automation Settings')).not.toBeInTheDocument();
});
});
});
@@ -0,0 +1,300 @@
/**
* Tests for the TagManagementModal component.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
import { TagManagementModal } from '../../components/TagManagementModal';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
const mockTags = [
{ name: 'functional', count: 5 },
{ name: 'calibration', count: 3 },
{ name: 'test', count: 2 },
{ name: 'art', count: 1 },
];
describe('TagManagementModal', () => {
const mockOnClose = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
server.use(
http.get('/api/v1/archives/tags', () => {
return HttpResponse.json(mockTags);
}),
http.put('/api/v1/archives/tags/:tagName', async () => {
return HttpResponse.json({ affected: 2 });
}),
http.delete('/api/v1/archives/tags/:tagName', () => {
return HttpResponse.json({ affected: 1 });
})
);
});
describe('rendering', () => {
it('renders the modal title', async () => {
render(<TagManagementModal onClose={mockOnClose} />);
expect(screen.getByText('Manage Tags')).toBeInTheDocument();
});
it('shows loading state initially', () => {
render(<TagManagementModal onClose={mockOnClose} />);
// Should show loading spinner before data loads
expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
});
it('displays tags with counts', async () => {
render(<TagManagementModal onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('functional')).toBeInTheDocument();
expect(screen.getByText('5')).toBeInTheDocument();
expect(screen.getByText('calibration')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
});
});
it('shows total tag count and usage', async () => {
render(<TagManagementModal onClose={mockOnClose} />);
await waitFor(() => {
// 4 tags, 11 total usages
expect(screen.getByText(/4 tags across 11 usages/i)).toBeInTheDocument();
});
});
});
describe('search functionality', () => {
it('filters tags by search input', async () => {
const user = userEvent.setup();
render(<TagManagementModal onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('functional')).toBeInTheDocument();
});
const searchInput = screen.getByPlaceholderText('Search tags...');
await user.type(searchInput, 'cal');
await waitFor(() => {
expect(screen.getByText('calibration')).toBeInTheDocument();
expect(screen.queryByText('functional')).not.toBeInTheDocument();
expect(screen.queryByText('art')).not.toBeInTheDocument();
});
});
it('shows no results message when search has no matches', async () => {
const user = userEvent.setup();
render(<TagManagementModal onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('functional')).toBeInTheDocument();
});
const searchInput = screen.getByPlaceholderText('Search tags...');
await user.type(searchInput, 'nonexistent');
await waitFor(() => {
expect(screen.getByText('No tags match your search')).toBeInTheDocument();
});
});
});
describe('sorting', () => {
it('sorts by count by default', async () => {
render(<TagManagementModal onClose={mockOnClose} />);
await waitFor(() => {
const tagElements = screen.getAllByText(/functional|calibration|test|art/);
// First should be functional (count 5)
expect(tagElements[0]).toHaveTextContent('functional');
});
});
it('can sort by name', async () => {
const user = userEvent.setup();
render(<TagManagementModal onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('functional')).toBeInTheDocument();
});
const sortSelect = screen.getByDisplayValue('Sort by Count');
await user.selectOptions(sortSelect, 'name');
await waitFor(() => {
const tagElements = screen.getAllByText(/functional|calibration|test|art/);
// First should be 'art' alphabetically
expect(tagElements[0]).toHaveTextContent('art');
});
});
});
describe('rename functionality', () => {
it('enters edit mode when clicking edit button', async () => {
const user = userEvent.setup();
render(<TagManagementModal onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('functional')).toBeInTheDocument();
});
// Find the tag row and click its edit button
const tagRow = screen.getByText('functional').closest('div');
const editButton = within(tagRow!).getByTitle('Rename tag');
await user.click(editButton);
// Should show input with current value
await waitFor(() => {
expect(screen.getByDisplayValue('functional')).toBeInTheDocument();
});
});
it('submits rename on Enter key', async () => {
const user = userEvent.setup();
render(<TagManagementModal onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('functional')).toBeInTheDocument();
});
const tagRow = screen.getByText('functional').closest('div');
const editButton = within(tagRow!).getByTitle('Rename tag');
await user.click(editButton);
const input = screen.getByDisplayValue('functional');
await user.clear(input);
await user.type(input, 'new-name{Enter}');
// Should show success (mutation called)
await waitFor(() => {
// After successful rename, edit mode should close
expect(screen.queryByDisplayValue('new-name')).not.toBeInTheDocument();
});
});
it('cancels edit on Escape key', async () => {
const user = userEvent.setup();
render(<TagManagementModal onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('functional')).toBeInTheDocument();
});
const tagRow = screen.getByText('functional').closest('div');
const editButton = within(tagRow!).getByTitle('Rename tag');
await user.click(editButton);
const input = screen.getByDisplayValue('functional');
await user.type(input, '-modified{Escape}');
// Should exit edit mode without saving
await waitFor(() => {
expect(screen.queryByDisplayValue('functional-modified')).not.toBeInTheDocument();
expect(screen.getByText('functional')).toBeInTheDocument();
});
});
});
describe('delete functionality', () => {
it('shows delete confirmation when clicking delete button', async () => {
const user = userEvent.setup();
render(<TagManagementModal onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('functional')).toBeInTheDocument();
});
const tagRow = screen.getByText('functional').closest('div');
const deleteButton = within(tagRow!).getByTitle('Delete tag');
await user.click(deleteButton);
await waitFor(() => {
expect(screen.getByText(/Delete "functional" from 5 archives?/i)).toBeInTheDocument();
});
});
it('cancels delete confirmation on X button', async () => {
const user = userEvent.setup();
render(<TagManagementModal onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('functional')).toBeInTheDocument();
});
const tagRow = screen.getByText('functional').closest('div');
const deleteButton = within(tagRow!).getByTitle('Delete tag');
await user.click(deleteButton);
await waitFor(() => {
expect(screen.getByText(/Delete "functional"/i)).toBeInTheDocument();
});
// Find the confirmation row and click the cancel (X) button within it
const confirmationText = screen.getByText(/Delete "functional"/i);
const confirmationRow = confirmationText.closest('div');
// The X button is the last button in the confirmation row
const buttons = within(confirmationRow!.parentElement!).getAllByRole('button');
const cancelButton = buttons[buttons.length - 1]; // X button is last
await user.click(cancelButton);
await waitFor(() => {
// Should return to normal display - the tag name should be visible again
expect(screen.getByText('functional')).toBeInTheDocument();
});
});
});
describe('modal behavior', () => {
it('calls onClose when close button is clicked', async () => {
const user = userEvent.setup();
render(<TagManagementModal onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('Manage Tags')).toBeInTheDocument();
});
// Find close button in header (X icon)
const headerCloseButton = screen.getAllByRole('button')[0];
await user.click(headerCloseButton);
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it('calls onClose when Close button is clicked', async () => {
const user = userEvent.setup();
render(<TagManagementModal onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('Manage Tags')).toBeInTheDocument();
});
const closeButton = screen.getByRole('button', { name: /close/i });
await user.click(closeButton);
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
});
describe('empty state', () => {
it('shows empty message when no tags exist', async () => {
server.use(
http.get('/api/v1/archives/tags', () => {
return HttpResponse.json([]);
})
);
render(<TagManagementModal onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('No tags found')).toBeInTheDocument();
});
});
});
});
@@ -10,20 +10,12 @@ import { UploadModal } from '../../components/UploadModal';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
const mockPrinters = [
{ id: 1, name: 'X1 Carbon', model: 'X1C', serial_number: '123' },
{ id: 2, name: 'P1S', model: 'P1S', serial_number: '456' },
];
describe('UploadModal', () => {
const mockOnClose = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
server.use(
http.get('/api/v1/printers/', () => {
return HttpResponse.json(mockPrinters);
}),
http.post('/api/v1/archives/upload-bulk', async () => {
return HttpResponse.json({
uploaded: 1,
@@ -54,17 +46,6 @@ describe('UploadModal', () => {
expect(screen.getByRole('button', { name: 'Browse Files' })).toBeInTheDocument();
});
it('renders printer selection dropdown', async () => {
render(<UploadModal onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('Associate with printer (optional)')).toBeInTheDocument();
});
const select = screen.getByRole('combobox');
expect(select).toBeInTheDocument();
});
it('renders Cancel button', () => {
render(<UploadModal onClose={mockOnClose} />);
@@ -79,32 +60,6 @@ describe('UploadModal', () => {
});
});
describe('printer selection', () => {
it('shows available printers in dropdown', async () => {
render(<UploadModal onClose={mockOnClose} />);
await waitFor(() => {
// Check for printer options in the select
expect(screen.getByRole('option', { name: 'No printer' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'X1 Carbon' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'P1S' })).toBeInTheDocument();
});
});
it('allows selecting a printer', async () => {
render(<UploadModal onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByRole('option', { name: 'X1 Carbon' })).toBeInTheDocument();
});
const select = screen.getByRole('combobox');
fireEvent.change(select, { target: { value: '1' } });
expect(select).toHaveValue('1');
});
});
describe('file handling with initialFiles', () => {
it('shows initial files when provided', () => {
const initialFiles = [
@@ -347,8 +347,8 @@ describe('FileManagerPage', () => {
render(<FileManagerPage />);
await waitFor(() => {
// Sort dropdown should show Date as default option
expect(screen.getByDisplayValue('Date')).toBeInTheDocument();
// Sort dropdown should show Name as default option (persisted to localStorage)
expect(screen.getByDisplayValue('Name')).toBeInTheDocument();
});
});
});
@@ -476,4 +476,200 @@ describe('FileManagerPage', () => {
});
});
});
describe('STL thumbnail generation', () => {
it('shows Generate Thumbnails button', async () => {
render(<FileManagerPage />);
await waitFor(() => {
expect(screen.getByText('Generate Thumbnails')).toBeInTheDocument();
});
});
it('Generate Thumbnails button has correct title', async () => {
render(<FileManagerPage />);
await waitFor(() => {
const button = screen.getByTitle('Generate thumbnails for STL files missing them');
expect(button).toBeInTheDocument();
});
});
it('can click Generate Thumbnails button', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/v1/library/generate-stl-thumbnails', () => {
return HttpResponse.json({
processed: 1,
succeeded: 1,
failed: 0,
results: [{ file_id: 2, success: true }],
});
})
);
render(<FileManagerPage />);
await waitFor(() => {
expect(screen.getByText('Generate Thumbnails')).toBeInTheDocument();
});
const button = screen.getByText('Generate Thumbnails');
await user.click(button);
// Button should work without error
await waitFor(() => {
expect(screen.getByText('Generate Thumbnails')).toBeInTheDocument();
});
});
it('shows STL file without thumbnail in file list', async () => {
render(<FileManagerPage />);
await waitFor(() => {
// bracket.stl has no thumbnail_path
expect(screen.getByText('bracket.stl')).toBeInTheDocument();
expect(screen.getAllByText('STL').length).toBeGreaterThan(0);
});
});
});
describe('upload modal STL options', () => {
it('opens upload modal', 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();
expect(screen.getByText(/Drag & drop/)).toBeInTheDocument();
});
});
it('shows STL thumbnail option when STL 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();
});
// 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('Generate thumbnails for STL files')).toBeInTheDocument();
});
});
it('STL thumbnail checkbox is checked by default', 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();
});
// Add an STL file
const stlFile = new File(['solid test'], 'model.stl', { type: 'application/sla' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, stlFile);
await waitFor(() => {
expect(screen.getByText('Generate thumbnails for STL files')).toBeInTheDocument();
});
// Checkbox should be checked by default
const checkbox = screen.getByRole('checkbox', { name: /Generate thumbnails for STL files/i });
expect(checkbox).toBeChecked();
});
it('can toggle STL thumbnail checkbox', 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();
});
// Add an STL file
const stlFile = new File(['solid test'], 'model.stl', { type: 'application/sla' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, stlFile);
await waitFor(() => {
expect(screen.getByText('Generate thumbnails for STL files')).toBeInTheDocument();
});
const checkbox = screen.getByRole('checkbox', { name: /Generate thumbnails for STL files/i });
expect(checkbox).toBeChecked();
// Toggle off
await user.click(checkbox);
expect(checkbox).not.toBeChecked();
// Toggle back on
await user.click(checkbox);
expect(checkbox).toBeChecked();
});
it('shows STL thumbnail option for ZIP files', 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();
});
// Create a mock ZIP file
const zipFile = new File(['PK'], 'models.zip', { type: 'application/zip' });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, zipFile);
// STL thumbnail option should appear for ZIP files (may contain STLs)
await waitFor(() => {
expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument();
expect(screen.getByText(/ZIP files may contain STL files/)).toBeInTheDocument();
});
});
});
});
+141 -12
View File
@@ -300,6 +300,7 @@ export interface Archive {
nozzle_diameter: number | null;
bed_temperature: number | null;
nozzle_temperature: number | null;
sliced_for_model: string | null; // Printer model this file was sliced for
status: string;
started_at: string | null;
completed_at: string | null;
@@ -334,6 +335,11 @@ export interface ArchiveStats {
total_energy_cost: number;
}
export interface TagInfo {
name: string;
count: number;
}
export interface FailureAnalysis {
period_days: number;
total_prints: number;
@@ -840,13 +846,29 @@ export interface CloudDevice {
export interface SmartPlug {
id: number;
name: string;
plug_type: 'tasmota' | 'homeassistant';
plug_type: 'tasmota' | 'homeassistant' | 'mqtt';
ip_address: string | null; // Required for Tasmota
ha_entity_id: string | null; // Required for Home Assistant (e.g., "switch.printer_plug")
ha_entity_id: string | null; // Required for Home Assistant (e.g., "switch.printer_plug", "script.turn_on_printer")
// Home Assistant energy sensor entities (optional)
ha_power_entity: string | null;
ha_energy_today_entity: string | null;
ha_energy_total_entity: string | null;
// MQTT fields (required when plug_type="mqtt")
// Legacy field - kept for backward compatibility
mqtt_topic: string | null; // Deprecated, use mqtt_power_topic
mqtt_multiplier: number; // Deprecated, use mqtt_power_multiplier
// Power monitoring
mqtt_power_topic: string | null; // Topic for power data
mqtt_power_path: string | null; // e.g., "power_l1" or "data.power"
mqtt_power_multiplier: number; // Unit conversion for power
// Energy monitoring
mqtt_energy_topic: string | null; // Topic for energy data
mqtt_energy_path: string | null; // e.g., "energy_l1"
mqtt_energy_multiplier: number; // Unit conversion for energy
// State monitoring
mqtt_state_topic: string | null; // Topic for state data
mqtt_state_path: string | null; // e.g., "state_l1" for ON/OFF
mqtt_state_on_value: string | null; // What value means "ON" (e.g., "ON", "true", "1")
printer_id: number | null;
enabled: boolean;
auto_on: boolean;
@@ -865,8 +887,9 @@ export interface SmartPlug {
schedule_enabled: boolean;
schedule_on_time: string | null;
schedule_off_time: string | null;
// Switchbar visibility
// Visibility options
show_in_switchbar: boolean;
show_on_printer_card: boolean; // For scripts: show on printer card
// Status
last_state: string | null;
last_checked: string | null;
@@ -877,13 +900,29 @@ export interface SmartPlug {
export interface SmartPlugCreate {
name: string;
plug_type?: 'tasmota' | 'homeassistant';
plug_type?: 'tasmota' | 'homeassistant' | 'mqtt';
ip_address?: string | null; // Required for Tasmota
ha_entity_id?: string | null; // Required for Home Assistant
// Home Assistant energy sensor entities (optional)
ha_power_entity?: string | null;
ha_energy_today_entity?: string | null;
ha_energy_total_entity?: string | null;
// MQTT fields (required when plug_type="mqtt")
// Legacy fields - kept for backward compatibility
mqtt_topic?: string | null;
mqtt_multiplier?: number;
// Power monitoring
mqtt_power_topic?: string | null;
mqtt_power_path?: string | null;
mqtt_power_multiplier?: number;
// Energy monitoring
mqtt_energy_topic?: string | null;
mqtt_energy_path?: string | null;
mqtt_energy_multiplier?: number;
// State monitoring
mqtt_state_topic?: string | null;
mqtt_state_path?: string | null;
mqtt_state_on_value?: string | null;
printer_id?: number | null;
enabled?: boolean;
auto_on?: boolean;
@@ -901,19 +940,35 @@ export interface SmartPlugCreate {
schedule_enabled?: boolean;
schedule_on_time?: string | null;
schedule_off_time?: string | null;
// Switchbar visibility
// Visibility options
show_in_switchbar?: boolean;
show_on_printer_card?: boolean;
}
export interface SmartPlugUpdate {
name?: string;
plug_type?: 'tasmota' | 'homeassistant';
plug_type?: 'tasmota' | 'homeassistant' | 'mqtt';
ip_address?: string | null;
ha_entity_id?: string | null;
// Home Assistant energy sensor entities (optional)
ha_power_entity?: string | null;
ha_energy_today_entity?: string | null;
ha_energy_total_entity?: string | null;
// MQTT fields (legacy)
mqtt_topic?: string | null;
mqtt_multiplier?: number;
// MQTT power fields
mqtt_power_topic?: string | null;
mqtt_power_path?: string | null;
mqtt_power_multiplier?: number;
// MQTT energy fields
mqtt_energy_topic?: string | null;
mqtt_energy_path?: string | null;
mqtt_energy_multiplier?: number;
// MQTT state fields
mqtt_state_topic?: string | null;
mqtt_state_path?: string | null;
mqtt_state_on_value?: string | null;
printer_id?: number | null;
enabled?: boolean;
auto_on?: boolean;
@@ -931,8 +986,9 @@ export interface SmartPlugUpdate {
schedule_enabled?: boolean;
schedule_on_time?: string | null;
schedule_off_time?: string | null;
// Switchbar visibility
// Visibility options
show_in_switchbar?: boolean;
show_on_printer_card?: boolean;
}
// Home Assistant entity for smart plug selection
@@ -940,7 +996,7 @@ export interface HAEntity {
entity_id: string;
friendly_name: string;
state: string | null;
domain: string; // "switch", "light", "input_boolean"
domain: string; // "switch", "light", "input_boolean", "script"
}
// Home Assistant sensor entity for energy monitoring
@@ -1001,6 +1057,9 @@ export interface DiscoveredTasmotaDevice {
export interface PrintQueueItem {
id: number;
printer_id: number | null; // null = unassigned
target_model: string | null; // Target printer model for model-based assignment
required_filament_types: string[] | null; // Required filament types for model-based assignment
waiting_reason: string | null; // Why a model-based job hasn't started yet
// Either archive_id OR library_file_id must be set (archive created at print start)
archive_id: number | null;
library_file_id: number | null;
@@ -1033,6 +1092,7 @@ export interface PrintQueueItem {
export interface PrintQueueItemCreate {
printer_id?: number | null; // null = unassigned
target_model?: string | null; // Target printer model (mutually exclusive with printer_id)
// Either archive_id OR library_file_id must be provided
archive_id?: number | null;
library_file_id?: number | null;
@@ -1053,6 +1113,7 @@ export interface PrintQueueItemCreate {
export interface PrintQueueItemUpdate {
printer_id?: number | null; // null = unassign
target_model?: string | null; // Target printer model (mutually exclusive with printer_id)
position?: number;
scheduled_time?: string | null;
require_previous_success?: boolean;
@@ -1212,6 +1273,14 @@ export interface NotificationProvider {
on_ams_ht_temperature_high: boolean;
// Build plate detection
on_plate_not_empty: boolean;
// Print queue events
on_queue_job_added: boolean;
on_queue_job_assigned: boolean;
on_queue_job_started: boolean;
on_queue_job_waiting: boolean;
on_queue_job_skipped: boolean;
on_queue_job_failed: boolean;
on_queue_completed: boolean;
// Quiet hours
quiet_hours_enabled: boolean;
quiet_hours_start: string | null;
@@ -1254,6 +1323,14 @@ export interface NotificationProviderCreate {
on_ams_ht_temperature_high?: boolean;
// Build plate detection
on_plate_not_empty?: boolean;
// Print queue events
on_queue_job_added?: boolean;
on_queue_job_assigned?: boolean;
on_queue_job_started?: boolean;
on_queue_job_waiting?: boolean;
on_queue_job_skipped?: boolean;
on_queue_job_failed?: boolean;
on_queue_completed?: boolean;
// Quiet hours
quiet_hours_enabled?: boolean;
quiet_hours_start?: string | null;
@@ -1289,6 +1366,14 @@ export interface NotificationProviderUpdate {
on_ams_ht_temperature_high?: boolean;
// Build plate detection
on_plate_not_empty?: boolean;
// Print queue events
on_queue_job_added?: boolean;
on_queue_job_assigned?: boolean;
on_queue_job_started?: boolean;
on_queue_job_waiting?: boolean;
on_queue_job_skipped?: boolean;
on_queue_job_failed?: boolean;
on_queue_completed?: boolean;
// Quiet hours
quiet_hours_enabled?: boolean;
quiet_hours_start?: string | null;
@@ -1901,6 +1986,17 @@ export const api = {
deleteArchive: (id: number) =>
request<void>(`/archives/${id}`, { method: 'DELETE' }),
getArchiveStats: () => request<ArchiveStats>('/archives/stats'),
// Tag management
getTags: () => request<TagInfo[]>('/archives/tags'),
renameTag: (oldName: string, newName: string) =>
request<{ affected: number }>(`/archives/tags/${encodeURIComponent(oldName)}`, {
method: 'PUT',
body: JSON.stringify({ new_name: newName }),
}),
deleteTag: (name: string) =>
request<{ affected: number }>(`/archives/tags/${encodeURIComponent(name)}`, {
method: 'DELETE',
}),
recalculateCosts: () =>
request<{ message: string; updated: number }>('/archives/recalculate-costs', { method: 'POST' }),
getFailureAnalysis: (options?: { days?: number; printerId?: number; projectId?: number }) => {
@@ -2394,6 +2490,7 @@ export const api = {
getSmartPlugs: () => request<SmartPlug[]>('/smart-plugs/'),
getSmartPlug: (id: number) => request<SmartPlug>(`/smart-plugs/${id}`),
getSmartPlugByPrinter: (printerId: number) => request<SmartPlug | null>(`/smart-plugs/by-printer/${printerId}`),
getScriptPlugsByPrinter: (printerId: number) => request<SmartPlug[]>(`/smart-plugs/by-printer/${printerId}/scripts`),
createSmartPlug: (data: SmartPlugCreate) =>
request<SmartPlug>('/smart-plugs/', {
method: 'POST',
@@ -2999,11 +3096,17 @@ export const api = {
return request<LibraryFileListItem[]>(`/library/files?${params}`);
},
getLibraryFile: (id: number) => request<LibraryFile>(`/library/files/${id}`),
uploadLibraryFile: async (file: File, folderId?: number | null): Promise<LibraryFileUploadResponse> => {
uploadLibraryFile: async (
file: File,
folderId?: number | null,
generateStlThumbnails: boolean = true
): Promise<LibraryFileUploadResponse> => {
const formData = new FormData();
formData.append('file', file);
const params = folderId ? `?folder_id=${folderId}` : '';
const response = await fetch(`${API_BASE}/library/files${params}`, {
const params = new URLSearchParams();
if (folderId) params.set('folder_id', String(folderId));
params.set('generate_stl_thumbnails', String(generateStlThumbnails));
const response = await fetch(`${API_BASE}/library/files?${params}`, {
method: 'POST',
body: formData,
});
@@ -3017,7 +3120,8 @@ export const api = {
file: File,
folderId?: number | null,
preserveStructure: boolean = true,
createFolderFromZip: boolean = false
createFolderFromZip: boolean = false,
generateStlThumbnails: boolean = true
): Promise<ZipExtractResponse> => {
const formData = new FormData();
formData.append('file', file);
@@ -3025,6 +3129,7 @@ export const api = {
if (folderId) params.set('folder_id', String(folderId));
params.set('preserve_structure', String(preserveStructure));
params.set('create_folder_from_zip', String(createFolderFromZip));
params.set('generate_stl_thumbnails', String(generateStlThumbnails));
const response = await fetch(`${API_BASE}/library/files/extract-zip?${params}`, {
method: 'POST',
body: formData,
@@ -3058,6 +3163,15 @@ export const api = {
body: JSON.stringify({ file_ids: fileIds, folder_ids: folderIds }),
}),
getLibraryStats: () => request<LibraryStats>('/library/stats'),
batchGenerateStlThumbnails: (options: {
file_ids?: number[];
folder_id?: number;
all_missing?: boolean;
}) =>
request<BatchThumbnailResponse>('/library/generate-stl-thumbnails', {
method: 'POST',
body: JSON.stringify(options),
}),
addLibraryFilesToQueue: (fileIds: number[]) =>
request<AddToQueueResponse>('/library/files/add-to-queue', {
method: 'POST',
@@ -3382,6 +3496,21 @@ export interface ZipExtractResponse {
errors: ZipExtractError[];
}
// STL Thumbnail Generation types
export interface BatchThumbnailResult {
file_id: number;
filename: string;
success: boolean;
error?: string | null;
}
export interface BatchThumbnailResponse {
processed: number;
succeeded: number;
failed: number;
results: BatchThumbnailResult[];
}
// Library Queue types
export interface AddToQueueResult {
file_id: number;
+276 -67
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home } from 'lucide-react';
import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home, Radio } from 'lucide-react';
import { api } from '../api/client';
import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate, DiscoveredTasmotaDevice } from '../api/client';
import { Button } from './Button';
@@ -15,7 +15,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
const isEditing = !!plug;
// Plug type selection
const [plugType, setPlugType] = useState<'tasmota' | 'homeassistant'>(plug?.plug_type || 'tasmota');
const [plugType, setPlugType] = useState<'tasmota' | 'homeassistant' | 'mqtt'>(plug?.plug_type || 'tasmota');
const [name, setName] = useState(plug?.name || '');
// Tasmota fields
@@ -24,6 +24,22 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
const [password, setPassword] = useState(plug?.password || '');
// Home Assistant fields
const [haEntityId, setHaEntityId] = useState(plug?.ha_entity_id || '');
// MQTT fields - Power
const [mqttPowerTopic, setMqttPowerTopic] = useState(plug?.mqtt_power_topic || plug?.mqtt_topic || '');
const [mqttPowerPath, setMqttPowerPath] = useState(plug?.mqtt_power_path || '');
const [mqttPowerMultiplier, setMqttPowerMultiplier] = useState<string>(
(plug?.mqtt_power_multiplier ?? plug?.mqtt_multiplier ?? 1).toString()
);
// MQTT fields - Energy
const [mqttEnergyTopic, setMqttEnergyTopic] = useState(plug?.mqtt_energy_topic || '');
const [mqttEnergyPath, setMqttEnergyPath] = useState(plug?.mqtt_energy_path || '');
const [mqttEnergyMultiplier, setMqttEnergyMultiplier] = useState<string>(
(plug?.mqtt_energy_multiplier ?? 1).toString()
);
// MQTT fields - State
const [mqttStateTopic, setMqttStateTopic] = useState(plug?.mqtt_state_topic || '');
const [mqttStatePath, setMqttStatePath] = useState(plug?.mqtt_state_path || '');
const [mqttStateOnValue, setMqttStateOnValue] = useState(plug?.mqtt_state_on_value || '');
// HA energy sensor entities (optional)
const [haPowerEntity, setHaPowerEntity] = useState(plug?.ha_power_entity || '');
const [haEnergyTodayEntity, setHaEnergyTodayEntity] = useState(plug?.ha_energy_today_entity || '');
@@ -279,6 +295,18 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
return;
}
if (plugType === 'mqtt') {
// Check that at least one topic is configured (path is optional)
const hasPower = mqttPowerTopic.trim();
const hasEnergy = mqttEnergyTopic.trim();
const hasState = mqttStateTopic.trim();
if (!hasPower && !hasEnergy && !hasState) {
setError('At least one MQTT topic must be configured for power, energy, or state monitoring');
return;
}
}
const data = {
name: name.trim(),
plug_type: plugType,
@@ -288,6 +316,18 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
ha_power_entity: plugType === 'homeassistant' ? (haPowerEntity || null) : null,
ha_energy_today_entity: plugType === 'homeassistant' ? (haEnergyTodayEntity || null) : null,
ha_energy_total_entity: plugType === 'homeassistant' ? (haEnergyTotalEntity || null) : null,
// MQTT power fields
mqtt_power_topic: plugType === 'mqtt' ? (mqttPowerTopic.trim() || null) : null,
mqtt_power_path: plugType === 'mqtt' ? (mqttPowerPath.trim() || null) : null,
mqtt_power_multiplier: plugType === 'mqtt' ? (parseFloat(mqttPowerMultiplier) || 1) : 1,
// MQTT energy fields
mqtt_energy_topic: plugType === 'mqtt' ? (mqttEnergyTopic.trim() || null) : null,
mqtt_energy_path: plugType === 'mqtt' ? (mqttEnergyPath.trim() || null) : null,
mqtt_energy_multiplier: plugType === 'mqtt' ? (parseFloat(mqttEnergyMultiplier) || 1) : 1,
// MQTT state fields
mqtt_state_topic: plugType === 'mqtt' ? (mqttStateTopic.trim() || null) : null,
mqtt_state_path: plugType === 'mqtt' ? (mqttStatePath.trim() || null) : null,
mqtt_state_on_value: plugType === 'mqtt' ? (mqttStateOnValue.trim() || null) : null,
username: plugType === 'tasmota' ? (username.trim() || null) : null,
password: plugType === 'tasmota' ? (password.trim() || null) : null,
printer_id: printerId,
@@ -352,7 +392,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
setTestResult(null);
setError(null);
}}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-colors ${
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${
plugType === 'tasmota'
? 'bg-bambu-green text-white'
: 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
@@ -368,14 +408,30 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
setTestResult(null);
setError(null);
}}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-colors ${
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${
plugType === 'homeassistant'
? 'bg-bambu-green text-white'
: 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
}`}
>
<Home className="w-4 h-4" />
Home Assistant
HA
</button>
<button
type="button"
onClick={() => {
setPlugType('mqtt');
setTestResult(null);
setError(null);
}}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${
plugType === 'mqtt'
? 'bg-bambu-green text-white'
: 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
}`}
>
<Radio className="w-4 h-4" />
MQTT
</button>
</div>
)}
@@ -857,6 +913,155 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
</div>
)}
{/* MQTT Configuration - only show when MQTT is selected */}
{plugType === 'mqtt' && (
<div className="space-y-3">
{/* MQTT broker not configured */}
{!settings?.mqtt_broker && (
<div className="p-3 bg-yellow-500/20 border border-yellow-500/50 rounded-lg text-sm text-yellow-400">
MQTT broker not configured. Set broker address in{' '}
<span className="font-medium">Settings → Network → MQTT Publishing</span>
{' '}(you don't need to enable publishing, just fill in the broker details).
</div>
)}
{/* MQTT broker configured - show fields */}
{settings?.mqtt_broker && (
<>
<div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg text-sm text-blue-300">
<p className="font-medium mb-1">Monitor Only</p>
<p className="text-xs opacity-80">
MQTT plugs receive power/energy data via MQTT subscription. On/off control is not available - use your MQTT broker or home automation system.
</p>
</div>
{/* Power Section */}
<div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<p className="text-white font-medium text-sm">Power Monitoring</p>
<div>
<label className="block text-sm text-bambu-gray mb-1">Topic</label>
<input
type="text"
value={mqttPowerTopic}
onChange={(e) => setMqttPowerTopic(e.target.value)}
placeholder="zigbee2mqtt/shelly-working-room"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-bambu-gray mb-1">JSON Path</label>
<input
type="text"
value={mqttPowerPath}
onChange={(e) => setMqttPowerPath(e.target.value)}
placeholder="power_l1"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">Multiplier</label>
<input
type="text"
value={mqttPowerMultiplier}
onChange={(e) => setMqttPowerMultiplier(e.target.value)}
placeholder="1"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
</div>
<p className="text-xs text-bambu-gray">
JSON path extracts value from JSON payload (e.g., "power_l1"). Leave empty if topic publishes raw numeric values.<br/>
Use multiplier 0.001 for mW→W, 1000 for kW→W.
</p>
</div>
{/* Energy Section */}
<div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<p className="text-white font-medium text-sm">Energy Monitoring <span className="text-bambu-gray font-normal">(optional)</span></p>
<div>
<label className="block text-sm text-bambu-gray mb-1">Topic</label>
<input
type="text"
value={mqttEnergyTopic}
onChange={(e) => setMqttEnergyTopic(e.target.value)}
placeholder="Same as power topic, or different"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-bambu-gray mb-1">JSON Path</label>
<input
type="text"
value={mqttEnergyPath}
onChange={(e) => setMqttEnergyPath(e.target.value)}
placeholder="energy_l1"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">Multiplier</label>
<input
type="text"
value={mqttEnergyMultiplier}
onChange={(e) => setMqttEnergyMultiplier(e.target.value)}
placeholder="1"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
</div>
<p className="text-xs text-bambu-gray">
JSON path extracts value from JSON payload. Leave empty for raw values.<br/>
Use multiplier 0.001 for Wh→kWh, 1000 for MWh→kWh.
</p>
</div>
{/* State Section */}
<div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<p className="text-white font-medium text-sm">State Monitoring <span className="text-bambu-gray font-normal">(optional)</span></p>
<div>
<label className="block text-sm text-bambu-gray mb-1">Topic</label>
<input
type="text"
value={mqttStateTopic}
onChange={(e) => setMqttStateTopic(e.target.value)}
placeholder="Same as power topic, or different"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-bambu-gray mb-1">JSON Path</label>
<input
type="text"
value={mqttStatePath}
onChange={(e) => setMqttStatePath(e.target.value)}
placeholder="state_l1"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">ON Value</label>
<input
type="text"
value={mqttStateOnValue}
onChange={(e) => setMqttStateOnValue(e.target.value)}
placeholder="ON, true, 1"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
</div>
<p className="text-xs text-bambu-gray">
JSON path extracts value from JSON payload. Leave empty for raw values.<br/>
ON value: the exact string that means "ON". Leave empty for auto-detect (ON, true, 1).
</p>
</div>
</>
)}
</div>
)}
{/* IP Address - only show for Tasmota */}
{plugType === 'tasmota' && (
<div>
@@ -959,25 +1164,27 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
</>
)}
{/* Link to Printer */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Link to Printer</label>
<select
value={printerId ?? ''}
onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="">No printer (manual control only)</option>
{availablePrinters?.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<p className="text-xs text-bambu-gray mt-1">
Linking enables automatic on/off when prints start/complete
</p>
</div>
{/* Link to Printer - not shown for MQTT plugs (monitor-only) */}
{plugType !== 'mqtt' && (
<div>
<label className="block text-sm text-bambu-gray mb-1">Link to Printer</label>
<select
value={printerId ?? ''}
onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="">No printer (manual control only)</option>
{availablePrinters?.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<p className="text-xs text-bambu-gray mt-1">
Linking enables automatic on/off when prints start/complete
</p>
</div>
)}
{/* Power Alerts */}
<div className="border-t border-bambu-dark-tertiary pt-4">
@@ -1031,51 +1238,53 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
)}
</div>
{/* Schedule */}
<div className="border-t border-bambu-dark-tertiary pt-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-bambu-green" />
<span className="text-white font-medium">Daily Schedule</span>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={scheduleEnabled}
onChange={(e) => setScheduleEnabled(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
{scheduleEnabled && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-bambu-gray mb-1">Turn On at</label>
<input
type="time"
value={scheduleOnTime}
onChange={(e) => setScheduleOnTime(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">Turn Off at</label>
<input
type="time"
value={scheduleOffTime}
onChange={(e) => setScheduleOffTime(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
</div>
{/* Schedule - not shown for MQTT plugs (monitor-only) */}
{plugType !== 'mqtt' && (
<div className="border-t border-bambu-dark-tertiary pt-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-bambu-green" />
<span className="text-white font-medium">Daily Schedule</span>
</div>
<p className="text-xs text-bambu-gray">
Automatically turn the plug on/off at these times daily. Leave empty to skip that action.
</p>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={scheduleEnabled}
onChange={(e) => setScheduleEnabled(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
)}
</div>
{scheduleEnabled && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-bambu-gray mb-1">Turn On at</label>
<input
type="time"
value={scheduleOnTime}
onChange={(e) => setScheduleOnTime(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">Turn Off at</label>
<input
type="time"
value={scheduleOffTime}
onChange={(e) => setScheduleOffTime(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
</div>
</div>
<p className="text-xs text-bambu-gray">
Automatically turn the plug on/off at these times daily. Leave empty to skip that action.
</p>
</div>
)}
</div>
)}
{/* Switchbar Visibility */}
<div className="border-t border-bambu-dark-tertiary pt-4">
+33 -14
View File
@@ -68,30 +68,49 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
queryFn: () => api.getProjects(),
});
// Get all archives to extract existing tags if not provided
const { data: archives } = useQuery({
queryKey: ['archives'],
queryFn: () => api.getArchives(undefined, 1000, 0),
// Fetch all tags using the dedicated API
const { data: tagsData } = useQuery({
queryKey: ['tags'],
queryFn: api.getTags,
enabled: existingTags.length === 0,
});
// Extract unique tags from all archives
// Use existing tags prop if provided, otherwise use fetched tags
const allTags = existingTags.length > 0
? existingTags
: [...new Set(
archives?.flatMap(a => a.tags?.split(',').map(t => t.trim()) || []).filter(Boolean) || []
)].sort();
: (tagsData?.map(t => t.name) || []);
// Get current tags as array
const currentTags = tags.split(',').map(t => t.trim()).filter(Boolean);
// Filter suggestions based on what's not already added
const tagSuggestions = allTags.filter(t => !currentTags.includes(t));
// Get the text being typed after the last comma (for autocomplete filtering)
const currentInput = tags.includes(',')
? tags.substring(tags.lastIndexOf(',') + 1).trim().toLowerCase()
: tags.trim().toLowerCase();
// Add a tag
// Filter suggestions: not already added AND matches current input (if any)
const tagSuggestions = allTags.filter(t =>
!currentTags.includes(t) &&
(currentInput === '' || t.toLowerCase().includes(currentInput))
);
// Add a tag (replaces any partial input with the selected tag)
const addTag = (tag: string) => {
if (!currentTags.includes(tag)) {
const newTags = [...currentTags, tag].join(', ');
// If there's partial input being typed, replace it with the selected tag
// Otherwise, just append the tag
let baseTags: string[];
if (currentInput && !allTags.includes(currentInput)) {
// User is typing a partial tag - replace it with the selected one
baseTags = tags.includes(',')
? tags.substring(0, tags.lastIndexOf(',')).split(',').map(t => t.trim()).filter(Boolean)
: [];
} else {
// No partial input or input is already a complete tag - append
baseTags = currentTags;
}
if (!baseTags.includes(tag)) {
const newTags = [...baseTags, tag].join(', ');
setTags(newTags);
}
// Clear any pending blur timeout to prevent hiding suggestions
@@ -342,7 +361,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
{showTagSuggestions && tagSuggestions.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-10 max-h-40 overflow-y-auto">
<div className="p-2 text-xs text-bambu-gray border-b border-bambu-dark-tertiary">
Existing tags (click to add)
{currentInput ? `Matching "${currentInput}"` : 'Existing tags'} (click to add)
</div>
<div className="p-2 flex flex-wrap gap-1.5">
{tagSuggestions.map((tag) => (
@@ -398,6 +398,88 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
</div>
</div>
{/* Print Queue Events */}
<div className="space-y-2">
<p className="text-xs text-bambu-gray uppercase tracking-wide">Print Queue</p>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Job Added</p>
<p className="text-xs text-bambu-gray">Job added to queue</p>
</div>
<Toggle
checked={provider.on_queue_job_added ?? false}
onChange={(checked) => updateMutation.mutate({ on_queue_job_added: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Job Assigned</p>
<p className="text-xs text-bambu-gray">Model-based job assigned to printer</p>
</div>
<Toggle
checked={provider.on_queue_job_assigned ?? false}
onChange={(checked) => updateMutation.mutate({ on_queue_job_assigned: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Job Started</p>
<p className="text-xs text-bambu-gray">Queue job started printing</p>
</div>
<Toggle
checked={provider.on_queue_job_started ?? false}
onChange={(checked) => updateMutation.mutate({ on_queue_job_started: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Job Waiting</p>
<p className="text-xs text-bambu-gray">Job waiting for filament</p>
</div>
<Toggle
checked={provider.on_queue_job_waiting ?? true}
onChange={(checked) => updateMutation.mutate({ on_queue_job_waiting: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Job Skipped</p>
<p className="text-xs text-bambu-gray">Job skipped (previous failed)</p>
</div>
<Toggle
checked={provider.on_queue_job_skipped ?? true}
onChange={(checked) => updateMutation.mutate({ on_queue_job_skipped: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Job Failed</p>
<p className="text-xs text-bambu-gray">Job failed to start</p>
</div>
<Toggle
checked={provider.on_queue_job_failed ?? true}
onChange={(checked) => updateMutation.mutate({ on_queue_job_failed: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Queue Complete</p>
<p className="text-xs text-bambu-gray">All queue jobs finished</p>
</div>
<Toggle
checked={provider.on_queue_completed ?? false}
onChange={(checked) => updateMutation.mutate({ on_queue_completed: checked })}
/>
</div>
</div>
{/* Quiet Hours */}
<div className="space-y-2">
<div className="flex items-center justify-between">
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
Printer as PrinterIcon,
@@ -9,6 +9,7 @@ import {
Circle,
RefreshCw,
Wand2,
Users,
} from 'lucide-react';
import { api } from '../../api/client';
import { getColorName } from '../../utils/colors';
@@ -16,7 +17,7 @@ import {
normalizeColorForCompare,
colorsAreSimilar,
} from '../../utils/amsHelpers';
import type { PrinterSelectorProps } from './types';
import type { PrinterSelectorProps, AssignmentMode } from './types';
import type { PrinterMappingResult, PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
import type { FilamentRequirement, LoadedFilament } from '../../hooks/useFilamentMapping';
@@ -29,6 +30,16 @@ interface PrinterSelectorWithMappingProps extends PrinterSelectorProps {
onAutoConfigurePrinter?: (printerId: number) => void;
/** Callback to update printer config */
onUpdatePrinterConfig?: (printerId: number, config: Partial<PerPrinterConfig>) => void;
/** Current assignment mode */
assignmentMode?: AssignmentMode;
/** Handler for assignment mode change */
onAssignmentModeChange?: (mode: AssignmentMode) => void;
/** Selected target model (when assignmentMode is 'model') */
targetModel?: string | null;
/** Handler for target model change */
onTargetModelChange?: (model: string | null) => void;
/** Suggested model from sliced file (for pre-selection) */
slicedForModel?: string | null;
}
/**
@@ -212,9 +223,42 @@ export function PrinterSelector({
filamentReqs,
onAutoConfigurePrinter,
onUpdatePrinterConfig,
assignmentMode = 'printer',
onAssignmentModeChange,
targetModel,
onTargetModelChange,
slicedForModel,
}: PrinterSelectorWithMappingProps) {
// State for showing all printers vs only matching model
const [showAllPrinters, setShowAllPrinters] = useState(false);
// Filter printers based on showInactive flag
const displayPrinters = showInactive ? printers : printers.filter((p) => p.is_active);
const activePrinters = showInactive ? printers : printers.filter((p) => p.is_active);
// Filter by sliced model (only in printer mode, when slicedForModel is set)
const displayPrinters = useMemo(() => {
if (assignmentMode !== 'printer' || !slicedForModel || showAllPrinters) {
return activePrinters;
}
// Filter to only show printers matching the sliced model
const matching = activePrinters.filter((p) => p.model === slicedForModel);
// If no matching printers, show all
return matching.length > 0 ? matching : activePrinters;
}, [activePrinters, assignmentMode, slicedForModel, showAllPrinters]);
// Check if there are hidden printers due to model filtering
const hiddenPrinterCount = activePrinters.length - displayPrinters.length;
// Get unique models from available printers (for model-based assignment)
const uniqueModels = useMemo(() => {
const models = activePrinters
.map(p => p.model)
.filter((m): m is string => Boolean(m));
return [...new Set(models)].sort();
}, [activePrinters]);
// Check if model-based assignment is available (need callbacks and multiple printers of same model)
const modelAssignmentAvailable = onAssignmentModeChange && onTargetModelChange && uniqueModels.length > 0;
const showMappingOptions = allowMultiple &&
selectedPrinterIds.length > 1 &&
@@ -285,8 +329,56 @@ export function PrinterSelector({
return (
<div className="space-y-2 mb-6">
{/* Multi-select header */}
{allowMultiple && displayPrinters.length > 1 && (
{/* Assignment mode toggle (model vs specific printer) */}
{modelAssignmentAvailable && (
<div className="flex gap-2 mb-4">
<button
type="button"
onClick={() => {
onAssignmentModeChange!('printer');
onTargetModelChange!(null);
}}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
assignmentMode === 'printer'
? 'border-bambu-green bg-bambu-green/10 text-white'
: 'border-bambu-dark-tertiary bg-bambu-dark text-bambu-gray hover:border-bambu-gray'
}`}
>
<PrinterIcon className="w-4 h-4" />
<span className="text-sm">Specific Printer</span>
</button>
<button
type="button"
onClick={() => {
onAssignmentModeChange!('model');
onMultiSelect([]);
// Pre-select the sliced-for model if available, otherwise first model
const defaultModel = slicedForModel && uniqueModels.includes(slicedForModel)
? slicedForModel
: uniqueModels[0];
onTargetModelChange!(defaultModel);
}}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
assignmentMode === 'model'
? 'border-bambu-green bg-bambu-green/10 text-white'
: 'border-bambu-dark-tertiary bg-bambu-dark text-bambu-gray hover:border-bambu-gray'
}`}
>
<Users className="w-4 h-4" />
<span className="text-sm">Any {slicedForModel || 'Model'}</span>
</button>
</div>
)}
{/* Model info (when in model mode) */}
{assignmentMode === 'model' && modelAssignmentAvailable && targetModel && (
<p className="text-xs text-bambu-gray mb-4">
Scheduler will assign to first available idle {targetModel} printer
</p>
)}
{/* Multi-select header (only in printer mode) */}
{assignmentMode === 'printer' && allowMultiple && displayPrinters.length > 1 && (
<div className="flex items-center justify-between text-xs text-bambu-gray mb-2">
<span>
{selectedCount === 0
@@ -316,7 +408,8 @@ export function PrinterSelector({
</div>
)}
{displayPrinters.map((printer) => {
{/* Printer list (only in printer mode) */}
{assignmentMode === 'printer' && displayPrinters.map((printer) => {
const selected = isSelected(printer.id);
const mappingResult = getPrinterMappingResult(printer.id);
const hasOverride = mappingResult && !mappingResult.config.useDefault;
@@ -430,13 +523,45 @@ export function PrinterSelector({
);
})}
{/* Warning when no printer selected */}
{selectedCount === 0 && (
{/* Show hidden printers toggle */}
{assignmentMode === 'printer' && hiddenPrinterCount > 0 && !showAllPrinters && (
<button
type="button"
onClick={() => setShowAllPrinters(true)}
className="text-xs text-bambu-gray hover:text-white transition-colors mt-2 flex items-center gap-1"
>
<AlertTriangle className="w-3 h-3 text-yellow-400" />
{hiddenPrinterCount} other printer{hiddenPrinterCount > 1 ? 's' : ''} hidden (different model) —
<span className="underline">show all</span>
</button>
)}
{/* Show matching only toggle */}
{assignmentMode === 'printer' && showAllPrinters && slicedForModel && (
<button
type="button"
onClick={() => setShowAllPrinters(false)}
className="text-xs text-bambu-gray hover:text-white transition-colors mt-2"
>
<span className="underline">Show only {slicedForModel} printers</span>
</button>
)}
{/* Warning when no printer selected (only in printer mode) */}
{assignmentMode === 'printer' && selectedCount === 0 && (
<p className="text-xs text-orange-400 mt-1 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Select at least one printer
</p>
)}
{/* Warning when no model selected (only in model mode) */}
{assignmentMode === 'model' && !targetModel && (
<p className="text-xs text-orange-400 mt-1 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Select a target printer model
</p>
)}
</div>
);
}
+144 -42
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { X, Printer, Loader2, Calendar, Pencil, AlertCircle } from 'lucide-react';
import { X, Printer, Loader2, Calendar, Pencil, AlertCircle, AlertTriangle } from 'lucide-react';
import { api } from '../../api/client';
import type { PrintQueueItemCreate, PrintQueueItemUpdate } from '../../api/client';
import { Card, CardContent } from '../Card';
@@ -19,6 +19,7 @@ import type {
PrintOptions,
ScheduleOptions,
ScheduleType,
AssignmentMode,
} from './types';
import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from './types';
@@ -117,6 +118,23 @@ export function PrintModal({
// Per-printer override configs (for multi-printer selection)
const [perPrinterConfigs, setPerPrinterConfigs] = useState<Record<number, PerPrinterConfig>>({});
// Assignment mode: 'printer' (specific) or 'model' (any of model)
const [assignmentMode, setAssignmentMode] = useState<AssignmentMode>(() => {
// Initialize from queue item if editing with target_model
if (mode === 'edit-queue-item' && queueItem?.target_model) {
return 'model';
}
return 'printer';
});
// Target model for model-based assignment
const [targetModel, setTargetModel] = useState<string | null>(() => {
if (mode === 'edit-queue-item' && queueItem?.target_model) {
return queueItem.target_model;
}
return null;
});
// Track initial values for clearing mappings on change (edit mode only)
const [initialPrinterIds] = useState(() => (mode === 'edit-queue-item' && queueItem?.printer_id ? [queueItem.printer_id] : []));
const [initialPlateId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.plate_id : null));
@@ -145,6 +163,16 @@ export function PrintModal({
queryFn: api.getPrinters,
});
// Fetch archive details to get sliced_for_model
const { data: archiveDetails } = useQuery({
queryKey: ['archive', archiveId],
queryFn: () => api.getArchive(archiveId!),
enabled: !!archiveId && !isLibraryFile,
});
// Get sliced_for_model from archive or library file
const slicedForModel = archiveDetails?.sliced_for_model || null;
// Fetch plates for archives
const { data: archivePlatesData, isError: archivePlatesError } = useQuery({
queryKey: ['archive-plates', archiveId],
@@ -304,14 +332,20 @@ export function PrintModal({
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
// Validate printer selection
if (selectedPrinters.length === 0) {
// Validate printer/model selection
if (assignmentMode === 'printer' && selectedPrinters.length === 0) {
showToast('Please select at least one printer', 'error');
return;
}
if (assignmentMode === 'model' && !targetModel) {
showToast('Please select a target printer model', 'error');
return;
}
setIsSubmitting(true);
setSubmitProgress({ current: 0, total: selectedPrinters.length });
// For model-based assignment, we just make one API call
const totalCount = assignmentMode === 'model' ? 1 : selectedPrinters.length;
setSubmitProgress({ current: 0, total: totalCount });
const results: { success: number; failed: number; errors: string[] } = {
success: 0,
@@ -332,15 +366,16 @@ export function PrintModal({
};
// Common queue data for add-to-queue and edit modes
const getQueueData = (printerId: number): PrintQueueItemCreate => ({
printer_id: printerId,
const getQueueData = (printerId: number | null): PrintQueueItemCreate => ({
printer_id: assignmentMode === 'printer' ? printerId : null,
target_model: assignmentMode === 'model' ? targetModel : null,
// Use library_file_id for library files, archive_id for archives
archive_id: isLibraryFile ? undefined : archiveId,
library_file_id: isLibraryFile ? libraryFileId : undefined,
require_previous_success: scheduleOptions.requirePreviousSuccess,
auto_off_after: scheduleOptions.autoOffAfter,
manual_start: scheduleOptions.scheduleType === 'manual',
ams_mapping: getMappingForPrinter(printerId),
ams_mapping: printerId ? getMappingForPrinter(printerId) : undefined,
plate_id: selectedPlate,
scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
? new Date(scheduleOptions.scheduledTime).toISOString()
@@ -348,35 +383,24 @@ export function PrintModal({
...printOptions,
});
for (let i = 0; i < selectedPrinters.length; i++) {
const printerId = selectedPrinters[i];
setSubmitProgress({ current: i + 1, total: selectedPrinters.length });
// Model-based assignment: single API call
if (assignmentMode === 'model') {
setSubmitProgress({ current: 1, total: 1 });
try {
if (mode === 'reprint') {
// Reprint mode - start print immediately
const printerMapping = getMappingForPrinter(printerId);
if (isLibraryFile) {
await api.printLibraryFile(libraryFileId!, printerId, {
ams_mapping: printerMapping,
...printOptions,
});
} else {
await api.reprintArchive(archiveId!, printerId, {
plate_id: selectedPlate ?? undefined,
ams_mapping: printerMapping,
...printOptions,
});
}
} else if (mode === 'edit-queue-item' && i === 0) {
// Edit mode - update the original queue item for the first printer
const printerMapping = getMappingForPrinter(printerId);
// Model-based reprint not supported (need specific printer for immediate print)
showToast('Model-based assignment only works with queue mode', 'error');
setIsSubmitting(false);
return;
} else if (mode === 'edit-queue-item') {
// Edit mode - update with target_model
const updateData: PrintQueueItemUpdate = {
printer_id: printerId,
printer_id: null,
target_model: targetModel,
require_previous_success: scheduleOptions.requirePreviousSuccess,
auto_off_after: scheduleOptions.autoOffAfter,
manual_start: scheduleOptions.scheduleType === 'manual',
ams_mapping: printerMapping,
ams_mapping: undefined,
plate_id: selectedPlate,
scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
? new Date(scheduleOptions.scheduledTime).toISOString()
@@ -385,14 +409,63 @@ export function PrintModal({
};
await updateQueueMutation.mutateAsync(updateData);
} else {
// Add-to-queue mode OR edit mode with additional printers
await addToQueueMutation.mutateAsync(getQueueData(printerId));
// Add-to-queue mode with model-based assignment
await addToQueueMutation.mutateAsync(getQueueData(null));
}
results.success++;
} catch (error) {
results.failed++;
const printerName = printers?.find(p => p.id === printerId)?.name || `Printer ${printerId}`;
results.errors.push(`${printerName}: ${(error as Error).message}`);
results.errors.push((error as Error).message);
}
} else {
// Printer-based assignment: loop through selected printers
for (let i = 0; i < selectedPrinters.length; i++) {
const printerId = selectedPrinters[i];
setSubmitProgress({ current: i + 1, total: selectedPrinters.length });
try {
if (mode === 'reprint') {
// Reprint mode - start print immediately
const printerMapping = getMappingForPrinter(printerId);
if (isLibraryFile) {
await api.printLibraryFile(libraryFileId!, printerId, {
ams_mapping: printerMapping,
...printOptions,
});
} else {
await api.reprintArchive(archiveId!, printerId, {
plate_id: selectedPlate ?? undefined,
ams_mapping: printerMapping,
...printOptions,
});
}
} else if (mode === 'edit-queue-item' && i === 0) {
// Edit mode - update the original queue item for the first printer
const printerMapping = getMappingForPrinter(printerId);
const updateData: PrintQueueItemUpdate = {
printer_id: printerId,
target_model: null,
require_previous_success: scheduleOptions.requirePreviousSuccess,
auto_off_after: scheduleOptions.autoOffAfter,
manual_start: scheduleOptions.scheduleType === 'manual',
ams_mapping: printerMapping,
plate_id: selectedPlate,
scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
? new Date(scheduleOptions.scheduledTime).toISOString()
: null,
...printOptions,
};
await updateQueueMutation.mutateAsync(updateData);
} else {
// Add-to-queue mode OR edit mode with additional printers
await addToQueueMutation.mutateAsync(getQueueData(printerId));
}
results.success++;
} catch (error) {
results.failed++;
const printerName = printers?.find(p => p.id === printerId)?.name || `Printer ${printerId}`;
results.errors.push(`${printerName}: ${(error as Error).message}`);
}
}
}
@@ -400,11 +473,15 @@ export function PrintModal({
// Show result toast
if (results.failed === 0) {
const action = mode === 'reprint' ? 'sent to' : (mode === 'edit-queue-item' ? 'updated/queued for' : 'queued for');
if (results.success === 1) {
showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Print ${action} printer`);
if (assignmentMode === 'model') {
showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Queued for any ${targetModel}`);
} else {
showToast(`Print ${action} ${results.success} printers`);
const action = mode === 'reprint' ? 'sent to' : (mode === 'edit-queue-item' ? 'updated/queued for' : 'queued for');
if (results.success === 1) {
showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Print ${action} printer`);
} else {
showToast(`Print ${action} ${results.success} printers`);
}
}
queryClient.invalidateQueries({ queryKey: ['queue'] });
onSuccess?.();
@@ -422,14 +499,18 @@ export function PrintModal({
const canSubmit = useMemo(() => {
if (isPending) return false;
// Need at least one selected printer
if (selectedPrinters.length === 0) return false;
// Need valid printer/model selection
if (assignmentMode === 'printer' && selectedPrinters.length === 0) return false;
if (assignmentMode === 'model' && !targetModel) return false;
// Model-based assignment only works in queue modes (not immediate reprint)
if (assignmentMode === 'model' && mode === 'reprint') return false;
// For multi-plate archive files, need a selected plate (library files skip this)
if (!isLibraryFile && isMultiPlate && !selectedPlate) return false;
return true;
}, [selectedPrinters.length, isMultiPlate, selectedPlate, isPending, isLibraryFile]);
}, [selectedPrinters.length, assignmentMode, targetModel, mode, isMultiPlate, selectedPlate, isPending, isLibraryFile]);
// Modal title and action button text based on mode
const getModalConfig = () => {
@@ -541,8 +622,29 @@ export function PrintModal({
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}
slicedForModel={slicedForModel}
/>
{/* Compatibility warning when sliced model doesn't match selected printer */}
{slicedForModel && assignmentMode === 'printer' && selectedPrinters.length === 1 && (() => {
const selectedPrinter = printers?.find(p => p.id === selectedPrinters[0]);
if (selectedPrinter && selectedPrinter.model && slicedForModel !== selectedPrinter.model) {
return (
<div className="p-3 mb-2 bg-yellow-500/10 border border-yellow-500/30 rounded-lg flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-yellow-400 flex-shrink-0" />
<span className="text-sm text-yellow-400">
File was sliced for {slicedForModel}, but printing on {selectedPrinter.model}
</span>
</div>
);
}
return null;
})()}
{/* Warning when archive data couldn't be loaded */}
{archiveDataMissing && (
<div className="flex items-start gap-2 p-3 mb-2 bg-orange-500/10 border border-orange-500/30 rounded-lg text-sm">
@@ -565,7 +667,7 @@ export function PrintModal({
)}
{/* Print options */}
{(mode === 'reprint' || effectivePrinterCount > 0) && (
{(mode === 'reprint' || effectivePrinterCount > 0 || (assignmentMode === 'model' && targetModel)) && (
<PrintOptionsPanel options={printOptions} onChange={setPrintOptions} />
)}
@@ -104,6 +104,13 @@ export interface PlatesResponse {
plates: PlateInfo[];
}
/**
* Assignment mode for queue items.
* - 'printer': Assign to specific printer(s)
* - 'model': Assign to any printer of a specific model (load balancing)
*/
export type AssignmentMode = 'printer' | 'model';
/**
* Props for the PrinterSelector component.
*/
@@ -115,6 +122,16 @@ export interface PrinterSelectorProps {
allowMultiple?: boolean;
/** Show inactive printers (for edit mode where original assignment may be inactive) */
showInactive?: boolean;
/** Current assignment mode */
assignmentMode?: AssignmentMode;
/** Handler for assignment mode change */
onAssignmentModeChange?: (mode: AssignmentMode) => void;
/** Selected target model (when assignmentMode is 'model') */
targetModel?: string | null;
/** Handler for target model change */
onTargetModelChange?: (model: string | null) => void;
/** Suggested model from sliced file (for pre-selection) */
slicedForModel?: string | null;
}
/**
+128 -73
View File
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid, ExternalLink, Home } from 'lucide-react';
import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid, ExternalLink, Home, Radio, Eye } from 'lucide-react';
import { api } from '../api/client';
import type { SmartPlug, SmartPlugUpdate } from '../api/client';
import { Card, CardContent } from './Card';
@@ -92,7 +92,9 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
});
const isOn = status?.state === 'ON';
const isReachable = status?.reachable ?? false;
// For MQTT plugs, consider reachable if we have power data (even if backend says not reachable)
const hasMqttData = plug.plug_type === 'mqtt' && (status?.energy?.power !== null && status?.energy?.power !== undefined);
const isReachable = (status?.reachable ?? false) || hasMqttData;
const isPending = controlMutation.isPending;
// Generate admin URL with auto-login credentials (Tasmota only)
@@ -113,27 +115,49 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
<Card className="relative">
<CardContent className="p-4">
{/* Header Row */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20'}`}>
{plug.plug_type === 'homeassistant' ? (
<div className="flex items-start justify-between gap-2 mb-3">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className={`p-2 rounded-lg flex-shrink-0 ${
plug.plug_type === 'mqtt'
? (isReachable ? 'bg-teal-500/20' : 'bg-red-500/20')
: (isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20')
}`}>
{plug.plug_type === 'mqtt' ? (
<Radio className={`w-5 h-5 ${isReachable ? 'text-teal-400' : 'text-red-400'}`} />
) : plug.plug_type === 'homeassistant' ? (
<Home className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
) : (
<Plug className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
)}
</div>
<div>
<h3 className="font-medium text-white">{plug.name}</h3>
<p className="text-sm text-bambu-gray">
{plug.plug_type === 'homeassistant' ? plug.ha_entity_id : plug.ip_address}
<div className="min-w-0">
<h3 className="font-medium text-white truncate">{plug.name}</h3>
<p
className="text-sm text-bambu-gray truncate"
title={plug.plug_type === 'mqtt' ? plug.mqtt_topic ?? undefined : plug.plug_type === 'homeassistant' ? plug.ha_entity_id ?? undefined : plug.ip_address ?? undefined}
>
{plug.plug_type === 'mqtt' ? plug.mqtt_topic : plug.plug_type === 'homeassistant' ? plug.ha_entity_id : plug.ip_address}
</p>
</div>
</div>
{/* Status indicator */}
<div className="flex flex-col items-end gap-1">
<div className="flex flex-col items-end gap-1 flex-shrink-0">
{statusLoading ? (
<Loader2 className="w-4 h-4 text-bambu-gray animate-spin" />
) : plug.plug_type === 'mqtt' ? (
/* MQTT plugs - show badge and checkmark when receiving data */
<div className="flex items-center gap-1.5 text-sm whitespace-nowrap">
<span className="px-1.5 py-0.5 bg-teal-500/20 text-teal-400 text-[10px] font-medium rounded flex-shrink-0">MQTT</span>
{isReachable && <span className="text-status-ok">✓</span>}
</div>
) : plug.plug_type === 'homeassistant' ? (
<div className="flex items-center gap-1 text-sm">
<span className="px-1 py-0.5 bg-blue-500/20 text-blue-400 text-[10px] font-medium rounded">HA</span>
<span className={isReachable ? (isOn ? 'text-status-ok' : 'text-bambu-gray') : 'text-status-error'}>
{isReachable ? (status?.state || '?') : 'Offline'}
</span>
</div>
) : isReachable ? (
<div className="flex items-center gap-1 text-sm">
<Wifi className="w-4 h-4 text-status-ok" />
@@ -170,8 +194,14 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
)}
{/* Feature Badges */}
{(plug.power_alert_enabled || plug.schedule_enabled) && (
{(plug.power_alert_enabled || plug.schedule_enabled || plug.plug_type === 'mqtt') && (
<div className="flex flex-wrap gap-1.5 mb-3">
{plug.plug_type === 'mqtt' && (
<span className="flex items-center gap-1 px-2 py-0.5 bg-teal-500/20 text-teal-400 text-xs rounded-full">
<Eye className="w-3 h-3" />
Monitor Only
</span>
)}
{plug.power_alert_enabled && (
<span className="flex items-center gap-1 px-2 py-0.5 bg-yellow-500/20 text-yellow-400 text-xs rounded-full">
<Bell className="w-3 h-3" />
@@ -191,29 +221,49 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
</div>
)}
{/* Quick Controls */}
<div className="flex gap-2 mb-3">
<Button
size="sm"
variant={isOn ? 'primary' : 'secondary'}
disabled={!isReachable || isPending}
onClick={() => setShowPowerOnConfirm(true)}
className="flex-1"
>
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
On
</Button>
<Button
size="sm"
variant={!isOn ? 'primary' : 'secondary'}
disabled={!isReachable || isPending}
onClick={() => setShowPowerOffConfirm(true)}
className="flex-1"
>
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
Off
</Button>
</div>
{/* Quick Controls - hidden for MQTT plugs (monitor-only) */}
{plug.plug_type !== 'mqtt' && (
<div className="flex gap-2 mb-3">
<Button
size="sm"
variant={isOn ? 'primary' : 'secondary'}
disabled={!isReachable || isPending}
onClick={() => setShowPowerOnConfirm(true)}
className="flex-1"
>
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
On
</Button>
<Button
size="sm"
variant={!isOn ? 'primary' : 'secondary'}
disabled={!isReachable || isPending}
onClick={() => setShowPowerOffConfirm(true)}
className="flex-1"
>
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
Off
</Button>
</div>
)}
{/* Energy display for MQTT plugs */}
{plug.plug_type === 'mqtt' && status?.energy && (
<div className="flex gap-2 mb-3 px-3 py-2 bg-bambu-dark rounded-lg">
{status.energy.power !== null && status.energy.power !== undefined && (
<div className="flex-1 text-center">
<p className="text-lg font-semibold text-white">{Math.round(status.energy.power)}W</p>
<p className="text-xs text-bambu-gray">Power</p>
</div>
)}
{status.energy.today !== null && status.energy.today !== undefined && (
<div className="flex-1 text-center border-l border-bambu-dark-tertiary">
<p className="text-lg font-semibold text-white">{status.energy.today.toFixed(2)}</p>
<p className="text-xs text-bambu-gray">kWh Today</p>
</div>
)}
</div>
)}
{/* Toggle Settings Panel */}
<button
@@ -222,7 +272,7 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
>
<span className="flex items-center gap-2">
<Settings2 className="w-4 h-4" />
Automation Settings
{plug.plug_type === 'mqtt' ? 'Settings' : 'Automation Settings'}
</span>
<span>{isExpanded ? '-' : '+'}</span>
</button>
@@ -250,45 +300,48 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
</label>
</div>
{/* Enabled Toggle */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Enabled</p>
<p className="text-xs text-bambu-gray">Enable automation for this plug</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={plug.enabled}
onChange={(e) => updateMutation.mutate({ enabled: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
{/* Automation controls - only for controllable plugs (not MQTT) */}
{plug.plug_type !== 'mqtt' && (
<>
{/* Enabled Toggle */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Enabled</p>
<p className="text-xs text-bambu-gray">Enable automation for this plug</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={plug.enabled}
onChange={(e) => updateMutation.mutate({ enabled: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
{/* Auto On */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Auto On</p>
<p className="text-xs text-bambu-gray">Turn on when print starts</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={plug.auto_on}
onChange={(e) => updateMutation.mutate({ auto_on: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
{/* Auto On */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Auto On</p>
<p className="text-xs text-bambu-gray">Turn on when print starts</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={plug.auto_on}
onChange={(e) => updateMutation.mutate({ auto_on: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
{/* Auto Off */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Auto Off</p>
<p className="text-xs text-bambu-gray">Turn off when print completes (one-shot)</p>
{/* Auto Off */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Auto Off</p>
<p className="text-xs text-bambu-gray">Turn off when print completes (one-shot)</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
@@ -360,6 +413,8 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
)}
</div>
)}
</>
)}
{/* Action Buttons */}
<div className="flex gap-2 pt-2">
+60 -30
View File
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plug, Power, PowerOff, Loader2, Wifi, WifiOff, Zap } from 'lucide-react';
import { Plug, Power, PowerOff, Loader2, Wifi, WifiOff, Zap, Radio, Eye } from 'lucide-react';
import { api } from '../api/client';
import type { SmartPlug } from '../api/client';
import { ConfirmModal } from './ConfirmModal';
@@ -29,8 +29,11 @@ function SwitchItem({ plug }: { plug: SmartPlug }) {
});
const isOn = status?.state === 'ON';
const isReachable = status?.reachable ?? false;
// For MQTT plugs, consider reachable if we have power data
const hasMqttData = plug.plug_type === 'mqtt' && (status?.energy?.power !== null && status?.energy?.power !== undefined);
const isReachable = (status?.reachable ?? false) || hasMqttData;
const isPending = controlMutation.isPending;
const isMqtt = plug.plug_type === 'mqtt';
const handleConfirm = () => {
if (confirmAction) {
@@ -43,14 +46,38 @@ function SwitchItem({ plug }: { plug: SmartPlug }) {
<>
<div className="flex items-center justify-between py-2 px-3 hover:bg-bambu-dark-tertiary rounded-lg transition-colors">
<div className="flex items-center gap-2">
<div className={`p-1.5 rounded ${isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20'}`}>
<Plug className={`w-4 h-4 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
<div className={`p-1.5 rounded ${
isMqtt
? (isReachable ? 'bg-teal-500/20' : 'bg-red-500/20')
: (isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20')
}`}>
{isMqtt ? (
<Radio className={`w-4 h-4 ${isReachable ? 'text-teal-400' : 'text-red-400'}`} />
) : (
<Plug className={`w-4 h-4 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
)}
</div>
<div>
<p className="text-sm text-white font-medium">{plug.name}</p>
<div className="flex items-center gap-1 text-xs">
{statusLoading ? (
<Loader2 className="w-3 h-3 text-bambu-gray animate-spin" />
) : isMqtt ? (
/* MQTT plugs show power and monitor-only indicator */
isReachable ? (
<>
<Zap className="w-3 h-3 text-teal-400" />
<span className="text-teal-400">{Math.round(status?.energy?.power ?? 0)}W</span>
<span className="text-bambu-gray mx-1">|</span>
<Eye className="w-3 h-3 text-bambu-gray" />
<span className="text-bambu-gray">Monitor</span>
</>
) : (
<>
<WifiOff className="w-3 h-3 text-status-error" />
<span className="text-status-error">Waiting</span>
</>
)
) : isReachable ? (
<>
<Wifi className="w-3 h-3 text-status-ok" />
@@ -73,32 +100,35 @@ function SwitchItem({ plug }: { plug: SmartPlug }) {
</div>
</div>
<div className="flex gap-1">
<button
onClick={() => setConfirmAction('on')}
disabled={!isReachable || isPending}
className={`p-1.5 rounded transition-colors ${
isOn
? 'bg-bambu-green text-white'
: 'bg-bambu-dark text-bambu-gray hover:text-white'
} disabled:opacity-50 disabled:cursor-not-allowed`}
title="Turn On"
>
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
</button>
<button
onClick={() => setConfirmAction('off')}
disabled={!isReachable || isPending}
className={`p-1.5 rounded transition-colors ${
!isOn && isReachable
? 'bg-bambu-dark-tertiary text-white'
: 'bg-bambu-dark text-bambu-gray hover:text-white'
} disabled:opacity-50 disabled:cursor-not-allowed`}
title="Turn Off"
>
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
</button>
</div>
{/* Hide on/off buttons for MQTT plugs (monitor-only) */}
{!isMqtt && (
<div className="flex gap-1">
<button
onClick={() => setConfirmAction('on')}
disabled={!isReachable || isPending}
className={`p-1.5 rounded transition-colors ${
isOn
? 'bg-bambu-green text-white'
: 'bg-bambu-dark text-bambu-gray hover:text-white'
} disabled:opacity-50 disabled:cursor-not-allowed`}
title="Turn On"
>
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
</button>
<button
onClick={() => setConfirmAction('off')}
disabled={!isReachable || isPending}
className={`p-1.5 rounded transition-colors ${
!isOn && isReachable
? 'bg-bambu-dark-tertiary text-white'
: 'bg-bambu-dark text-bambu-gray hover:text-white'
} disabled:opacity-50 disabled:cursor-not-allowed`}
title="Turn Off"
>
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
</button>
</div>
)}
</div>
{confirmAction && (
@@ -0,0 +1,294 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { X, Tag, Pencil, Trash2, Loader2, Search, Check, AlertTriangle } from 'lucide-react';
import { api } from '../api/client';
import type { TagInfo } from '../api/client';
import { Card, CardContent } from './Card';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
interface TagManagementModalProps {
onClose: () => void;
}
export function TagManagementModal({ onClose }: TagManagementModalProps) {
const queryClient = useQueryClient();
const { showToast } = useToast();
const [search, setSearch] = useState('');
const [editingTag, setEditingTag] = useState<string | null>(null);
const [editValue, setEditValue] = useState('');
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [sortBy, setSortBy] = useState<'count' | 'name'>('count');
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (editingTag) {
setEditingTag(null);
} else if (deleteConfirm) {
setDeleteConfirm(null);
} else {
onClose();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose, editingTag, deleteConfirm]);
const { data: tags, isLoading } = useQuery({
queryKey: ['tags'],
queryFn: api.getTags,
});
const renameMutation = useMutation({
mutationFn: ({ oldName, newName }: { oldName: string; newName: string }) =>
api.renameTag(oldName, newName),
onSuccess: (data, { oldName, newName }) => {
queryClient.invalidateQueries({ queryKey: ['tags'] });
queryClient.invalidateQueries({ queryKey: ['archives'] });
showToast(`Renamed "${oldName}" to "${newName}" in ${data.affected} archive${data.affected !== 1 ? 's' : ''}`);
setEditingTag(null);
},
onError: (error: Error) => {
showToast(error.message || 'Failed to rename tag', 'error');
},
});
const deleteMutation = useMutation({
mutationFn: (name: string) => api.deleteTag(name),
onSuccess: (data, name) => {
queryClient.invalidateQueries({ queryKey: ['tags'] });
queryClient.invalidateQueries({ queryKey: ['archives'] });
showToast(`Deleted "${name}" from ${data.affected} archive${data.affected !== 1 ? 's' : ''}`);
setDeleteConfirm(null);
},
onError: (error: Error) => {
showToast(error.message || 'Failed to delete tag', 'error');
},
});
const startEdit = (tag: TagInfo) => {
setEditingTag(tag.name);
setEditValue(tag.name);
setDeleteConfirm(null);
};
const cancelEdit = () => {
setEditingTag(null);
setEditValue('');
};
const submitEdit = () => {
if (!editingTag || !editValue.trim()) return;
const newName = editValue.trim();
if (newName === editingTag) {
cancelEdit();
return;
}
renameMutation.mutate({ oldName: editingTag, newName });
};
const handleEditKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
submitEdit();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelEdit();
}
};
const confirmDelete = (name: string) => {
setDeleteConfirm(name);
setEditingTag(null);
};
const executeDelete = () => {
if (deleteConfirm) {
deleteMutation.mutate(deleteConfirm);
}
};
// Filter and sort tags
const filteredTags = tags
?.filter(t => t.name.toLowerCase().includes(search.toLowerCase()))
.sort((a, b) => {
if (sortBy === 'count') {
return b.count - a.count || a.name.localeCompare(b.name);
}
return a.name.localeCompare(b.name);
});
const totalUsage = tags?.reduce((sum, t) => sum + t.count, 0) || 0;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="w-full max-w-lg max-h-[80vh] flex flex-col">
<CardContent className="p-0 flex flex-col min-h-0">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
<div className="flex items-center gap-2">
<Tag className="w-5 h-5 text-bambu-green" />
<h2 className="text-xl font-semibold text-white">Manage Tags</h2>
</div>
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Search and sort */}
<div className="p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
<input
type="text"
placeholder="Search tags..."
className="w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<select
className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
value={sortBy}
onChange={(e) => setSortBy(e.target.value as 'count' | 'name')}
>
<option value="count">Sort by Count</option>
<option value="name">Sort by Name</option>
</select>
</div>
{tags && (
<p className="text-xs text-bambu-gray mt-2">
{tags.length} tag{tags.length !== 1 ? 's' : ''} across {totalUsage} usage{totalUsage !== 1 ? 's' : ''}
</p>
)}
</div>
{/* Tags list */}
<div className="flex-1 overflow-y-auto min-h-0 p-4">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-bambu-gray" />
</div>
) : !filteredTags?.length ? (
<div className="text-center py-8 text-bambu-gray">
{search ? 'No tags match your search' : 'No tags found'}
</div>
) : (
<div className="space-y-2">
{filteredTags.map((tag) => (
<div
key={tag.name}
className="flex items-center gap-2 p-2 rounded-lg bg-bambu-dark hover:bg-bambu-dark-tertiary transition-colors group"
>
{editingTag === tag.name ? (
// Edit mode
<div className="flex-1 flex items-center gap-2">
<input
type="text"
className="flex-1 px-2 py-1 bg-bambu-dark-tertiary border border-bambu-green rounded text-white text-sm focus:outline-none"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleEditKeyDown}
autoFocus
/>
<Button
size="sm"
variant="primary"
onClick={submitEdit}
disabled={!editValue.trim() || renameMutation.isPending}
className="p-1.5"
>
{renameMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Check className="w-4 h-4" />
)}
</Button>
<Button
size="sm"
variant="ghost"
onClick={cancelEdit}
className="p-1.5"
>
<X className="w-4 h-4" />
</Button>
</div>
) : deleteConfirm === tag.name ? (
// Delete confirmation
<div className="flex-1 flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-yellow-400 flex-shrink-0" />
<span className="text-sm text-bambu-gray-light flex-1">
Delete "{tag.name}" from {tag.count} archive{tag.count !== 1 ? 's' : ''}?
</span>
<Button
size="sm"
variant="danger"
onClick={executeDelete}
disabled={deleteMutation.isPending}
className="p-1.5"
>
{deleteMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setDeleteConfirm(null)}
className="p-1.5"
>
<X className="w-4 h-4" />
</Button>
</div>
) : (
// Normal display
<>
<Tag className="w-4 h-4 text-bambu-gray flex-shrink-0" />
<span className="text-white flex-1 truncate">{tag.name}</span>
<span className="px-2 py-0.5 rounded-full bg-bambu-dark-tertiary text-bambu-gray text-xs">
{tag.count}
</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => startEdit(tag)}
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
title="Rename tag"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => confirmDelete(tag.name)}
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors"
title="Delete tag"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</>
)}
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="flex gap-3 p-4 border-t border-bambu-dark-tertiary flex-shrink-0">
<Button variant="secondary" onClick={onClose} className="flex-1">
Close
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
+6 -27
View File
@@ -1,5 +1,5 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Upload, X, File, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
import { api } from '../api/client';
import type { BulkUploadResult } from '../api/client';
@@ -27,7 +27,6 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
initialFiles?.filter(f => f.name.endsWith('.3mf')).map(file => ({ file, status: 'pending' as const })) || []
);
const [isDragging, setIsDragging] = useState(false);
const [selectedPrinter, setSelectedPrinter] = useState<number | undefined>();
const [uploadResult, setUploadResult] = useState<BulkUploadResult | null>(null);
// Close on Escape key
@@ -39,14 +38,9 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const { data: printers } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
});
const uploadMutation = useMutation({
mutationFn: (filesToUpload: File[]) =>
api.uploadArchivesBulk(filesToUpload, selectedPrinter),
api.uploadArchivesBulk(filesToUpload),
onSuccess: (result) => {
setUploadResult(result);
queryClient.invalidateQueries({ queryKey: ['archives'] });
@@ -200,26 +194,11 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
</div>
</div>
{/* Optional Printer Selection */}
{/* Info about printer model extraction */}
<div className="px-4 pb-4">
<label className="block text-sm text-bambu-gray mb-2">
Associate with printer (optional)
</label>
<select
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
value={selectedPrinter || ''}
onChange={(e) =>
setSelectedPrinter(e.target.value ? Number(e.target.value) : undefined)
}
disabled={isUploading}
>
<option value="">No printer</option>
{printers?.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<p className="text-xs text-bambu-gray">
The printer model will be automatically extracted from the 3MF file metadata.
</p>
</div>
{/* File List */}
+36 -4
View File
@@ -43,6 +43,7 @@ import {
FolderKanban,
ChevronLeft,
ChevronRight,
Settings,
} from 'lucide-react';
import { api } from '../api/client';
import { openInSlicer } from '../utils/slicer';
@@ -66,6 +67,7 @@ import { ProjectPageModal } from '../components/ProjectPageModal';
import { TimelapseViewer } from '../components/TimelapseViewer';
import { CompareArchivesModal } from '../components/CompareArchivesModal';
import { PendingUploadsPanel } from '../components/PendingUploadsPanel';
import { TagManagementModal } from '../components/TagManagementModal';
import { useToast } from '../contexts/ToastContext';
function formatFileSize(bytes: number): string {
@@ -797,6 +799,12 @@ function ArchiveCard({
{archive.object_count} object{archive.object_count > 1 ? 's' : ''}
</div>
)}
{archive.sliced_for_model && (
<div className="flex items-center gap-1.5 text-bambu-gray" title={`Sliced for ${archive.sliced_for_model}`}>
<Printer className="w-3 h-3" />
{archive.sliced_for_model}
</div>
)}
{archive.filament_type && (
<div className="flex items-center gap-1.5 col-span-2">
<span className="text-bambu-gray text-xs">{archive.filament_type}</span>
@@ -1612,9 +1620,20 @@ function ArchiveListRow({
</Link>
)}
</div>
{archive.filament_type && (
{(archive.filament_type || archive.sliced_for_model) && (
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-xs text-bambu-gray">{archive.filament_type}</span>
{archive.sliced_for_model && (
<span className="text-xs text-bambu-gray flex items-center gap-1" title={`Sliced for ${archive.sliced_for_model}`}>
<Printer className="w-2.5 h-2.5" />
{archive.sliced_for_model}
</span>
)}
{archive.sliced_for_model && archive.filament_type && (
<span className="text-bambu-gray/50">·</span>
)}
{archive.filament_type && (
<span className="text-xs text-bambu-gray">{archive.filament_type}</span>
)}
{archive.filament_color && (
<div className="flex items-center gap-0.5 flex-wrap">
{archive.filament_color.split(',').map((color, i) => (
@@ -1989,6 +2008,7 @@ export function ArchivesPage() {
const [showExportMenu, setShowExportMenu] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [showCompareModal, setShowCompareModal] = useState(false);
const [showTagManagement, setShowTagManagement] = useState(false);
const [highlightedArchiveId, setHighlightedArchiveId] = useState<number | null>(null);
// Clear highlight after 5 seconds and scroll to highlighted element
@@ -2620,6 +2640,13 @@ export function ArchivesPage() {
</option>
))}
</select>
<button
onClick={() => setShowTagManagement(true)}
className="p-2 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-green transition-colors"
title="Manage Tags"
>
<Settings className="w-4 h-4" />
</button>
</div>
)}
<div className="flex items-center gap-2 flex-shrink-0">
@@ -2755,7 +2782,7 @@ export function ArchivesPage() {
<ArchiveCard
key={archive.id}
archive={archive}
printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : 'No Printer'}
printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : (archive.sliced_for_model ? `Sliced for ${archive.sliced_for_model}` : 'No Printer')}
isSelected={selectedIds.has(archive.id)}
onSelect={toggleSelect}
selectionMode={selectionMode}
@@ -2782,7 +2809,7 @@ export function ArchivesPage() {
<ArchiveListRow
key={archive.id}
archive={archive}
printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : 'No Printer'}
printerName={archive.printer_id ? printerMap.get(archive.printer_id) || 'Unknown' : (archive.sliced_for_model ? `Sliced for ${archive.sliced_for_model}` : 'No Printer')}
isSelected={selectedIds.has(archive.id)}
onSelect={toggleSelect}
selectionMode={selectionMode}
@@ -2848,6 +2875,11 @@ export function ArchivesPage() {
}}
/>
)}
{/* Tag Management Modal */}
{showTagManagement && (
<TagManagementModal onClose={() => setShowTagManagement(false)} />
)}
</div>
);
}
+243 -79
View File
@@ -35,6 +35,7 @@ import {
Printer,
Pencil,
Play,
Image,
} from 'lucide-react';
import { api } from '../api/client';
import type {
@@ -49,6 +50,7 @@ import { Button } from '../components/Button';
import { ConfirmModal } from '../components/ConfirmModal';
import { PrintModal } from '../components/PrintModal';
import { useToast } from '../contexts/ToastContext';
import { useIsMobile } from '../hooks/useIsMobile';
type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
type SortDirection = 'asc' | 'desc';
@@ -427,6 +429,7 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
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>) => {
@@ -466,6 +469,7 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
};
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 handleUpload = async () => {
if (files.length === 0) return;
@@ -482,7 +486,7 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
try {
if (files[i].isZip) {
// Extract ZIP file
const result = await api.extractZipFile(files[i].file, folderId, preserveZipStructure, createFolderFromZip);
const result = await api.extractZipFile(files[i].file, folderId, preserveZipStructure, createFolderFromZip, generateStlThumbnails);
setFiles((prev) =>
prev.map((f, idx) =>
idx === i
@@ -497,7 +501,7 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
);
} else {
// Regular file upload
await api.uploadLibraryFile(files[i].file, folderId);
await api.uploadLibraryFile(files[i].file, folderId, generateStlThumbnails);
setFiles((prev) =>
prev.map((f, idx) => (idx === i ? { ...f, status: 'success' } : f))
);
@@ -596,6 +600,32 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
</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">STL thumbnail generation</p>
<p className="text-xs text-bambu-green/70 mt-1">
{hasZipFiles && !hasStlFiles
? 'ZIP files may contain STL files. Thumbnails can be generated during extraction.'
: 'Thumbnails can be generated for STL files. Large models may take longer to process.'}
</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">Generate thumbnails for STL files</span>
</label>
</div>
</div>
</div>
)}
{/* File List */}
{files.length > 0 && (
<div className="max-h-48 overflow-y-auto space-y-2">
@@ -826,15 +856,18 @@ function isSlicedFilename(filename: string): boolean {
interface FileCardProps {
file: LibraryFileListItem;
isSelected: boolean;
isMobile: boolean;
onSelect: (id: number) => void;
onDelete: (id: number) => void;
onDownload: (id: number) => void;
onAddToQueue?: (id: number) => void;
onPrint?: (file: LibraryFileListItem) => void;
onRename?: (file: LibraryFileListItem) => void;
onGenerateThumbnail?: (file: LibraryFileListItem) => void;
thumbnailVersion?: number;
}
function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename }: FileCardProps) {
function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename, onGenerateThumbnail, thumbnailVersion }: FileCardProps) {
const [showActions, setShowActions] = useState(false);
return (
@@ -850,7 +883,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
<div className="aspect-square bg-bambu-dark flex items-center justify-center overflow-hidden">
{file.thumbnail_path ? (
<img
src={api.getLibraryFileThumbnailUrl(file.id)}
src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersion ? `?v=${thumbnailVersion}` : ''}`}
alt={file.filename}
className="w-full h-full object-cover"
/>
@@ -890,7 +923,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
</div>
{/* Actions - always visible on mobile, hover on desktop */}
<div className="absolute bottom-2 right-2 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity" onClick={(e) => e.stopPropagation()}>
<div className={`absolute bottom-2 right-2 transition-opacity ${isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`} onClick={(e) => e.stopPropagation()}>
<button
onClick={() => setShowActions(!showActions)}
className="p-1.5 rounded bg-bambu-dark-secondary/90 hover:bg-bambu-dark-tertiary"
@@ -935,6 +968,15 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
Rename
</button>
)}
{onGenerateThumbnail && file.file_type === 'stl' && (
<button
className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
onClick={() => { onGenerateThumbnail(file); setShowActions(false); }}
>
<Image className="w-3.5 h-3.5" />
Generate Thumbnail
</button>
)}
<button
className="w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-bambu-dark flex items-center gap-2"
onClick={() => { onDelete(file.id); setShowActions(false); }}
@@ -951,7 +993,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
<div className={`absolute top-2 left-2 w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${
isSelected
? 'bg-bambu-green border-bambu-green'
: 'border-white/30 bg-black/30 opacity-100 md:opacity-0 md:group-hover:opacity-100'
: `border-white/30 bg-black/30 ${isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`
}`}>
{isSelected && <div className="w-2 h-2 bg-white rounded-sm" />}
</div>
@@ -979,6 +1021,7 @@ export function FileManagerPage() {
const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
const [printMultiFile, setPrintMultiFile] = useState<LibraryFileListItem | null>(null);
const [renameItem, setRenameItem] = useState<{ type: 'file' | 'folder'; id: number; name: string } | null>(null);
const [thumbnailVersions, setThumbnailVersions] = useState<Record<number, number>>({});
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
});
@@ -1032,11 +1075,20 @@ export function FileManagerPage() {
};
}, [isResizing, sidebarWidth]);
// Filter and sort state
// Filter and sort state (persist sort preferences to localStorage)
const [searchQuery, setSearchQuery] = useState('');
const [filterType, setFilterType] = useState<string>('all');
const [sortField, setSortField] = useState<SortField>('date');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const [sortField, setSortField] = useState<SortField>(() => {
const saved = localStorage.getItem('library-sort-field');
return (saved as SortField) || 'name';
});
const [sortDirection, setSortDirection] = useState<SortDirection>(() => {
const saved = localStorage.getItem('library-sort-direction');
return (saved as SortDirection) || 'asc';
});
// Mobile detection for touch-friendly UI
const isMobile = useIsMobile();
// Update selectedFolderId when URL parameter changes (e.g., navigating from Project or Archive page)
useEffect(() => {
@@ -1272,6 +1324,52 @@ export function FileManagerPage() {
},
});
const batchThumbnailMutation = useMutation({
mutationFn: () => api.batchGenerateStlThumbnails({ all_missing: true }),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['library-files'] });
// Update thumbnail versions for cache busting
if (result.succeeded > 0) {
const now = Date.now();
const newVersions: Record<number, number> = {};
result.results.forEach((r) => {
if (r.success) {
newVersions[r.file_id] = now;
}
});
setThumbnailVersions((prev) => ({ ...prev, ...newVersions }));
}
if (result.succeeded > 0 && result.failed === 0) {
showToast(`Generated ${result.succeeded} thumbnail${result.succeeded > 1 ? 's' : ''}`, 'success');
} else if (result.succeeded > 0 && result.failed > 0) {
showToast(`Generated ${result.succeeded} thumbnail${result.succeeded > 1 ? 's' : ''}, ${result.failed} failed`, 'success');
} else if (result.processed === 0) {
showToast('No STL files missing thumbnails', 'info');
} else {
showToast(`Failed to generate thumbnails: ${result.results[0]?.error || 'Unknown error'}`, 'error');
}
},
onError: (error: Error) => showToast(error.message, 'error'),
});
const singleThumbnailMutation = useMutation({
mutationFn: (fileId: number) => api.batchGenerateStlThumbnails({ file_ids: [fileId] }),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['library-files'] });
// Update thumbnail version for cache busting
if (result.succeeded > 0) {
const fileId = result.results[0]?.file_id;
if (fileId) {
setThumbnailVersions((prev) => ({ ...prev, [fileId]: Date.now() }));
}
showToast('Thumbnail generated', 'success');
} else {
showToast(`Failed to generate thumbnail: ${result.results[0]?.error || 'Unknown error'}`, 'error');
}
},
onError: (error: Error) => showToast(error.message, 'error'),
});
// Helper to check if a file is sliced (printable)
const isSlicedFile = useCallback((filename: string) => {
const lower = filename.toLowerCase();
@@ -1333,7 +1431,7 @@ export function FileManagerPage() {
const isLoading = foldersLoading || filesLoading;
return (
<div className="p-4 md:p-8 h-[calc(100vh-64px)] flex flex-col">
<div className="p-4 md:p-8 min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] flex flex-col">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<div>
@@ -1369,6 +1467,19 @@ export function FileManagerPage() {
<List className="w-4 h-4" />
</button>
</div>
<Button
variant="secondary"
onClick={() => batchThumbnailMutation.mutate()}
disabled={batchThumbnailMutation.isPending}
title="Generate thumbnails for STL files missing them"
>
{batchThumbnailMutation.isPending ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Image className="w-4 h-4 mr-2" />
)}
Generate Thumbnails
</Button>
<Button variant="secondary" onClick={() => setShowNewFolderModal(true)}>
<FolderPlus className="w-4 h-4 mr-2" />
New Folder
@@ -1396,7 +1507,7 @@ export function FileManagerPage() {
{/* Stats bar */}
{stats && (
<div className="flex items-center gap-6 mb-6 p-3 bg-bambu-card rounded-lg border border-bambu-dark-tertiary">
<div className="flex flex-wrap items-center gap-3 sm:gap-6 mb-6 p-3 bg-bambu-card rounded-lg border border-bambu-dark-tertiary">
<div className="flex items-center gap-2 text-sm">
<File className="w-4 h-4 text-bambu-green" />
<span className="text-bambu-gray">Files:</span>
@@ -1412,7 +1523,7 @@ export function FileManagerPage() {
<span className="text-bambu-gray">Size:</span>
<span className="text-white font-medium">{formatFileSize(stats.total_size_bytes)}</span>
</div>
<div className="flex items-center gap-2 text-sm ml-auto">
<div className="flex items-center gap-2 text-sm sm:ml-auto">
<span className="text-bambu-gray">Free:</span>
<span className={`font-medium ${isDiskSpaceLow ? 'text-amber-500' : 'text-white'}`}>
{formatFileSize(stats.disk_free_bytes)}
@@ -1422,11 +1533,40 @@ export function FileManagerPage() {
)}
{/* Main content */}
<div className="flex-1 flex gap-6 min-h-0">
{/* Folder sidebar - resizable */}
<div className="flex-1 flex flex-col lg:flex-row gap-4 lg:gap-6 min-h-0">
{/* Mobile folder selector */}
<div className="lg:hidden">
<select
value={selectedFolderId ?? ''}
onChange={(e) => setSelectedFolderId(e.target.value ? parseInt(e.target.value, 10) : null)}
className="w-full bg-bambu-card border border-bambu-dark-tertiary rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-bambu-green"
>
<option value="">📁 All Files</option>
{folders && (() => {
// Flatten folder tree for mobile selector
const flattenFolders = (items: LibraryFolderTree[], depth = 0): { id: number; name: string; fileCount: number; depth: number }[] => {
const result: { id: number; name: string; fileCount: number; depth: number }[] = [];
for (const item of items) {
result.push({ id: item.id, name: item.name, fileCount: item.file_count, depth });
if (item.children.length > 0) {
result.push(...flattenFolders(item.children, depth + 1));
}
}
return result;
};
return flattenFolders(folders).map((folder) => (
<option key={folder.id} value={folder.id}>
{'│ '.repeat(folder.depth)}📂 {folder.name} {folder.fileCount > 0 ? `(${folder.fileCount})` : ''}
</option>
));
})()}
</select>
</div>
{/* Folder sidebar - resizable, hidden on mobile */}
<div
ref={sidebarRef}
className="flex-shrink-0 bg-bambu-card rounded-lg border border-bambu-dark-tertiary overflow-hidden flex flex-col relative"
className="hidden lg:flex flex-shrink-0 bg-bambu-card rounded-lg border border-bambu-dark-tertiary overflow-hidden flex-col relative"
style={{ width: `${sidebarWidth}px` }}
>
{/* Resize handle - drag to resize, double-click to reset */}
@@ -1500,12 +1640,12 @@ export function FileManagerPage() {
</div>
{/* Files area */}
<div className="flex-1 flex flex-col min-w-0">
{/* Search, Filter, Sort toolbar */}
<div className="flex-1 flex flex-col min-w-0 min-h-0">
{/* Search, Filter, Sort toolbar - sticky on mobile for easier access */}
{files && files.length > 0 && (
<div className="flex items-center gap-3 mb-4 p-3 bg-bambu-card rounded-lg border border-bambu-dark-tertiary">
<div className="flex flex-wrap items-center gap-2 sm:gap-3 mb-4 p-2 sm:p-3 bg-bambu-card rounded-lg border border-bambu-dark-tertiary sticky top-0 z-10 lg:static">
{/* Search */}
<div className="relative flex-1 max-w-xs">
<div className="relative w-full sm:w-auto sm:flex-1 sm:max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
<input
type="text"
@@ -1518,7 +1658,7 @@ export function FileManagerPage() {
{/* Type filter */}
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-bambu-gray" />
<Filter className="w-4 h-4 text-bambu-gray hidden sm:block" />
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
@@ -1537,17 +1677,25 @@ export function FileManagerPage() {
<div className="flex items-center gap-2">
<select
value={sortField}
onChange={(e) => setSortField(e.target.value as SortField)}
onChange={(e) => {
const newField = e.target.value as SortField;
setSortField(newField);
localStorage.setItem('library-sort-field', newField);
}}
className="bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-bambu-green"
>
<option value="date">Date</option>
<option value="name">Name</option>
<option value="date">Date</option>
<option value="size">Size</option>
<option value="type">Type</option>
<option value="prints">Prints</option>
</select>
<button
onClick={() => setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'))}
onClick={() => setSortDirection((d) => {
const newDir = d === 'asc' ? 'desc' : 'asc';
localStorage.setItem('library-sort-direction', newDir);
return newDir;
})}
className="p-1.5 rounded bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-green transition-colors"
title={sortDirection === 'asc' ? 'Ascending' : 'Descending'}
>
@@ -1561,16 +1709,16 @@ export function FileManagerPage() {
{/* Results count */}
{(searchQuery || filterType !== 'all') && (
<span className="text-sm text-bambu-gray">
<span className="text-sm text-bambu-gray hidden sm:inline">
{filteredAndSortedFiles.length} of {files.length} files
</span>
)}
</div>
)}
{/* Selection toolbar */}
{/* Selection toolbar - sticky on mobile below search bar */}
{filteredAndSortedFiles.length > 0 && (
<div className="flex items-center gap-2 mb-4 p-2 bg-bambu-card rounded-lg border border-bambu-dark-tertiary">
<div className="flex flex-wrap items-center gap-2 mb-4 p-2 bg-bambu-card rounded-lg border border-bambu-dark-tertiary sticky top-[52px] z-10 lg:static">
{/* Select all / Deselect all */}
{selectedFiles.length === filteredAndSortedFiles.length && selectedFiles.length > 0 ? (
<Button
@@ -1578,8 +1726,8 @@ export function FileManagerPage() {
size="sm"
onClick={handleDeselectAll}
>
<Square className="w-4 h-4 mr-1" />
Deselect All
<Square className="w-4 h-4 sm:mr-1" />
<span className="hidden sm:inline">Deselect All</span>
</Button>
) : (
<Button
@@ -1587,8 +1735,8 @@ export function FileManagerPage() {
size="sm"
onClick={handleSelectAll}
>
<CheckSquare className="w-4 h-4 mr-1" />
Select All
<CheckSquare className="w-4 h-4 sm:mr-1" />
<span className="hidden sm:inline">Select All</span>
</Button>
)}
@@ -1597,57 +1745,60 @@ export function FileManagerPage() {
<span className="text-sm text-bambu-gray ml-2">
{selectedFiles.length} selected
</span>
<div className="flex-1" />
{selectedSlicedFiles.length === 1 && (
<div className="hidden sm:block flex-1" />
<div className="w-full sm:w-auto flex flex-wrap items-center gap-2 mt-2 sm:mt-0">
{selectedSlicedFiles.length === 1 && (
<Button
variant="primary"
size="sm"
onClick={() => setPrintMultiFile(selectedSlicedFiles[0])}
>
<Play className="w-4 h-4 sm:mr-1" />
<span className="hidden sm:inline">Print</span>
</Button>
)}
{selectedSlicedFiles.length > 0 && (
<Button
variant={selectedSlicedFiles.length === 1 ? 'secondary' : 'primary'}
size="sm"
onClick={() => addToQueueMutation.mutate(selectedSlicedFiles.map(f => f.id))}
disabled={addToQueueMutation.isPending}
>
<Clock className="w-4 h-4 sm:mr-1" />
<span className="hidden sm:inline">{addToQueueMutation.isPending ? 'Adding...' : `Add to Queue${selectedSlicedFiles.length < selectedFiles.length ? ` (${selectedSlicedFiles.length})` : ''}`}</span>
</Button>
)}
<Button
variant="primary"
variant="secondary"
size="sm"
onClick={() => setPrintMultiFile(selectedSlicedFiles[0])}
onClick={() => setShowMoveModal(true)}
>
<Play className="w-4 h-4 mr-1" />
Print
<MoveRight className="w-4 h-4 sm:mr-1" />
<span className="hidden sm:inline">Move</span>
</Button>
)}
{selectedSlicedFiles.length > 0 && (
<Button
variant={selectedSlicedFiles.length === 1 ? 'secondary' : 'primary'}
variant="danger"
size="sm"
onClick={() => addToQueueMutation.mutate(selectedSlicedFiles.map(f => f.id))}
disabled={addToQueueMutation.isPending}
onClick={() => {
if (selectedFiles.length === 1) {
setDeleteConfirm({ type: 'file', id: selectedFiles[0] });
} else {
setDeleteConfirm({ type: 'bulk', id: 0, count: selectedFiles.length });
}
}}
>
<Clock className="w-4 h-4 mr-1" />
{addToQueueMutation.isPending ? 'Adding...' : `Add to Queue${selectedSlicedFiles.length < selectedFiles.length ? ` (${selectedSlicedFiles.length})` : ''}`}
<Trash2 className="w-4 h-4 sm:mr-1" />
<span className="hidden sm:inline">Delete</span>
</Button>
)}
<Button
variant="secondary"
size="sm"
onClick={() => setShowMoveModal(true)}
>
<MoveRight className="w-4 h-4 mr-1" />
Move
</Button>
<Button
variant="danger"
size="sm"
onClick={() => {
if (selectedFiles.length === 1) {
setDeleteConfirm({ type: 'file', id: selectedFiles[0] });
} else {
setDeleteConfirm({ type: 'bulk', id: 0, count: selectedFiles.length });
}
}}
>
<Trash2 className="w-4 h-4 mr-1" />
Delete
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleDeselectAll}
>
Clear
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleDeselectAll}
>
<X className="w-4 h-4 sm:mr-1" />
<span className="hidden sm:inline">Clear</span>
</Button>
</div>
</>
)}
</div>
@@ -1693,28 +1844,31 @@ export function FileManagerPage() {
</Button>
</div>
) : viewMode === 'grid' ? (
<div className="flex-1 overflow-y-auto">
<div className="flex-1 lg:overflow-y-auto">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
{filteredAndSortedFiles.map((file) => (
<FileCard
key={file.id}
file={file}
isSelected={selectedFiles.includes(file.id)}
isMobile={isMobile}
onSelect={handleFileSelect}
onDelete={(id) => setDeleteConfirm({ type: 'file', id })}
onDownload={handleDownload}
onAddToQueue={(id) => addToQueueMutation.mutate([id])}
onPrint={setPrintFile}
onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
thumbnailVersion={thumbnailVersions[file.id]}
/>
))}
</div>
</div>
) : (
<div className="flex-1 overflow-y-auto">
<div className="flex-1 lg:overflow-y-auto">
<div className="bg-bambu-card rounded-lg border border-bambu-dark-tertiary overflow-hidden">
{/* List header */}
<div className="grid grid-cols-[auto_1fr_100px_100px_100px_80px] gap-4 px-4 py-2 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary text-xs text-bambu-gray font-medium">
{/* List header - hidden on mobile, show simplified on small screens */}
<div className="hidden sm:grid grid-cols-[auto_1fr_100px_100px_100px_80px] gap-4 px-4 py-2 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary text-xs text-bambu-gray font-medium">
<div className="w-6" />
<div>Name</div>
<div>Type</div>
@@ -1745,7 +1899,7 @@ export function FileManagerPage() {
<div className="w-10 h-10 rounded bg-bambu-dark flex-shrink-0 overflow-hidden">
{file.thumbnail_path ? (
<img
src={api.getLibraryFileThumbnailUrl(file.id)}
src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersions[file.id] ? `?v=${thumbnailVersions[file.id]}` : ''}`}
alt=""
className="w-full h-full object-cover"
/>
@@ -1760,7 +1914,7 @@ export function FileManagerPage() {
<div className="absolute left-0 top-full mt-2 z-50 hidden group-hover/thumb:block">
<div className="w-48 h-48 rounded-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary shadow-xl overflow-hidden">
<img
src={api.getLibraryFileThumbnailUrl(file.id)}
src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersions[file.id] ? `?v=${thumbnailVersions[file.id]}` : ''}`}
alt={file.filename}
className="w-full h-full object-contain"
/>
@@ -1822,6 +1976,16 @@ export function FileManagerPage() {
>
<Pencil className="w-4 h-4" />
</button>
{file.file_type === 'stl' && (
<button
onClick={() => singleThumbnailMutation.mutate(file.id)}
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green transition-colors"
title="Generate Thumbnail"
disabled={singleThumbnailMutation.isPending}
>
<Image className="w-4 h-4" />
</button>
)}
<button
onClick={() => setDeleteConfirm({ type: 'file', id: file.id })}
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors"
+41 -4
View File
@@ -1093,6 +1093,12 @@ function PrinterCard({
queryFn: () => api.getSmartPlugByPrinter(printer.id),
});
// Fetch script plugs for this printer (for multi-device control)
const { data: scriptPlugs } = useQuery({
queryKey: ['scriptPlugsByPrinter', printer.id],
queryFn: () => api.getScriptPlugsByPrinter(printer.id),
});
// Fetch smart plug status if plug exists (faster refresh for energy monitoring)
const { data: plugStatus } = useQuery({
queryKey: ['smartPlugStatus', smartPlug?.id],
@@ -1155,6 +1161,15 @@ function PrinterCard({
},
});
// Run script mutation
const runScriptMutation = useMutation({
mutationFn: (scriptId: number) => api.controlSmartPlug(scriptId, 'on'),
onSuccess: () => {
showToast('Script triggered');
},
onError: (error: Error) => showToast(error.message || 'Failed to run script', 'error'),
});
// Print control mutations
const stopPrintMutation = useMutation({
mutationFn: () => api.stopPrint(printer.id),
@@ -2683,17 +2698,39 @@ function PrinterCard({
</button>
</div>
</div>
{/* Script buttons row */}
{scriptPlugs && scriptPlugs.length > 0 && (
<div className="flex items-center gap-2 mt-2 pt-2 border-t border-bambu-dark-tertiary/50">
<Play className="w-3.5 h-3.5 text-blue-400 flex-shrink-0" />
<span className="text-xs text-bambu-gray">Scripts:</span>
<div className="flex flex-wrap gap-1">
{scriptPlugs.map(script => (
<button
key={script.id}
onClick={() => runScriptMutation.mutate(script.id)}
disabled={runScriptMutation.isPending}
title={`Run ${script.ha_entity_id}`}
className="px-2 py-0.5 text-xs bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 rounded transition-colors flex items-center gap-1"
>
<Play className="w-2.5 h-2.5" />
{script.name}
</button>
))}
</div>
</div>
)}
</div>
)}
{/* Connection Info & Actions - hidden in compact mode */}
{viewMode === 'expanded' && (
<div className="mt-4 pt-4 border-t border-bambu-dark-tertiary flex items-center justify-between">
<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">
<div className="flex items-center gap-2 flex-wrap">
{/* Chamber Light Toggle */}
<Button
variant="secondary"
@@ -4487,12 +4524,12 @@ export function PrintersPage() {
return (
<div className="p-4 md:p-8">
<div className="flex items-center justify-between mb-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div>
<h1 className="text-2xl font-bold text-white">Printers</h1>
<StatusSummaryBar printers={printers} />
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 sm:gap-3 flex-wrap">
{/* Sort dropdown */}
<div className="flex items-center gap-1">
<select
+26 -4
View File
@@ -77,7 +77,17 @@ function formatRelativeTime(dateString: string | null, timeFormat: TimeFormat =
return formatDateTime(dateString, timeFormat);
}
function StatusBadge({ status }: { status: PrintQueueItem['status'] }) {
function StatusBadge({ status, waitingReason }: { status: PrintQueueItem['status']; waitingReason?: string | null }) {
// Special case: pending with waiting_reason shows as "Waiting"
if (status === 'pending' && waitingReason) {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border text-purple-400 bg-purple-400/10 border-purple-400/20">
<Clock className="w-3.5 h-3.5" />
Waiting
</span>
);
}
const config = {
pending: { icon: Clock, color: 'text-status-warning bg-status-warning/10 border-status-warning/20', label: 'Pending' },
printing: { icon: Play, color: 'text-blue-400 bg-blue-400/10 border-blue-400/20', label: 'Printing' },
@@ -399,9 +409,13 @@ function SortableQueueItem({
</div>
<div className="flex items-center gap-3 text-sm text-bambu-gray">
<span className={`flex items-center gap-1.5 ${item.printer_id === null ? 'text-orange-400' : ''}`}>
<span className={`flex items-center gap-1.5 ${item.printer_id === null && !item.target_model ? 'text-orange-400' : ''} ${item.target_model ? 'text-blue-400' : ''}`}>
<Printer className="w-3.5 h-3.5" />
{item.printer_id === null ? 'Unassigned' : (item.printer_name || `Printer #${item.printer_id}`)}
{item.target_model
? `Any ${item.target_model}${item.required_filament_types?.length ? ` (${item.required_filament_types.join(', ')})` : ''}`
: item.printer_id === null
? 'Unassigned'
: (item.printer_name || `Printer #${item.printer_id}`)}
</span>
{item.print_time_seconds && (
<span className="flex items-center gap-1.5">
@@ -448,6 +462,14 @@ function SortableQueueItem({
</div>
)}
{/* Waiting reason for model-based assignments */}
{item.waiting_reason && item.status === 'pending' && (
<p className="text-xs text-purple-400 mt-2 flex items-start gap-1">
<AlertCircle className="w-3 h-3 mt-0.5 flex-shrink-0" />
<span>{item.waiting_reason}</span>
</p>
)}
{/* Error message */}
{item.error_message && (
<p className="text-xs text-red-400 mt-2 flex items-center gap-1">
@@ -458,7 +480,7 @@ function SortableQueueItem({
</div>
{/* Status badge */}
<StatusBadge status={item.status} />
<StatusBadge status={item.status} waitingReason={item.waiting_reason} />
{/* Actions */}
<div className="flex items-center gap-1">
+83 -62
View File
@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock } from 'lucide-react';
import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { api } from '../api/client';
@@ -138,13 +138,17 @@ export function SettingsPage() {
let totalLifetime = 0;
let reachableCount = 0;
for (const { status } of statuses) {
if (status?.reachable && status.energy) {
for (const { plug, status } of statuses) {
// For MQTT plugs, consider reachable if we have power data
const hasMqttData = plug.plug_type === 'mqtt' && (status?.energy?.power != null);
const isReachable = (status?.reachable || hasMqttData) && status?.energy;
if (isReachable) {
reachableCount++;
if (status.energy.power != null) totalPower += status.energy.power;
if (status.energy.today != null) totalToday += status.energy.today;
if (status.energy.yesterday != null) totalYesterday += status.energy.yesterday;
if (status.energy.total != null) totalLifetime += status.energy.total;
if (status.energy?.power != null) totalPower += status.energy.power;
if (status.energy?.today != null) totalToday += status.energy.today;
if (status.energy?.yesterday != null) totalYesterday += status.energy.yesterday;
if (status.energy?.total != null) totalLifetime += status.energy.total;
}
}
@@ -544,14 +548,16 @@ export function SettingsPage() {
// Local state for camera URL inputs (to avoid saving on every keystroke)
const [localCameraUrls, setLocalCameraUrls] = useState<Record<number, string>>({});
const cameraUrlSaveTimeoutRef = useRef<Record<number, ReturnType<typeof setTimeout>>>({});
const initializedPrinterUrlsRef = useRef<Set<number>>(new Set());
// Initialize local camera URLs from printer data
useEffect(() => {
if (printers) {
const urls: Record<number, string> = {};
printers.forEach(p => {
if (p.external_camera_url && localCameraUrls[p.id] === undefined) {
if (p.external_camera_url && !initializedPrinterUrlsRef.current.has(p.id)) {
urls[p.id] = p.external_camera_url;
initializedPrinterUrlsRef.current.add(p.id);
}
});
if (Object.keys(urls).length > 0) {
@@ -738,17 +744,20 @@ export function SettingsPage() {
<Globe className="w-4 h-4 inline mr-1" />
{t('settings.language')}
</label>
<select
value={i18n.language}
onChange={(e) => i18n.changeLanguage(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
{availableLanguages.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.nativeName} ({lang.name})
</option>
))}
</select>
<div className="relative">
<select
value={i18n.language}
onChange={(e) => i18n.changeLanguage(e.target.value)}
className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
>
{availableLanguages.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.nativeName} ({lang.name})
</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
</div>
<p className="text-xs text-bambu-gray mt-1">
{t('settings.languageDescription')}
</p>
@@ -757,17 +766,20 @@ export function SettingsPage() {
<label className="block text-sm text-bambu-gray mb-1">
{t('settings.defaultView')}
</label>
<select
value={defaultView}
onChange={(e) => handleDefaultViewChange(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
{defaultNavItems.map((item) => (
<option key={item.id} value={item.to}>
{t(item.labelKey)}
</option>
))}
</select>
<div className="relative">
<select
value={defaultView}
onChange={(e) => handleDefaultViewChange(e.target.value)}
className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
>
{defaultNavItems.map((item) => (
<option key={item.id} value={item.to}>
{t(item.labelKey)}
</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
</div>
<p className="text-xs text-bambu-gray mt-1">
{t('settings.defaultViewDescription')}
</p>
@@ -777,48 +789,57 @@ export function SettingsPage() {
<label className="block text-sm text-bambu-gray mb-1">
Date Format
</label>
<select
value={localSettings.date_format || 'system'}
onChange={(e) => updateSetting('date_format', e.target.value as 'system' | 'us' | 'eu' | 'iso')}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="system">System Default</option>
<option value="us">US (MM/DD/YYYY)</option>
<option value="eu">EU (DD/MM/YYYY)</option>
<option value="iso">ISO (YYYY-MM-DD)</option>
</select>
<div className="relative">
<select
value={localSettings.date_format || 'system'}
onChange={(e) => updateSetting('date_format', e.target.value as 'system' | 'us' | 'eu' | 'iso')}
className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
>
<option value="system">System Default</option>
<option value="us">US (MM/DD/YYYY)</option>
<option value="eu">EU (DD/MM/YYYY)</option>
<option value="iso">ISO (YYYY-MM-DD)</option>
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
</div>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">
Time Format
</label>
<select
value={localSettings.time_format || 'system'}
onChange={(e) => updateSetting('time_format', e.target.value as 'system' | '12h' | '24h')}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="system">System Default</option>
<option value="12h">12-hour (3:30 PM)</option>
<option value="24h">24-hour (15:30)</option>
</select>
<div className="relative">
<select
value={localSettings.time_format || 'system'}
onChange={(e) => updateSetting('time_format', e.target.value as 'system' | '12h' | '24h')}
className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
>
<option value="system">System Default</option>
<option value="12h">12-hour (3:30 PM)</option>
<option value="24h">24-hour (15:30)</option>
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
</div>
</div>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">
Default Printer
</label>
<select
value={localSettings.default_printer_id ?? ''}
onChange={(e) => updateSetting('default_printer_id', e.target.value ? Number(e.target.value) : null)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="">No default (ask each time)</option>
{printers?.map((printer) => (
<option key={printer.id} value={printer.id}>
{printer.name}
</option>
))}
</select>
<div className="relative">
<select
value={localSettings.default_printer_id ?? ''}
onChange={(e) => updateSetting('default_printer_id', e.target.value ? Number(e.target.value) : null)}
className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
>
<option value="">No default (ask each time)</option>
{printers?.map((printer) => (
<option key={printer.id} value={printer.id}>
{printer.name}
</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
</div>
<p className="text-xs text-bambu-gray mt-1">
Pre-select this printer for uploads, reprints, and other operations.
</p>
@@ -1643,7 +1664,7 @@ export function SettingsPage() {
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-bambu-gray">
Connect to Home Assistant to control smart plugs via HA's REST API. Supports switch, light, and input_boolean entities.
Connect to Home Assistant to control smart plugs via HA's REST API. Supports switch, light, input_boolean, and script entities.
</p>
<div className="flex items-center justify-between">