mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
Merge branch '0.1.6-final' into feature/164
This commit is contained in:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user