[FEAT] Printer page header update (#1203)

This commit is contained in:
Ed
2026-05-07 10:43:11 +02:00
committed by GitHub
parent 972e635233
commit 3c0c7a8ddc
10 changed files with 419 additions and 181 deletions
@@ -4,6 +4,7 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
import { PrintersPage } from '../../pages/PrintersPage';
import { http, HttpResponse } from 'msw';
@@ -60,6 +61,13 @@ const mockPrinterStatus = {
vt_tray: [],
};
const selectToolbarDropdownOption = async (triggerName: RegExp, optionName: RegExp) => {
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: triggerName }));
await user.click(await screen.findByRole('button', { name: optionName }));
};
describe('PrintersPage', () => {
beforeEach(() => {
localStorage.removeItem('printerCardSize');
@@ -811,9 +819,7 @@ describe('PrintersPage', () => {
render(<PrintersPage />);
await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());
// Select "Offline" from the status filter dropdown
const statusSelect = screen.getByDisplayValue('All statuses');
fireEvent.change(statusSelect, { target: { value: 'offline' } });
await selectToolbarDropdownOption(/all statuses/i, /^offline$/i);
await waitFor(() => {
expect(screen.queryByText('X1 Carbon')).not.toBeInTheDocument();
@@ -826,8 +832,7 @@ describe('PrintersPage', () => {
await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());
// Both printers are IDLE; filtering by "printing" should yield no results
const statusSelect = screen.getByDisplayValue('All statuses');
fireEvent.change(statusSelect, { target: { value: 'printing' } });
await selectToolbarDropdownOption(/all statuses/i, /^printing$/i);
await waitFor(() => {
expect(screen.getByText('No printers match your search or filters')).toBeInTheDocument();
@@ -849,7 +854,7 @@ describe('PrintersPage', () => {
await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());
// Filter to only "printing" printers
fireEvent.change(screen.getByDisplayValue('All statuses'), { target: { value: 'printing' } });
await selectToolbarDropdownOption(/all statuses/i, /^printing$/i);
// Then also search for a term that only matches printer 1
fireEvent.change(screen.getByPlaceholderText('Search printers...'), { target: { value: 'X1' } });
@@ -878,16 +883,14 @@ describe('PrintersPage', () => {
expect(screen.getByText('P1S Backup')).toBeInTheDocument();
});
// Select "Workshop" from the location filter dropdown
fireEvent.change(screen.getByDisplayValue('All locations'), { target: { value: 'Workshop' } });
await selectToolbarDropdownOption(/all locations/i, /^workshop$/i);
await waitFor(() => {
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
expect(screen.queryByText('P1S Backup')).not.toBeInTheDocument();
});
// Switch to "Office" — the other printer should now be the only one visible
fireEvent.change(screen.getByDisplayValue('Workshop'), { target: { value: 'Office' } });
await selectToolbarDropdownOption(/^workshop$/i, /^office$/i);
await waitFor(() => {
expect(screen.queryByText('X1 Carbon')).not.toBeInTheDocument();
@@ -910,8 +913,8 @@ describe('PrintersPage', () => {
await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());
// Status filter is still there, but the location filter should be absent.
expect(screen.getByDisplayValue('All statuses')).toBeInTheDocument();
expect(screen.queryByDisplayValue('All locations')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /all statuses/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /all locations/i })).not.toBeInTheDocument();
});
});
});
+5
View File
@@ -183,6 +183,11 @@ export default {
allStatuses: 'Alle Status',
allLocations: 'Alle Standorte',
},
toolbar: {
filters: 'Filter',
view: 'Ansicht',
actions: 'Aktionen',
},
// Printer card
readyToPrint: 'Druckbereit',
external: 'Extern',
+5
View File
@@ -183,6 +183,11 @@ export default {
allStatuses: 'All statuses',
allLocations: 'All locations',
},
toolbar: {
filters: 'Filters',
view: 'View',
actions: 'Actions',
},
// Printer card
readyToPrint: 'Ready to print',
external: 'External',
+5
View File
@@ -183,6 +183,11 @@ export default {
allStatuses: 'Tous les statuts',
allLocations: 'Tous les emplacements',
},
toolbar: {
filters: 'Filters',
view: 'View',
actions: 'Actions',
},
// Printer card
readyToPrint: 'Prête à imprimer',
external: 'Externe',
+5
View File
@@ -183,6 +183,11 @@ export default {
allStatuses: 'Tutti gli stati',
allLocations: 'Tutti i luoghi',
},
toolbar: {
filters: 'Filters',
view: 'View',
actions: 'Actions',
},
// Printer card
readyToPrint: 'Pronta a stampare',
external: 'Esterna',
+5
View File
@@ -182,6 +182,11 @@ export default {
allStatuses: 'すべてのステータス',
allLocations: 'すべての場所',
},
toolbar: {
filters: 'Filters',
view: 'View',
actions: 'Actions',
},
// Printer card
readyToPrint: '印刷可能',
external: '外部',
+5
View File
@@ -183,6 +183,11 @@ export default {
allStatuses: 'Todos os status',
allLocations: 'Todos os locais',
},
toolbar: {
filters: 'Filters',
view: 'View',
actions: 'Actions',
},
// Printer card
readyToPrint: 'Pronto para imprimir',
external: 'Externo',
+5
View File
@@ -183,6 +183,11 @@ export default {
allStatuses: '所有状态',
allLocations: '所有位置',
},
toolbar: {
filters: 'Filters',
view: 'View',
actions: 'Actions',
},
// Printer card
readyToPrint: '准备打印',
external: '外部',
+5
View File
@@ -183,6 +183,11 @@ export default {
allStatuses: '所有狀態',
allLocations: '所有位置',
},
toolbar: {
filters: 'Filters',
view: 'View',
actions: 'Actions',
},
// Printer card
readyToPrint: '準備列印',
external: '外部',
+364 -169
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react';
import { compareFwVersions } from '../utils/firmwareVersion';
import { formatPrintName } from '../utils/printName';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
@@ -25,6 +25,7 @@ import {
Zap,
Wrench,
ChevronDown,
Filter,
Pencil,
ArrowUp,
ArrowDown,
@@ -57,6 +58,8 @@ import {
MoveVertical,
LogIn,
LogOut,
MoreHorizontal,
SlidersHorizontal,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
@@ -978,7 +981,7 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
];
return (
<div className="flex flex-wrap items-center gap-4 gap-y-2 text-sm">
<div className="mt-1 flex flex-wrap items-center gap-4 gap-y-2 text-bambu-gray">
{badges.map(({ count, dot, label }) => count > 0 && (
<div key={label} className="flex items-center gap-1.5">
<div className={`w-2 h-2 rounded-full ${dot}`} />
@@ -1015,6 +1018,97 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
type SortOption = 'name' | 'status' | 'model' | 'location';
type ViewMode = 'expanded' | 'compact';
type ToolbarDropdownOption<T extends string> = {
value: T;
label: string;
};
function ToolbarDropdown<T extends string>({
value,
options,
onChange,
fullWidth = false,
}: {
value: T;
options: ToolbarDropdownOption<T>[];
onChange: (value: T) => void;
fullWidth?: boolean;
}) {
const [isOpen, setIsOpen] = useState(false);
const selectedOption = options.find(option => option.value === value) ?? options[0];
return (
<div className={`relative ${fullWidth ? 'w-full min-w-0' : ''}`}>
<button
type="button"
onClick={() => setIsOpen(open => !open)}
className={`h-8 px-2 rounded-lg border bg-bambu-dark border-bambu-dark-tertiary text-white text-sm font-medium transition-colors hover:bg-bambu-dark-tertiary focus:outline-none focus:border-bambu-green flex items-center justify-between gap-2 ${fullWidth ? 'w-full' : 'min-w-28'}`}
>
<span className="truncate">{selectedOption?.label}</span>
<ChevronDown className={`w-4 h-4 text-bambu-gray transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setIsOpen(false)} />
<div className="absolute left-0 top-full z-20 mt-1 min-w-full rounded-lg border border-bambu-dark-tertiary bg-bambu-dark-secondary py-1 shadow-xl">
{options.map(option => (
<button
key={option.value}
type="button"
onClick={() => {
onChange(option.value);
setIsOpen(false);
}}
className={`w-full px-3 py-2 text-left text-sm transition-colors hover:bg-bambu-dark-tertiary ${
option.value === value ? 'text-bambu-green' : 'text-white'
}`}
>
{option.label}
</button>
))}
</div>
</>
)}
</div>
);
}
function ToolbarMenu({
label,
icon,
children,
}: {
label: string;
icon: React.ReactNode;
children: React.ReactNode;
}) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="relative">
<button
type="button"
onClick={() => setIsOpen(open => !open)}
className="h-8 w-8 rounded-lg border bg-bambu-dark border-bambu-dark-tertiary text-white hover:bg-bambu-dark-tertiary transition-colors flex items-center justify-center"
aria-label={label}
title={label}
>
{icon}
</button>
{isOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setIsOpen(false)} />
<div className="absolute right-0 top-full z-20 mt-1 min-w-40 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark-secondary p-2 shadow-xl">
{children}
</div>
</>
)}
</div>
);
}
const STATUS_GROUP_ORDER: string[] = ['error', 'printing', 'paused', 'finished', 'idle', 'offline'];
const STATUS_GROUP_META: Record<string, { labelKey: string; dot: string }> = {
@@ -6629,15 +6723,256 @@ export function PrintersPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps -- classifyPrinterStatus & filterKnownHMSErrors are stable module-level functions, not reactive deps; statusCacheVersion forces recompute on WebSocket status updates
}, [sortBy, sortedPrinters, queryClient, statusCacheVersion]);
const toolbarRef = useRef<HTMLDivElement>(null);
const expandedToolbarControlsRef = useRef<HTMLDivElement>(null);
const expandedToolbarWidthRef = useRef(0);
const [compactToolbar, setCompactToolbar] = useState(false);
const measureToolbar = useCallback(() => {
const toolbar = toolbarRef.current;
if (!toolbar) return;
const measuredControlsWidth = expandedToolbarControlsRef.current?.offsetWidth;
if (measuredControlsWidth) {
expandedToolbarWidthRef.current = measuredControlsWidth;
}
const searchMinimumWidth = 220;
const gapWidth = 8;
const shouldCompact = expandedToolbarWidthRef.current > 0 && toolbar.clientWidth < expandedToolbarWidthRef.current + searchMinimumWidth + gapWidth;
setCompactToolbar(prev => (prev === shouldCompact ? prev : shouldCompact));
}, []);
useLayoutEffect(() => {
measureToolbar();
const toolbar = toolbarRef.current;
if (!toolbar) return;
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', measureToolbar);
return () => window.removeEventListener('resize', measureToolbar);
}
const resizeObserver = new ResizeObserver(() => measureToolbar());
resizeObserver.observe(toolbar);
window.addEventListener('resize', measureToolbar);
return () => {
resizeObserver.disconnect();
window.removeEventListener('resize', measureToolbar);
};
}, [
measureToolbar,
printers?.length,
availableLocations.length,
hideDisconnected,
Object.keys(smartPlugByPrinter).length,
]);
const renderFilterControls = (inMenu = false) => (
<>
{/* Status filter */}
{printers && printers.length > 0 && (
<ToolbarDropdown
value={statusFilter}
onChange={setStatusFilter}
fullWidth={inMenu}
options={[
{ value: 'all', label: t('printers.filter.allStatuses') },
{ value: 'printing', label: t('printers.status.printing') },
{ value: 'paused', label: t('printers.status.paused') },
{ value: 'idle', label: t('printers.status.idle') },
{ value: 'finished', label: t('printers.status.finished') },
{ value: 'error', label: t('printers.status.error') },
{ value: 'offline', label: t('printers.status.offline') },
]}
/>
)}
{/* Location filter — only shown when at least one printer has a location */}
{printers && printers.length > 0 && availableLocations.length > 0 && (
<ToolbarDropdown
value={locationFilter}
onChange={setLocationFilter}
fullWidth={inMenu}
options={[
{ value: 'all', label: t('printers.filter.allLocations') },
...availableLocations.map(loc => ({ value: loc, label: loc })),
]}
/>
)}
<button
type="button"
onClick={toggleHideDisconnected}
aria-pressed={hideDisconnected}
className={`h-8 px-2 rounded-lg border text-sm font-medium transition-colors ${inMenu ? 'w-full' : ''} ${
hideDisconnected
? 'bg-bambu-green border-bambu-green text-white'
: 'bg-bambu-dark border-bambu-dark-tertiary text-white hover:bg-bambu-dark-tertiary'
}`}
>
{t('printers.hideOffline')}
</button>
</>
);
const renderViewControls = (inMenu = false) => (
<>
{/* Sort dropdown */}
<div className={`flex items-center gap-1 ${inMenu ? 'w-full' : ''}`}>
<ToolbarDropdown<SortOption>
value={sortBy}
onChange={handleSortChange}
fullWidth={inMenu}
options={[
{ value: 'name', label: t('printers.sort.name') },
{ value: 'status', label: t('printers.sort.status') },
{ value: 'model', label: t('printers.sort.model') },
{ value: 'location', label: t('printers.sort.location') },
]}
/>
<button
onClick={toggleSortDirection}
className="h-8 shrink-0 px-2 rounded-lg border bg-bambu-dark border-bambu-dark-tertiary text-white hover:bg-bambu-dark-tertiary transition-colors flex items-center justify-center"
title={sortAsc ? t('printers.sort.descending') : t('printers.sort.ascending')}
>
{sortAsc ? (
<ArrowUp className="w-4 h-4 text-white" />
) : (
<ArrowDown className="w-4 h-4 text-white" />
)}
</button>
</div>
{/* Card size selector */}
<div className={`flex h-8 items-center bg-bambu-dark rounded-lg border border-bambu-dark-tertiary ${inMenu ? 'w-full' : ''}`}>
{cardSizeLabels.map((label, index) => {
const size = index + 1;
const isSelected = cardSize === size;
return (
<button
key={label}
onClick={() => {
setCardSize(size);
localStorage.setItem('printerCardSize', String(size));
}}
className={`h-full px-2 text-xs font-medium transition-colors ${inMenu ? 'flex-1' : ''} ${
index === 0 ? 'rounded-l-lg' : ''
} ${
index === cardSizeLabels.length - 1 ? 'rounded-r-lg' : ''
} ${
isSelected
? 'bg-bambu-green text-white'
: 'text-white hover:bg-bambu-dark-tertiary'
}`}
title={label === 'S' ? t('printers.cardSize.small') : label === 'M' ? t('printers.cardSize.medium') : label === 'L' ? t('printers.cardSize.large') : t('printers.cardSize.extraLarge')}
>
{label}
</button>
);
})}
</div>
</>
);
const renderActionControls = (inMenu = false) => (
<>
{/* Bulk select toggle */}
<button
onClick={() => {
if (selectionMode) clearSelection();
else setIsSelectionMode(true);
}}
className={`h-8 px-2 rounded-lg border transition-colors ${inMenu ? 'w-full justify-center gap-1.5 text-sm font-medium flex items-center' : ''} ${
selectionMode
? 'bg-bambu-green border-bambu-green text-white'
: 'bg-bambu-dark border-bambu-dark-tertiary text-white hover:bg-bambu-dark-tertiary'
}`}
title={t('printers.bulk.select')}
disabled={!hasPermission('printers:control')}
>
<CheckSquare className="w-4 h-4" />
{inMenu && <span>{t('printers.bulk.select')}</span>}
</button>
{/* Power dropdown for offline printers with smart plugs */}
{hideDisconnected && Object.keys(smartPlugByPrinter).length > 0 && (
<div className={`relative ${inMenu ? 'w-full' : ''}`}>
<button
onClick={() => setShowPowerDropdown(!showPowerDropdown)}
className={`h-8 flex items-center gap-1.5 px-2 text-sm rounded-lg border transition-colors ${
inMenu
? 'w-full justify-between bg-bambu-dark border-bambu-dark-tertiary text-white hover:bg-bambu-dark-tertiary hover:text-white'
: 'bg-bambu-dark border-bambu-dark-tertiary text-white hover:bg-bambu-dark-tertiary'
}`}
>
<span className="flex items-center gap-1.5">
<Power className="w-4 h-4" />
{t('printers.powerOn')}
</span>
<ChevronDown className={`w-3 h-3 transition-transform ${showPowerDropdown ? 'rotate-180' : ''}`} />
</button>
{showPowerDropdown && (
<>
{/* Backdrop to close dropdown */}
<div
className="fixed inset-0 z-10"
onClick={() => setShowPowerDropdown(false)}
/>
<div className="absolute right-0 mt-2 w-56 bg-white dark:bg-bambu-dark-secondary border border-gray-200 dark:border-bambu-dark-tertiary rounded-lg shadow-lg z-20 py-1">
<div className="px-3 py-2 text-xs text-gray-500 dark:text-bambu-gray border-b border-gray-200 dark:border-bambu-dark-tertiary">
{t('printers.offlinePrintersWithPlugs')}
</div>
{printers?.filter(p => smartPlugByPrinter[p.id]).map(printer => (
<PowerDropdownItem
key={printer.id}
printer={printer}
plug={smartPlugByPrinter[printer.id]}
onPowerOn={(plugId) => {
setPoweringOn(plugId);
powerOnMutation.mutate(plugId);
}}
isPowering={poweringOn === smartPlugByPrinter[printer.id]?.id}
/>
))}
{printers?.filter(p => smartPlugByPrinter[p.id]).length === 0 && (
<div className="px-3 py-2 text-sm text-bambu-gray">
No printers with smart plugs
</div>
)}
</div>
</>
)}
</div>
)}
<Button
onClick={() => setShowAddModal(true)}
disabled={!hasPermission('printers:create')}
title={!hasPermission('printers:create') ? t('printers.permission.noAdd') : undefined}
className={`!h-8 !min-h-8 px-2 py-0 ${inMenu ? 'w-full' : ''}`}
>
<Plus className="w-4 h-4" />
{t('printers.addPrinter')}
</Button>
</>
);
return (
<div className="p-4 md:p-8">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div className="space-y-3 mb-6">
<div>
<h1 className="text-2xl font-bold text-white">{t('printers.title')}</h1>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<PrinterIcon className="w-7 h-7 text-bambu-green" />
{t('printers.title')}
</h1>
<StatusSummaryBar printers={printers} />
</div>
<div ref={toolbarRef} className="relative flex items-center gap-2">
{/* Only show search bar when printers exist */}
{printers && printers.length > 0 && (
<div className="relative w-full sm:max-w-sm mt-3">
<div className="relative min-w-0 flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50" />
<input
type="search"
@@ -6649,7 +6984,7 @@ export function PrintersPage() {
onChange={(e) => setSearch(e.target.value)}
placeholder={t('printers.search')}
aria-label={t('printers.search')}
className="w-full pl-10 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
className="w-full h-8 pl-9 pr-8 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
/>
{search && (
<button
@@ -6663,173 +6998,33 @@ export function PrintersPage() {
)}
</div>
)}
</div>
<div className="flex items-center gap-2 sm:gap-3 flex-wrap">
{/* Sort dropdown */}
<div className="flex items-center gap-1">
<select
value={sortBy}
onChange={(e) => handleSortChange(e.target.value as SortOption)}
className="text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg px-2 py-1.5 text-white focus:border-bambu-green focus:outline-none"
>
<option value="name">{t('printers.sort.name')}</option>
<option value="status">{t('printers.sort.status')}</option>
<option value="model">{t('printers.sort.model')}</option>
<option value="location">{t('printers.sort.location')}</option>
</select>
<button
onClick={toggleSortDirection}
className="p-1.5 rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
title={sortAsc ? t('printers.sort.descending') : t('printers.sort.ascending')}
>
{sortAsc ? (
<ArrowUp className="w-4 h-4 text-bambu-gray" />
) : (
<ArrowDown className="w-4 h-4 text-bambu-gray" />
)}
</button>
</div>
{/* Status filter */}
{printers && printers.length > 0 && (
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg px-2 py-1.5 text-white focus:border-bambu-green focus:outline-none"
>
<option value="all">{t('printers.filter.allStatuses')}</option>
<option value="printing">{t('printers.status.printing')}</option>
<option value="paused">{t('printers.status.paused')}</option>
<option value="idle">{t('printers.status.idle')}</option>
<option value="finished">{t('printers.status.finished')}</option>
<option value="error">{t('printers.status.error')}</option>
<option value="offline">{t('printers.status.offline')}</option>
</select>
)}
{/* Location filter — only shown when at least one printer has a location */}
{printers && printers.length > 0 && availableLocations.length > 0 && (
<select
value={locationFilter}
onChange={(e) => setLocationFilter(e.target.value)}
className="text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg px-2 py-1.5 text-white focus:border-bambu-green focus:outline-none"
>
<option value="all">{t('printers.filter.allLocations')}</option>
{availableLocations.map(loc => (
<option key={loc} value={loc}>{loc}</option>
))}
</select>
)}
{/* Card size selector */}
<div className="flex items-center bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
{cardSizeLabels.map((label, index) => {
const size = index + 1;
const isSelected = cardSize === size;
return (
<button
key={label}
onClick={() => {
setCardSize(size);
localStorage.setItem('printerCardSize', String(size));
}}
className={`px-2 py-1.5 text-xs font-medium transition-colors ${
index === 0 ? 'rounded-l-lg' : ''
} ${
index === cardSizeLabels.length - 1 ? 'rounded-r-lg' : ''
} ${
isSelected
? 'bg-bambu-green text-white'
: 'text-bambu-gray hover:bg-bambu-dark-tertiary hover:text-white'
}`}
title={label === 'S' ? t('printers.cardSize.small') : label === 'M' ? t('printers.cardSize.medium') : label === 'L' ? t('printers.cardSize.large') : t('printers.cardSize.extraLarge')}
>
{label}
</button>
);
})}
</div>
{/* Bulk select toggle */}
<button
onClick={() => {
if (selectionMode) clearSelection();
else setIsSelectionMode(true);
}}
className={`p-1.5 rounded-lg transition-colors ${
selectionMode
? 'bg-bambu-green text-white'
: 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
}`}
title={t('printers.bulk.select')}
disabled={!hasPermission('printers:control')}
<div
ref={expandedToolbarControlsRef}
aria-hidden={compactToolbar}
inert={compactToolbar}
className={`${compactToolbar ? 'absolute -left-[9999px] top-0 flex w-max pointer-events-none opacity-0' : 'flex'} ml-auto items-center justify-end gap-2 flex-nowrap [&>*]:shrink-0`}
>
<CheckSquare className="w-4 h-4" />
</button>
<div className="h-6 w-px bg-bambu-dark-tertiary" />
<div className="flex items-center gap-2">{renderFilterControls()}</div>
<div className="h-6 w-px bg-bambu-dark-tertiary" />
<div className="flex items-center gap-2">{renderViewControls()}</div>
<div className="h-6 w-px bg-bambu-dark-tertiary" />
<div className="flex items-center gap-2">{renderActionControls()}</div>
</div>
<div className="w-px h-6 bg-bambu-dark-tertiary" />
<label className="flex items-center gap-2 text-sm text-bambu-gray cursor-pointer">
<input
type="checkbox"
checked={hideDisconnected}
onChange={toggleHideDisconnected}
className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
{t('printers.hideOffline')}
</label>
{/* Power dropdown for offline printers with smart plugs */}
{hideDisconnected && Object.keys(smartPlugByPrinter).length > 0 && (
<div className="relative">
<button
onClick={() => setShowPowerDropdown(!showPowerDropdown)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-white dark:bg-bambu-dark-secondary border border-gray-200 dark:border-bambu-dark-tertiary rounded-lg text-gray-600 dark:text-bambu-gray hover:text-gray-900 dark:hover:text-white hover:border-bambu-green transition-colors"
>
<Power className="w-4 h-4" />
{t('printers.powerOn')}
<ChevronDown className={`w-3 h-3 transition-transform ${showPowerDropdown ? 'rotate-180' : ''}`} />
</button>
{showPowerDropdown && (
<>
{/* Backdrop to close dropdown */}
<div
className="fixed inset-0 z-10"
onClick={() => setShowPowerDropdown(false)}
/>
<div className="absolute right-0 mt-2 w-56 bg-white dark:bg-bambu-dark-secondary border border-gray-200 dark:border-bambu-dark-tertiary rounded-lg shadow-lg z-20 py-1">
<div className="px-3 py-2 text-xs text-gray-500 dark:text-bambu-gray border-b border-gray-200 dark:border-bambu-dark-tertiary">
{t('printers.offlinePrintersWithPlugs')}
</div>
{printers?.filter(p => smartPlugByPrinter[p.id]).map(printer => (
<PowerDropdownItem
key={printer.id}
printer={printer}
plug={smartPlugByPrinter[printer.id]}
onPowerOn={(plugId) => {
setPoweringOn(plugId);
powerOnMutation.mutate(plugId);
}}
isPowering={poweringOn === smartPlugByPrinter[printer.id]?.id}
/>
))}
{printers?.filter(p => smartPlugByPrinter[p.id]).length === 0 && (
<div className="px-3 py-2 text-sm text-bambu-gray">
No printers with smart plugs
</div>
)}
</div>
</>
)}
{compactToolbar && (
<div className="ml-auto flex items-center justify-end gap-1">
<ToolbarMenu label={t('printers.toolbar.filters', 'Filters')} icon={<Filter className="w-4 h-4" />}>
<div className="flex w-48 flex-col gap-2">{renderFilterControls(true)}</div>
</ToolbarMenu>
<ToolbarMenu label={t('printers.toolbar.view', 'View')} icon={<SlidersHorizontal className="w-4 h-4" />}>
<div className="flex w-48 flex-col gap-2">{renderViewControls(true)}</div>
</ToolbarMenu>
<ToolbarMenu label={t('printers.toolbar.actions', 'Actions')} icon={<MoreHorizontal className="w-4 h-4" />}>
<div className="flex w-48 flex-col gap-2">{renderActionControls(true)}</div>
</ToolbarMenu>
</div>
)}
<Button
onClick={() => setShowAddModal(true)}
disabled={!hasPermission('printers:create')}
title={!hasPermission('printers:create') ? t('printers.permission.noAdd') : undefined}
>
<Plus className="w-4 h-4" />
{t('printers.addPrinter')}
</Button>
</div>
</div>