mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-07 06:31:22 +02:00
[FEAT] Printer page header update (#1203)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -182,6 +182,11 @@ export default {
|
||||
allStatuses: 'すべてのステータス',
|
||||
allLocations: 'すべての場所',
|
||||
},
|
||||
toolbar: {
|
||||
filters: 'Filters',
|
||||
view: 'View',
|
||||
actions: 'Actions',
|
||||
},
|
||||
// Printer card
|
||||
readyToPrint: '印刷可能',
|
||||
external: '外部',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -183,6 +183,11 @@ export default {
|
||||
allStatuses: '所有状态',
|
||||
allLocations: '所有位置',
|
||||
},
|
||||
toolbar: {
|
||||
filters: 'Filters',
|
||||
view: 'View',
|
||||
actions: 'Actions',
|
||||
},
|
||||
// Printer card
|
||||
readyToPrint: '准备打印',
|
||||
external: '外部',
|
||||
|
||||
@@ -183,6 +183,11 @@ export default {
|
||||
allStatuses: '所有狀態',
|
||||
allLocations: '所有位置',
|
||||
},
|
||||
toolbar: {
|
||||
filters: 'Filters',
|
||||
view: 'View',
|
||||
actions: 'Actions',
|
||||
},
|
||||
// Printer card
|
||||
readyToPrint: '準備列印',
|
||||
external: '外部',
|
||||
|
||||
+364
-169
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user