Add slot action picker to SpoolBuddy AMS page

Clicking an AMS slot now shows a picker with options: Configure AMS
  Slot, Assign Spool (inventory) or Link to Spoolman, and Unassign/
  Unlink when a mapping exists. Reuses the existing AssignSpoolModal
  and LinkSpoolModal components from the main Bambuddy frontend.
This commit is contained in:
maziggy
2026-03-25 13:53:24 +01:00
parent c1c31d8d91
commit c822969df9
5 changed files with 755 additions and 470 deletions
+1
View File
@@ -11,6 +11,7 @@ All notable changes to Bambuddy will be documented in this file.
- **External Folder Mounting for File Manager** ([#124](https://github.com/maziggy/bambuddy/issues/124)) — Host directories (NAS shares, USB drives, network storage) can now be mounted into the File Manager without copying files. Click "Link External" to point at a Docker bind-mounted path. Files are indexed into the database on scan but accessed directly from their original location — nothing is copied. Supports read-only mode (default, blocks uploads/moves/deletes), hidden file filtering, and automatic thumbnail extraction for 3MF, STL, gcode, and image files. External folders show a distinct icon and info bar with a rescan button. Deleting an external folder only removes the database index, never the actual files. Requested by @S1N4X.
### Improved
- **SpoolBuddy AMS Slot Action Picker** — Clicking an AMS slot on the SpoolBuddy AMS page now shows a picker with contextual actions: Configure AMS Slot (set filament preset, K-profile, color), Assign Spool / Link to Spoolman (depending on which system is active), and Unassign / Unlink when a spool is already mapped. Previously the slot click went straight to the configure modal with no way to assign inventory spools or link Spoolman spools.
- **SpoolBuddy Settings Device Tab No Longer Scrolls** — Removed the branding card, folded Device ID into the Device Info card, placed Backend/Auth config and diagnostic buttons side by side in a 2-column layout, removed the redundant online/offline status row from Device Info, and tightened spacing throughout. The Device tab now fits on the small SpoolBuddy touchscreen without scrolling.
- **Spool Notes in Assign Spool Modal** ([#793](https://github.com/maziggy/bambuddy/issues/793)) — Spool cards in the Assign Spool modal now show the spool's note as a hover tooltip, making it easier to identify spools by tracking IDs or other metadata stored in notes. Works with both internal inventory and Spoolman-synced spools. Requested by @LegionCanadian.
- **WiFi Safeguard for SpoolBuddy Pi** — The install script now drops an APT hook (`/etc/apt/apt.conf.d/80-preserve-wifi`) that backs up NetworkManager WiFi connections before every `apt upgrade` and restores them if they get wiped. Prevents headless SpoolBuddy Pis from losing WiFi connectivity after Raspberry Pi OS package upgrades (observed with Bookworm kernel/raspi-config updates that clear `/etc/NetworkManager/system-connections/`).
@@ -1,15 +1,18 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useOutletContext } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Layers } from 'lucide-react';
import { Layers, Settings2, Package, Unlink, Link2, X } from 'lucide-react';
import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
import { api } from '../../api/client';
import type { PrinterStatus, AMSTray } from '../../api/client';
import type { PrinterStatus, AMSTray, SpoolAssignment } from '../../api/client';
import { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag } from '../../utils/amsHelpers';
import { AmsUnitCard, HumidityIndicator, TemperatureIndicator, NozzleBadge } from '../../components/spoolbuddy/AmsUnitCard';
import type { AmsThresholds } from '../../components/spoolbuddy/AmsUnitCard';
import { ConfigureAmsSlotModal } from '../../components/ConfigureAmsSlotModal';
import { AssignSpoolModal } from '../../components/AssignSpoolModal';
import { LinkSpoolModal } from '../../components/LinkSpoolModal';
import { useToast } from '../../contexts/ToastContext';
function getAmsName(amsId: number): string {
if (amsId <= 3) return `AMS ${String.fromCharCode(65 + amsId)}`;
@@ -43,6 +46,7 @@ export function SpoolBuddyAmsPage() {
const { selectedPrinterId, setAlert } = useOutletContext<SpoolBuddyOutletContext>();
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const { data: status } = useQuery<PrinterStatus>({
queryKey: ['printerStatus', selectedPrinterId],
@@ -205,6 +209,72 @@ export function SpoolBuddyAmsPage() {
savedPresetId?: string;
} | null>(null);
// Slot action picker: shown before opening configure or assign modal
const [slotActionPicker, setSlotActionPicker] = useState<{
amsId: number;
trayId: number;
trayCount: number;
tray: AMSTray | null;
trayType?: string;
trayColor?: string;
traySubBrands?: string;
trayInfoIdx?: string;
extruderId?: number;
caliIdx?: number | null;
savedPresetId?: string;
location: string;
} | null>(null);
// Assign spool modal state (inventory)
const [assignSpoolModal, setAssignSpoolModal] = useState<{
printerId: number;
amsId: number;
trayId: number;
trayInfo: { type: string; material?: string; profile?: string; color: string; location: string };
} | null>(null);
// Link spool modal state (Spoolman)
const [linkSpoolModal, setLinkSpoolModal] = useState<{
tagUid: string;
trayUuid: string;
printerId: number;
amsId: number;
trayId: number;
} | null>(null);
const getAssignment = useCallback((amsId: number, trayId: number): SpoolAssignment | undefined => {
return assignments?.find(a => a.ams_id === Number(amsId) && a.tray_id === Number(trayId));
}, [assignments]);
const getLinkedSpool = useCallback((amsId: number, trayId: number, tray: AMSTray | null) => {
if (!linkedSpools || !printerSerial) return undefined;
const tag = (tray?.tray_uuid || tray?.tag_uid || getFallbackSpoolTag(printerSerial, amsId, trayId))?.toUpperCase();
return tag ? linkedSpools[tag] : undefined;
}, [linkedSpools, printerSerial]);
const unassignMutation = useMutation({
mutationFn: ({ printerId, amsId, trayId }: { printerId: number; amsId: number; trayId: number }) =>
api.unassignSpool(printerId, amsId, trayId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['spool-assignments', selectedPrinterId] });
showToast(t('inventory.unassignSuccess', 'Spool unassigned'), 'success');
setSlotActionPicker(null);
},
});
const unlinkSpoolMutation = useMutation({
mutationFn: (spoolId: number) => api.unlinkSpool(spoolId),
onSuccess: (result) => {
showToast(t('spoolman.unlinkSuccess') || result?.message, 'success');
queryClient.invalidateQueries({ queryKey: ['linked-spools'] });
queryClient.invalidateQueries({ queryKey: ['unlinked-spools'] });
setSlotActionPicker(null);
},
onError: (error: Error) => {
showToast(error.message || t('spoolman.unlinkFailed'), 'error');
},
});
const getActiveSlotForAms = useCallback((amsId: number): number | null => {
if (effectiveTrayNow === undefined) return null;
if (amsId <= 3) {
@@ -224,10 +294,11 @@ export function SpoolBuddyAmsPage() {
const mappedExtruderId = amsExtruderMap[String(amsId)];
const normalizedId = amsId >= 128 ? amsId - 128 : amsId;
const extruderId = mappedExtruderId !== undefined ? mappedExtruderId : normalizedId;
setConfigureSlotModal({
const slotData = {
amsId,
trayId,
trayCount: tray ? (amsId >= 128 ? 1 : 4) : 4,
tray,
trayType: tray?.tray_type || undefined,
trayColor: tray?.tray_color || undefined,
traySubBrands: tray?.tray_sub_brands || undefined,
@@ -235,17 +306,21 @@ export function SpoolBuddyAmsPage() {
extruderId: isDualNozzle ? extruderId : undefined,
caliIdx: tray?.cali_idx,
savedPresetId: slotPreset?.preset_id,
});
location: `${getAmsName(amsId)} Slot ${trayId + 1}`,
};
setSlotActionPicker(slotData);
}, [slotPresets, amsExtruderMap, isDualNozzle]);
const handleExtSlotClick = useCallback((extTray: AMSTray) => {
const extTrayId = extTray.id ?? 254;
const slotTrayId = extTrayId - 254;
const extSlotPreset = slotPresets?.[255 * 4 + slotTrayId];
setConfigureSlotModal({
const slotData = {
amsId: 255,
trayId: slotTrayId,
trayCount: 1,
tray: isTrayEmpty(extTray) ? null : extTray,
trayType: extTray.tray_type || undefined,
trayColor: extTray.tray_color || undefined,
traySubBrands: extTray.tray_sub_brands || undefined,
@@ -253,9 +328,58 @@ export function SpoolBuddyAmsPage() {
extruderId: isDualNozzle ? (extTrayId === 254 ? 1 : 0) : undefined,
caliIdx: extTray.cali_idx,
savedPresetId: extSlotPreset?.preset_id,
});
location: isDualNozzle
? (extTrayId === 254 ? 'Ext-L' : 'Ext-R')
: 'External',
};
setSlotActionPicker(slotData);
}, [slotPresets, isDualNozzle]);
const openConfigureFromPicker = useCallback(() => {
if (!slotActionPicker) return;
const { tray: _t, location: _l, ...configData } = slotActionPicker;
setSlotActionPicker(null);
setConfigureSlotModal(configData);
}, [slotActionPicker]);
const openAssignFromPicker = useCallback(() => {
if (!slotActionPicker || !selectedPrinterId) return;
const { amsId, trayId, trayType, trayColor, location } = slotActionPicker;
setSlotActionPicker(null);
setAssignSpoolModal({
printerId: selectedPrinterId,
amsId,
trayId,
trayInfo: {
type: trayType || '',
material: trayType,
color: trayColor?.slice(0, 6) || '',
location,
},
});
}, [slotActionPicker, selectedPrinterId]);
const openLinkFromPicker = useCallback(() => {
if (!slotActionPicker || !selectedPrinterId) return;
const { amsId, trayId, tray } = slotActionPicker;
const linkTag = (tray?.tray_uuid || tray?.tag_uid || getFallbackSpoolTag(printerSerial, amsId, trayId))?.toUpperCase() || '';
setSlotActionPicker(null);
setLinkSpoolModal({
tagUid: tray?.tag_uid || linkTag,
trayUuid: tray?.tray_uuid || '',
printerId: selectedPrinterId,
amsId,
trayId,
});
}, [slotActionPicker, selectedPrinterId, printerSerial]);
const handleUnassignFromPicker = useCallback(() => {
if (!slotActionPicker || !selectedPrinterId) return;
const { amsId, trayId } = slotActionPicker;
unassignMutation.mutate({ printerId: selectedPrinterId, amsId, trayId });
}, [slotActionPicker, selectedPrinterId, unassignMutation]);
// Set alert for low filament in status bar
useEffect(() => {
if (!isConnected && selectedPrinterId) {
@@ -480,6 +604,166 @@ export function SpoolBuddyAmsPage() {
}}
/>
)}
{/* Slot action picker */}
{slotActionPicker && selectedPrinterId && (() => {
const assignment = getAssignment(slotActionPicker.amsId, slotActionPicker.trayId);
const linked = getLinkedSpool(slotActionPicker.amsId, slotActionPicker.trayId, slotActionPicker.tray);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setSlotActionPicker(null)}
/>
<div className="relative w-full max-w-sm mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl">
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-2">
{slotActionPicker.trayColor && (
<span
className="w-4 h-4 rounded-full border border-black/20"
style={{ backgroundColor: `#${slotActionPicker.trayColor.slice(0, 6)}` }}
/>
)}
<h2 className="text-lg font-semibold text-white">{slotActionPicker.location}</h2>
{slotActionPicker.traySubBrands && (
<span className="text-sm text-bambu-gray">({slotActionPicker.traySubBrands})</span>
)}
</div>
<button
onClick={() => setSlotActionPicker(null)}
className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-4 space-y-2">
{/* Currently assigned/linked spool info */}
{!spoolmanEnabled && assignment?.spool && (
<div className="p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary mb-3">
<p className="text-xs text-bambu-gray mb-1">{t('inventory.assignedSpool', 'Assigned spool')}</p>
<div className="flex items-center gap-2">
{assignment.spool.rgba && (
<span
className="w-3 h-3 rounded-full border border-black/20 flex-shrink-0"
style={{ backgroundColor: `#${assignment.spool.rgba.substring(0, 6)}` }}
/>
)}
<span className="text-sm text-white">
{assignment.spool.brand ? `${assignment.spool.brand} ` : ''}{assignment.spool.material}
{assignment.spool.color_name ? ` - ${assignment.spool.color_name}` : ''}
</span>
</div>
</div>
)}
{spoolmanEnabled && linked && (
<div className="p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary mb-3">
<p className="text-xs text-bambu-gray mb-1">{t('spoolman.linkedSpool', 'Linked spool')}</p>
<div className="flex items-center gap-2">
<span className="text-sm text-white">
Spoolman #{linked.id}
{linked.remaining_weight != null ? ` (${Math.round(linked.remaining_weight)}g)` : ''}
</span>
</div>
</div>
)}
<button
onClick={openConfigureFromPicker}
className="w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-blue transition-colors text-left"
>
<Settings2 className="w-5 h-5 text-bambu-blue flex-shrink-0" />
<div>
<p className="text-white font-medium">{t('configureAmsSlot.title')}</p>
<p className="text-xs text-bambu-gray">{t('spoolbuddy.ams.configureDesc', 'Set filament preset, K-profile, and color')}</p>
</div>
</button>
{/* Inventory: Assign / Unassign */}
{!spoolmanEnabled && (
<button
onClick={openAssignFromPicker}
className="w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-green transition-colors text-left"
>
<Package className="w-5 h-5 text-bambu-green flex-shrink-0" />
<div>
<p className="text-white font-medium">{t('inventory.assignSpool')}</p>
<p className="text-xs text-bambu-gray">{t('spoolbuddy.ams.assignDesc', 'Track a spool from your inventory')}</p>
</div>
</button>
)}
{!spoolmanEnabled && assignment && (
<button
onClick={handleUnassignFromPicker}
disabled={unassignMutation.isPending}
className="w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-amber-500 transition-colors text-left"
>
<Unlink className="w-5 h-5 text-amber-400 flex-shrink-0" />
<div>
<p className="text-amber-400 font-medium">{t('inventory.unassignSpool', 'Unassign')}</p>
<p className="text-xs text-bambu-gray">{t('spoolbuddy.ams.unassignDesc', 'Remove inventory spool from this slot')}</p>
</div>
</button>
)}
{/* Spoolman: Link / Unlink */}
{spoolmanEnabled && (
<button
onClick={openLinkFromPicker}
className="w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-green transition-colors text-left"
>
<Link2 className="w-5 h-5 text-bambu-green flex-shrink-0" />
<div>
<p className="text-white font-medium">{t('spoolman.linkToSpoolman')}</p>
<p className="text-xs text-bambu-gray">{t('spoolbuddy.ams.linkDesc', 'Link a Spoolman spool to this slot')}</p>
</div>
</button>
)}
{spoolmanEnabled && linked?.id && (
<button
onClick={() => unlinkSpoolMutation.mutate(linked.id)}
disabled={unlinkSpoolMutation.isPending}
className="w-full flex items-center gap-3 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary hover:border-amber-500 transition-colors text-left"
>
<Unlink className="w-5 h-5 text-amber-400 flex-shrink-0" />
<div>
<p className="text-amber-400 font-medium">{t('spoolman.unlinkSpool')}</p>
<p className="text-xs text-bambu-gray">{t('spoolbuddy.ams.unlinkDesc', 'Remove Spoolman link from this slot')}</p>
</div>
</button>
)}
</div>
</div>
</div>
);
})()}
{/* Assign spool modal (inventory) */}
{assignSpoolModal && (
<AssignSpoolModal
isOpen={!!assignSpoolModal}
onClose={() => {
setAssignSpoolModal(null);
queryClient.invalidateQueries({ queryKey: ['spool-assignments', selectedPrinterId] });
}}
printerId={assignSpoolModal.printerId}
amsId={assignSpoolModal.amsId}
trayId={assignSpoolModal.trayId}
trayInfo={assignSpoolModal.trayInfo}
/>
)}
{/* Link spool modal (Spoolman) */}
{linkSpoolModal && (
<LinkSpoolModal
isOpen={!!linkSpoolModal}
onClose={() => setLinkSpoolModal(null)}
tagUid={linkSpoolModal.tagUid}
trayUuid={linkSpoolModal.trayUuid}
printerId={linkSpoolModal.printerId}
amsId={linkSpoolModal.amsId}
trayId={linkSpoolModal.trayId}
/>
)}
</div>
);
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -23,8 +23,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-Dw3sIiDq.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DTsgpL4e.css">
<script type="module" crossorigin src="/assets/index-Dh0_3dIz.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CZLTApPU.css">
</head>
<body>
<div id="root"></div>