mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-06 14:11:35 +02:00
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:
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user