mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
Feature: Collapsible folders for printer filters (#968)
This commit is contained in:
@@ -108,7 +108,7 @@ export function BulkPrinterToolbar({
|
||||
printing: t('printers.status.printing'),
|
||||
paused: t('printers.status.paused', 'Paused'),
|
||||
finished: t('printers.status.finished', 'Finished'),
|
||||
idle: t('printers.status.available'),
|
||||
idle: t('printers.status.idle'),
|
||||
error: t('printers.status.problem'),
|
||||
offline: t('printers.status.offline'),
|
||||
};
|
||||
|
||||
@@ -8,12 +8,21 @@ interface CollapsibleProps {
|
||||
defaultOpen?: boolean;
|
||||
className?: string;
|
||||
summaryClassName?: string;
|
||||
/** When provided, the component is controlled — parent owns the open state. */
|
||||
open?: boolean;
|
||||
/** Called when the user clicks the toggle. Use with `open` for controlled mode. */
|
||||
onToggle?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight disclosure used for densifying the Settings page.
|
||||
* Renders a clickable summary row and animates open/close via a simple
|
||||
* display swap (no height animation — keeps it snappy and layout-stable).
|
||||
* Lightweight disclosure widget.
|
||||
* Renders a clickable summary row and conditionally displays children.
|
||||
*
|
||||
* The toggle region is a plain <div> with role="button" so that the summary
|
||||
* slot may safely contain interactive elements (buttons, links) without
|
||||
* nesting a <button> inside a <button>.
|
||||
*
|
||||
* Supports both uncontrolled (internal state) and controlled (`open`/`onToggle`) modes.
|
||||
*/
|
||||
export function Collapsible({
|
||||
summary,
|
||||
@@ -21,22 +30,35 @@ export function Collapsible({
|
||||
defaultOpen = false,
|
||||
className = '',
|
||||
summaryClassName = '',
|
||||
open: controlledOpen,
|
||||
onToggle,
|
||||
}: CollapsibleProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const isOpen = isControlled ? controlledOpen : internalOpen;
|
||||
|
||||
const handleToggle = () => {
|
||||
const next = !isOpen;
|
||||
if (!isControlled) setInternalOpen(next);
|
||||
onToggle?.(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className={`w-full flex items-center justify-between gap-2 text-left ${summaryClassName}`}
|
||||
aria-expanded={open}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleToggle}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleToggle(); } }}
|
||||
className={`w-full flex items-center justify-between gap-2 text-left cursor-pointer ${summaryClassName}`}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<div className="flex-1 min-w-0">{summary}</div>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-bambu-gray flex-shrink-0 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
className={`w-4 h-4 text-bambu-gray flex-shrink-0 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{open && <div className="mt-3">{children}</div>}
|
||||
</div>
|
||||
{isOpen && <div className="mt-3">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ import { PrinterInfoModal } from '../components/PrinterInfoModal';
|
||||
import { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag } from '../utils/amsHelpers';
|
||||
import { getPrinterImage, getWifiStrength, filterCompatibleQueueItems } from '../utils/printer';
|
||||
import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
|
||||
import { Collapsible } from '../components/Collapsible';
|
||||
import { getColorName, parseFilamentColor, isLightColor } from '../utils/colors';
|
||||
|
||||
// Color names resolve via getColorName() which reads the backend color_catalog
|
||||
@@ -988,6 +989,33 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
|
||||
type SortOption = 'name' | 'status' | 'model' | 'location';
|
||||
type ViewMode = 'expanded' | 'compact';
|
||||
|
||||
const STATUS_GROUP_ORDER: string[] = ['error', 'printing', 'paused', 'finished', 'idle', 'offline'];
|
||||
|
||||
const STATUS_GROUP_META: Record<string, { labelKey: string; dot: string }> = {
|
||||
error: { labelKey: 'printers.status.problem', dot: 'bg-status-error' },
|
||||
printing: { labelKey: 'printers.status.printing', dot: 'bg-bambu-green animate-pulse' },
|
||||
paused: { labelKey: 'printers.status.paused', dot: 'bg-status-warning' },
|
||||
finished: { labelKey: 'printers.status.finished', dot: 'bg-blue-400' },
|
||||
idle: { labelKey: 'printers.status.idle', dot: 'bg-bambu-green' },
|
||||
offline: { labelKey: 'printers.status.offline', dot: 'bg-gray-400' },
|
||||
};
|
||||
|
||||
/** Classify a printer into one of the UI status buckets. */
|
||||
function classifyPrinterStatus(
|
||||
status: { connected: boolean; state: string | null; hms_errors?: HMSError[] } | undefined,
|
||||
): PrinterState {
|
||||
if (!status?.connected) return 'offline';
|
||||
const hmsErrors = status.hms_errors ? filterKnownHMSErrors(status.hms_errors) : [];
|
||||
if (hmsErrors.length > 0) return 'error';
|
||||
switch (status.state) {
|
||||
case 'RUNNING': return 'printing';
|
||||
case 'PAUSE': return 'paused';
|
||||
case 'FINISH': return 'finished';
|
||||
case 'FAILED': return 'error';
|
||||
default: return 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable status display text for a printer.
|
||||
* Uses stg_cur_name for detailed calibration/preparation stages,
|
||||
@@ -5841,6 +5869,12 @@ export function PrintersPage() {
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [locationFilter, setLocationFilter] = useState<string>('all');
|
||||
const [statusCacheVersion, setStatusCacheVersion] = useState(0);
|
||||
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('printerCollapsedSections');
|
||||
return saved ? JSON.parse(saved) : {};
|
||||
} catch { return {}; }
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const { hasPermission } = useAuth();
|
||||
@@ -6265,15 +6299,7 @@ export function PrintersPage() {
|
||||
const next = new Set(prev);
|
||||
sortedPrinters.forEach(p => {
|
||||
const status = queryClient.getQueryData<{ connected: boolean; state: string | null; hms_errors?: HMSError[] }>(['printerStatus', p.id]);
|
||||
if (!status) return;
|
||||
switch (state) {
|
||||
case 'printing': if (status.connected && status.state === 'RUNNING') next.add(p.id); break;
|
||||
case 'paused': if (status.connected && status.state === 'PAUSE') next.add(p.id); break;
|
||||
case 'finished': if (status.connected && status.state === 'FINISH') next.add(p.id); break;
|
||||
case 'idle': if (status.connected && status.state !== 'RUNNING' && status.state !== 'PAUSE' && status.state !== 'FINISH' && status.state !== 'FAILED') next.add(p.id); break;
|
||||
case 'error': if (status.connected && (status.state === 'FAILED' || (status.hms_errors && filterKnownHMSErrors(status.hms_errors).length > 0))) next.add(p.id); break;
|
||||
case 'offline': if (!status.connected) next.add(p.id); break;
|
||||
}
|
||||
if (classifyPrinterStatus(status) === state) next.add(p.id);
|
||||
});
|
||||
return next;
|
||||
});
|
||||
@@ -6289,18 +6315,53 @@ export function PrintersPage() {
|
||||
setIsSelectionMode(true);
|
||||
}, [sortedPrinters]);
|
||||
|
||||
// Group printers by location when sorted by location
|
||||
const selectByModel = useCallback((model: string) => {
|
||||
setSelectedPrinterIds(prev => {
|
||||
const next = new Set(prev);
|
||||
sortedPrinters.filter(p => (p.model || 'Unknown') === model).forEach(p => next.add(p.id));
|
||||
return next;
|
||||
});
|
||||
setIsSelectionMode(true);
|
||||
}, [sortedPrinters]);
|
||||
|
||||
const toggleSectionCollapse = useCallback((key: string) => {
|
||||
setCollapsedSections(prev => {
|
||||
const next = { ...prev, [key]: !prev[key] };
|
||||
try { localStorage.setItem('printerCollapsedSections', JSON.stringify(next)); } catch { /* quota exceeded / private mode */ }
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Group printers when sorted by location, status, or model
|
||||
const groupedPrinters = useMemo(() => {
|
||||
if (sortBy !== 'location') return null;
|
||||
if (sortBy === 'name') return null;
|
||||
|
||||
const groups: Record<string, typeof sortedPrinters> = {};
|
||||
sortedPrinters.forEach(printer => {
|
||||
const location = printer.location || 'Ungrouped';
|
||||
if (!groups[location]) groups[location] = [];
|
||||
groups[location].push(printer);
|
||||
});
|
||||
|
||||
if (sortBy === 'location') {
|
||||
sortedPrinters.forEach(printer => {
|
||||
const location = printer.location || 'Ungrouped';
|
||||
if (!groups[location]) groups[location] = [];
|
||||
groups[location].push(printer);
|
||||
});
|
||||
} else if (sortBy === 'model') {
|
||||
sortedPrinters.forEach(printer => {
|
||||
const model = printer.model || 'Unknown';
|
||||
if (!groups[model]) groups[model] = [];
|
||||
groups[model].push(printer);
|
||||
});
|
||||
} else if (sortBy === 'status') {
|
||||
sortedPrinters.forEach(printer => {
|
||||
const status = queryClient.getQueryData<{ connected: boolean; state: string | null; hms_errors?: HMSError[] }>(['printerStatus', printer.id]);
|
||||
const group = classifyPrinterStatus(status);
|
||||
if (!groups[group]) groups[group] = [];
|
||||
groups[group].push(printer);
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [sortBy, sortedPrinters]);
|
||||
// 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]);
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8">
|
||||
@@ -6525,59 +6586,92 @@ export function PrintersPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : groupedPrinters ? (
|
||||
/* Grouped by location view */
|
||||
/* Grouped view (location, status, or model) */
|
||||
<div className="space-y-6">
|
||||
{Object.entries(groupedPrinters).map(([location, locationPrinters]) => (
|
||||
<div key={location}>
|
||||
<h2 className="text-lg font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-bambu-green" />
|
||||
{location}
|
||||
<span className="text-sm font-normal text-bambu-gray">({locationPrinters.length})</span>
|
||||
{selectionMode && (
|
||||
<button
|
||||
onClick={() => selectByLocation(location === 'Ungrouped' ? '' : location)}
|
||||
className="text-xs text-bambu-green hover:text-bambu-green-light transition-colors ml-1"
|
||||
>
|
||||
{t('printers.bulk.selectAll')}
|
||||
</button>
|
||||
)}
|
||||
</h2>
|
||||
<div className={`grid gap-4 ${cardSize >= 3 ? 'gap-6' : ''} ${getGridClasses()}`}>
|
||||
{locationPrinters.map((printer) => (
|
||||
<PrinterCard
|
||||
key={printer.id}
|
||||
printer={printer}
|
||||
hideIfDisconnected={hideDisconnected}
|
||||
maintenanceInfo={maintenanceByPrinter[printer.id]}
|
||||
viewMode={viewMode}
|
||||
cardSize={cardSize}
|
||||
amsThresholds={settings ? {
|
||||
humidityGood: Number(settings.ams_humidity_good) || 40,
|
||||
humidityFair: Number(settings.ams_humidity_fair) || 60,
|
||||
tempGood: Number(settings.ams_temp_good) || 28,
|
||||
tempFair: Number(settings.ams_temp_fair) || 35,
|
||||
} : undefined}
|
||||
spoolmanEnabled={spoolmanEnabled}
|
||||
hasUnlinkedSpools={hasUnlinkedSpools}
|
||||
linkedSpools={linkedSpools}
|
||||
spoolmanUrl={spoolmanStatus?.url}
|
||||
spoolmanSyncMode={spoolmanSyncMode}
|
||||
onGetAssignment={getAssignment}
|
||||
onUnassignSpool={(pid, aid, tid) => unassignMutation.mutate({ printerId: pid, amsId: aid, trayId: tid })}
|
||||
timeFormat={settings?.time_format || 'system'}
|
||||
cameraViewMode={settings?.camera_view_mode || 'window'}
|
||||
onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
|
||||
checkPrinterFirmware={settings?.check_printer_firmware !== false}
|
||||
dryingPresets={effectiveDryingPresets}
|
||||
requirePlateClear={settings?.require_plate_clear === true}
|
||||
selectionMode={selectionMode}
|
||||
isSelected={selectedPrinterIds.has(printer.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(() => {
|
||||
const keys = sortBy === 'status'
|
||||
? STATUS_GROUP_ORDER.filter(k => groupedPrinters[k]?.length > 0)
|
||||
: Object.keys(groupedPrinters);
|
||||
// For status grouping, asc/desc flips the fixed priority order
|
||||
// (asc = error→offline, desc = offline→error). This matches the
|
||||
// sort-toggle behaviour for other groupings.
|
||||
return (sortAsc ? keys : [...keys].reverse());
|
||||
})().map((groupKey) => {
|
||||
const groupPrinters = groupedPrinters[groupKey];
|
||||
const collapseKey = `${sortBy}:${groupKey}`;
|
||||
const isOpen = !collapsedSections[collapseKey];
|
||||
|
||||
const dot = sortBy === 'status'
|
||||
? STATUS_GROUP_META[groupKey]?.dot || 'bg-bambu-green'
|
||||
: 'bg-bambu-green';
|
||||
const label = sortBy === 'status'
|
||||
? t(STATUS_GROUP_META[groupKey]?.labelKey || groupKey)
|
||||
: groupKey;
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
key={groupKey}
|
||||
open={isOpen}
|
||||
onToggle={() => toggleSectionCollapse(collapseKey)}
|
||||
summaryClassName="py-1"
|
||||
summary={
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${dot}`} />
|
||||
{label}
|
||||
<span className="text-sm font-normal text-bambu-gray">({groupPrinters.length})</span>
|
||||
{selectionMode && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (sortBy === 'location') selectByLocation(groupKey === 'Ungrouped' ? '' : groupKey);
|
||||
else if (sortBy === 'status') selectByState(groupKey as PrinterState);
|
||||
else if (sortBy === 'model') selectByModel(groupKey);
|
||||
}}
|
||||
className="text-xs text-bambu-green hover:text-bambu-green-light transition-colors ml-1"
|
||||
>
|
||||
{t('printers.bulk.selectAll')}
|
||||
</button>
|
||||
)}
|
||||
</h2>
|
||||
}
|
||||
>
|
||||
<div className={`grid gap-4 ${cardSize >= 3 ? 'gap-6' : ''} ${getGridClasses()}`}>
|
||||
{groupPrinters.map((printer) => (
|
||||
<PrinterCard
|
||||
key={printer.id}
|
||||
printer={printer}
|
||||
hideIfDisconnected={hideDisconnected}
|
||||
maintenanceInfo={maintenanceByPrinter[printer.id]}
|
||||
viewMode={viewMode}
|
||||
cardSize={cardSize}
|
||||
amsThresholds={settings ? {
|
||||
humidityGood: Number(settings.ams_humidity_good) || 40,
|
||||
humidityFair: Number(settings.ams_humidity_fair) || 60,
|
||||
tempGood: Number(settings.ams_temp_good) || 28,
|
||||
tempFair: Number(settings.ams_temp_fair) || 35,
|
||||
} : undefined}
|
||||
spoolmanEnabled={spoolmanEnabled}
|
||||
hasUnlinkedSpools={hasUnlinkedSpools}
|
||||
linkedSpools={linkedSpools}
|
||||
spoolmanUrl={spoolmanStatus?.url}
|
||||
spoolmanSyncMode={spoolmanSyncMode}
|
||||
onGetAssignment={getAssignment}
|
||||
onUnassignSpool={(pid, aid, tid) => unassignMutation.mutate({ printerId: pid, amsId: aid, trayId: tid })}
|
||||
timeFormat={settings?.time_format || 'system'}
|
||||
cameraViewMode={settings?.camera_view_mode || 'window'}
|
||||
onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
|
||||
checkPrinterFirmware={settings?.check_printer_firmware !== false}
|
||||
dryingPresets={effectiveDryingPresets}
|
||||
requirePlateClear={settings?.require_plate_clear === true}
|
||||
selectionMode={selectionMode}
|
||||
isSelected={selectedPrinterIds.has(printer.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
/* Regular grid view */
|
||||
|
||||
Reference in New Issue
Block a user