|
|
@@ -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="w-px h-6 bg-bambu-dark-tertiary" />
|
|
|
+ <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>
|
|
|
|
|
|
- <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>
|
|
|
|