v0.2.2.2 (#830)
This commit is contained in:
MartinNYHC
2026-03-27 09:27:47 +01:00
committed by GitHub
parent 8270030687
commit 67f91d94de
112 changed files with 18595 additions and 9993 deletions
@@ -256,6 +256,7 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
<button
key={spool.id}
onClick={() => setSelectedSpoolId(spool.id)}
title={spool.note || undefined}
className={`p-2.5 rounded-lg border text-left transition-colors ${
selectedSpoolId === spool.id
? 'bg-bambu-green/20 border-bambu-green'
@@ -161,6 +161,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
{provider.on_first_layer_complete && (
<span className="px-2 py-0.5 bg-emerald-600/20 text-emerald-300 text-xs rounded">{t('notifications.firstLayer')}</span>
)}
{provider.on_print_missing_spool_assignment && (
<span className="px-2 py-0.5 bg-amber-500/20 text-amber-300 text-xs rounded">{t('notifications.missingSpoolAssignmentLabel')}</span>
)}
{provider.quiet_hours_enabled && (
<span className="px-2 py-0.5 bg-indigo-500/20 text-indigo-400 text-xs rounded flex items-center gap-1">
<Moon className="w-3 h-3" />
@@ -296,6 +299,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">{t('notifications.missingSpoolAssignmentLabel')}</p>
<p className="text-xs text-bambu-gray">{t('notifications.missingSpoolAssignmentDescription')}</p>
</div>
<Toggle
checked={provider.on_print_missing_spool_assignment ?? false}
onChange={(checked) => updateMutation.mutate({ on_print_missing_spool_assignment: checked })}
/>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-white">{t('notifications.printFailed')}</p>
<Toggle
+19
View File
@@ -361,6 +361,25 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
</label>
</div>
{/* Auto Off Persistent */}
{plug.auto_off && (
<div className="flex items-center justify-between pl-4 border-l-2 border-bambu-dark-tertiary">
<div>
<p className="text-sm text-white">{t('smartPlugs.autoOffPersistent')}</p>
<p className="text-xs text-bambu-gray">{t('smartPlugs.autoOffPersistentDescription')}</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={plug.auto_off_persistent}
onChange={(e) => updateMutation.mutate({ auto_off_persistent: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
</div>
)}
{/* Delay Mode */}
{plug.auto_off && (
<div className="space-y-3 pl-4 border-l-2 border-bambu-dark-tertiary">
+56 -14
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useMemo } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Loader2, Save, Beaker, Palette, Zap, Tag } from 'lucide-react';
import { X, Loader2, Save, Beaker, Palette, Zap, Tag, Unlink } from 'lucide-react';
import { api } from '../api/client';
import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset } from '../api/client';
import { Button } from './Button';
@@ -24,9 +24,17 @@ interface SpoolFormModalProps {
spool?: InventorySpool | null;
printersWithCalibrations?: PrinterWithCalibrations[];
currencySymbol: string;
onSpoolsCreated?: (spools: InventorySpool[]) => void;
}
export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibrations = [], currencySymbol }: SpoolFormModalProps) {
export function SpoolFormModal({
isOpen,
onClose,
spool,
printersWithCalibrations = [],
currencySymbol,
onSpoolsCreated,
}: SpoolFormModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
@@ -317,6 +325,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
await saveKProfiles(newSpool.id);
}
await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
if (onSpoolsCreated) onSpoolsCreated([newSpool]);
showToast(t('inventory.spoolCreated'), 'success');
onClose();
},
@@ -335,6 +344,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
}
}
await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
if (onSpoolsCreated) onSpoolsCreated(newSpools);
showToast(t('inventory.spoolsCreated', { count: newSpools.length }), 'success');
onClose();
},
@@ -362,7 +372,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
const deleteTagMutation = useMutation({
mutationFn: () =>
api.updateSpool(spool!.id, { tag_uid: null } as Parameters<typeof api.updateSpool>[1]),
api.updateSpool(spool!.id, { tag_uid: null, tray_uuid: null, tag_type: null, data_origin: null } as Parameters<typeof api.updateSpool>[1]),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
showToast(t('inventory.tagDeleted', 'Tag removed'), 'success');
@@ -373,6 +383,29 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
},
});
// Fetch assignment for this spool (to show Unassign button)
const { data: assignments } = useQuery({
queryKey: ['spool-assignments'],
queryFn: () => api.getAssignments(),
enabled: isOpen && isEditing,
});
const spoolAssignment = spool ? assignments?.find(a => a.spool_id === spool.id) : undefined;
const unassignMutation = useMutation({
mutationFn: () => {
if (!spoolAssignment) throw new Error('No assignment');
return api.unassignSpool(spoolAssignment.printer_id, spoolAssignment.ams_id, spoolAssignment.tray_id);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
showToast(t('inventory.unassignSuccess', 'Spool unassigned'), 'success');
onClose();
},
onError: (error: Error) => {
showToast(error.message, 'error');
},
});
// Save K-profiles for selected calibrations
const saveKProfiles = async (spoolId: number) => {
if (selectedProfiles.size === 0) {
@@ -477,7 +510,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
}
};
const isPending = createMutation.isPending || bulkCreateMutation.isPending || updateMutation.isPending || deleteTagMutation.isPending;
const isPending = createMutation.isPending || bulkCreateMutation.isPending || updateMutation.isPending || deleteTagMutation.isPending || unassignMutation.isPending;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
@@ -636,15 +669,24 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
{/* Footer */}
<div className="flex gap-2 p-4 border-t border-bambu-dark-tertiary flex-shrink-0">
{isEditing && (
<Button
variant="secondary"
onClick={() => deleteTagMutation.mutate()}
disabled={isPending || !spool?.tag_uid}
className="mr-auto"
>
<Tag className="w-4 h-4" />
{t('inventory.deleteTag', 'Delete Tag')}
</Button>
<div className="flex gap-2 mr-auto">
<Button
variant="secondary"
onClick={() => deleteTagMutation.mutate()}
disabled={isPending || !spool?.tag_uid}
>
<Tag className="w-4 h-4" />
{t('inventory.deleteTag', 'Delete Tag')}
</Button>
<Button
variant="secondary"
onClick={() => unassignMutation.mutate()}
disabled={isPending || !spoolAssignment}
>
<Unlink className="w-4 h-4" />
{t('inventory.unassignSpool', 'Unassign')}
</Button>
</div>
)}
<div className="flex gap-2 ml-auto">
<Button variant="secondary" onClick={onClose}>
+11 -5
View File
@@ -3,7 +3,7 @@ import Keyboard from 'react-simple-keyboard';
import 'react-simple-keyboard/build/css/index.css';
import './VirtualKeyboard.css';
const FOCUSABLE_TYPES = new Set(['text', 'password', 'email', 'search', 'url']);
const FOCUSABLE_TYPES = new Set(['text', 'password', 'email', 'search', 'url', 'number']);
/**
* Set value on a controlled React input using the native setter,
@@ -17,7 +17,7 @@ function setNativeValue(input: HTMLInputElement | HTMLTextAreaElement, value: st
input.dispatchEvent(new Event('input', { bubbles: true }));
}
export function VirtualKeyboard() {
export function VirtualKeyboard({ onVisibilityChange }: { onVisibilityChange?: (visible: boolean) => void }) {
const [visible, setVisible] = useState(false);
const [closing, setClosing] = useState(false);
const closingRef = useRef(false);
@@ -26,6 +26,11 @@ export function VirtualKeyboard() {
const keyboardRef = useRef<ReturnType<typeof Keyboard> | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Notify parent when keyboard visibility changes
useEffect(() => {
onVisibilityChange?.(visible);
}, [visible, onVisibilityChange]);
const handleFocusIn = useCallback((e: FocusEvent) => {
if (closingRef.current) return;
const target = e.target as HTMLElement;
@@ -47,9 +52,10 @@ export function VirtualKeyboard() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(keyboardRef.current as any)?.setInput?.(activeInput.current.value);
// Scroll input into view above the keyboard
// Scroll focused input into view after the keyboard renders and layout reflows
setTimeout(() => {
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
const card = target.closest('.bg-zinc-800, .rounded-lg, [data-vkb-group]') as HTMLElement | null;
(card ?? target).scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 100);
}, []);
@@ -142,7 +148,7 @@ export function VirtualKeyboard() {
{!closing && (
<div
ref={containerRef}
className="fixed bottom-0 left-0 right-0 z-[9999]"
className="relative z-[9999] shrink-0"
onMouseDown={(e) => e.preventDefault()}
onTouchStart={(e) => {
// Prevent focus loss but allow button interaction
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { X, Loader2, CheckCircle, XCircle, Layers } from 'lucide-react';
import { api, type InventorySpool, type PrinterStatus, type AMSTray } from '../../api/client';
import { ConfirmModal } from '../ConfirmModal';
import { AmsUnitCard, NozzleBadge } from './AmsUnitCard';
import type { AmsThresholds } from './AmsUnitCard';
import { getFillBarColor } from '../../utils/amsHelpers';
@@ -22,6 +23,34 @@ function trayColorToCSS(color: string | null): string {
return `#${color.slice(0, 6)}`;
}
// --- Material/profile mismatch helpers (pure functions, no component state) ---
const normalizeValue = (value: string | undefined | null) =>
(value ?? '').trim().toUpperCase();
function checkMaterialMatch(
spoolMaterial: string | undefined | null,
trayMaterial: string | undefined | null
): 'exact' | 'partial' | 'none' {
const normalizedSpool = normalizeValue(spoolMaterial);
const normalizedTray = normalizeValue(trayMaterial);
if (!normalizedSpool || !normalizedTray) return 'none';
if (normalizedSpool === normalizedTray) return 'exact';
if (normalizedTray.includes(normalizedSpool) || normalizedSpool.includes(normalizedTray)) {
return 'partial';
}
return 'none';
}
function checkProfileMatch(
spoolProfile: string | undefined | null,
trayProfile: string | undefined | null
): boolean {
const normalizedSpoolProfile = normalizeValue(spoolProfile);
const normalizedTrayProfile = normalizeValue(trayProfile);
if (!normalizedSpoolProfile || !normalizedTrayProfile) return false;
return normalizedSpoolProfile === normalizedTrayProfile;
}
interface AssignToAmsModalProps {
isOpen: boolean;
onClose: () => void;
@@ -34,11 +63,24 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
const queryClient = useQueryClient();
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [statusType, setStatusType] = useState<'info' | 'success' | 'error' | null>(null);
const [showMismatchConfirm, setShowMismatchConfirm] = useState(false);
const [mismatchDetails, setMismatchDetails] = useState<{
type: 'material' | 'partial' | 'profile' | 'material_profile' | 'partial_profile';
spoolMaterial: string;
trayMaterial: string;
spoolProfile?: string;
trayProfile?: string;
location: string;
} | null>(null);
const [pendingSlot, setPendingSlot] = useState<{ amsId: number; trayId: number } | null>(null);
useEffect(() => {
if (isOpen) {
setStatusMessage(null);
setStatusType(null);
setShowMismatchConfirm(false);
setMismatchDetails(null);
setPendingSlot(null);
}
}, [isOpen]);
@@ -155,12 +197,78 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
const isWaiting = configureMutation.isPending;
const handleSlotClick = useCallback((amsId: number, trayId: number) => {
if (isWaiting) return;
const getTrayForSlot = useCallback((amsId: number, trayId: number): AMSTray | null => {
if (amsId === 254 || amsId === 255) {
const extTrayId = amsId === 254 ? 254 : 254 + trayId;
return vtTrays.find(t => (t.id ?? 254) === extTrayId) || null;
}
const unit = amsUnits.find(u => u.id === amsId);
return unit?.tray?.find(t => t.id === trayId) || null;
}, [amsUnits, vtTrays]);
const getSlotLocationLabel = useCallback((amsId: number, trayId: number): string => {
if (amsId <= 3) return `${getAmsName(amsId)} ${t('ams.slot', 'Slot')} ${trayId + 1}`;
if (amsId >= 128 && amsId <= 135) return getAmsName(amsId);
if (amsId === 254) return t('printers.extL', 'Ext-L');
return isDualNozzle ? t('printers.extR', 'Ext-R') : t('printers.ext', 'Ext');
}, [t, isDualNozzle]);
const doAssign = useCallback((amsId: number, trayId: number) => {
setStatusType('info');
setStatusMessage(t('spoolbuddy.modal.assigning', 'Configuring slot...'));
configureMutation.mutate({ amsId, trayId });
}, [isWaiting, configureMutation, t]);
}, [configureMutation, t]);
const handleSlotClick = useCallback((amsId: number, trayId: number) => {
if (isWaiting) return;
if (!settings?.disable_filament_warnings) {
const tray = getTrayForSlot(amsId, trayId);
if (tray && !isTrayEmpty(tray)) {
const trayMaterial = tray.tray_sub_brands || tray.tray_type || '';
const materialMatchResult = checkMaterialMatch(spool.material, trayMaterial);
const spoolProfile = spool.slicer_filament_name || spool.slicer_filament;
const trayProfile = tray.tray_type || '';
const profileMatches = checkProfileMatch(spoolProfile, trayProfile);
if (materialMatchResult !== 'exact' || !profileMatches) {
let mismatchType: 'material' | 'partial' | 'profile' | 'material_profile' | 'partial_profile' = 'profile';
if (materialMatchResult === 'none' && !profileMatches) {
mismatchType = 'material_profile';
} else if (materialMatchResult === 'partial' && !profileMatches) {
mismatchType = 'partial_profile';
} else if (materialMatchResult === 'none') {
mismatchType = 'material';
} else if (materialMatchResult === 'partial') {
mismatchType = 'partial';
}
const location = getSlotLocationLabel(amsId, trayId);
setPendingSlot({ amsId, trayId });
setMismatchDetails({
type: mismatchType,
spoolMaterial: spool.material || '',
trayMaterial: trayMaterial || '',
spoolProfile: spoolProfile || undefined,
trayProfile: trayProfile || undefined,
location,
});
setShowMismatchConfirm(true);
return;
}
}
}
doAssign(amsId, trayId);
}, [isWaiting, settings?.disable_filament_warnings, spool, getTrayForSlot, getSlotLocationLabel, doAssign]);
const handleConfirmMismatch = useCallback(() => {
if (!pendingSlot) return;
setShowMismatchConfirm(false);
setMismatchDetails(null);
doAssign(pendingSlot.amsId, pendingSlot.trayId);
setPendingSlot(null);
}, [pendingSlot, doAssign]);
// Build single-slot items (HT + External)
const singleSlots = useMemo(() => {
@@ -213,6 +321,7 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';
return (
<>
<div className="fixed inset-0 z-[60] bg-bambu-dark flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-5 py-3 border-b border-zinc-800 shrink-0">
@@ -255,7 +364,7 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
)}
{/* AMS slots */}
<div className="flex-1 flex flex-col gap-3 p-4 min-h-0">
<div className="flex-1 flex flex-col gap-3 p-4 min-h-0 overflow-y-auto">
{!isConnected && printerId ? (
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-white/50">
@@ -358,5 +467,68 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
</button>
</div>
</div>
{showMismatchConfirm && mismatchDetails && (() => {
let message = '';
if (mismatchDetails.type === 'material') {
message = t('inventory.assignMismatchMessage', {
spoolMaterial: mismatchDetails.spoolMaterial,
trayMaterial: mismatchDetails.trayMaterial,
location: mismatchDetails.location,
});
} else if (mismatchDetails.type === 'partial') {
message = t('inventory.assignPartialMismatchMessage', {
spoolMaterial: mismatchDetails.spoolMaterial,
trayMaterial: mismatchDetails.trayMaterial,
location: mismatchDetails.location,
});
} else if (mismatchDetails.type === 'material_profile') {
message = `${t('inventory.assignMismatchMessage', {
spoolMaterial: mismatchDetails.spoolMaterial,
trayMaterial: mismatchDetails.trayMaterial,
location: mismatchDetails.location,
})}\n\n${t('inventory.assignProfileMismatchMessage', {
spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'),
trayProfile: mismatchDetails.trayProfile || t('common.unknown'),
location: mismatchDetails.location,
})}`;
} else if (mismatchDetails.type === 'partial_profile') {
message = `${t('inventory.assignPartialMismatchMessage', {
spoolMaterial: mismatchDetails.spoolMaterial,
trayMaterial: mismatchDetails.trayMaterial,
location: mismatchDetails.location,
})}\n\n${t('inventory.assignProfileMismatchMessage', {
spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'),
trayProfile: mismatchDetails.trayProfile || t('common.unknown'),
location: mismatchDetails.location,
})}`;
} else if (mismatchDetails.type === 'profile') {
message = t('inventory.assignProfileMismatchMessage', {
spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'),
trayProfile: mismatchDetails.trayProfile || t('common.unknown'),
location: mismatchDetails.location,
});
}
return (
<ConfirmModal
title={t('inventory.assignMismatchTitle')}
message={message}
confirmText={t('inventory.assignMismatchConfirm')}
variant="warning"
isLoading={configureMutation.isPending}
onConfirm={handleConfirmMismatch}
onCancel={() => {
if (!configureMutation.isPending) {
setShowMismatchConfirm(false);
setPendingSlot(null);
setMismatchDetails(null);
}
}}
/>
);
})()}
</>
);
}
@@ -0,0 +1,172 @@
import { useState, useEffect, useCallback } from 'react';
import { X, Play, RotateCw } from 'lucide-react';
import { spoolbuddyApi } from '../../api/client';
import { useTranslation } from 'react-i18next';
interface DiagnosticModalProps {
type: 'scale' | 'nfc' | 'read_tag';
deviceId: string;
onClose: () => void;
}
export function DiagnosticModal({ type, deviceId, onClose }: DiagnosticModalProps) {
const { t } = useTranslation();
const [isRunning, setIsRunning] = useState(false);
const [output, setOutput] = useState<string>('');
const [error, setError] = useState<string>('');
const [hasRun, setHasRun] = useState(false);
// Close on Escape
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !isRunning) {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isRunning, onClose]);
const runDiagnostic = useCallback(async () => {
setIsRunning(true);
setOutput('');
setError('');
setHasRun(true);
try {
// Step 1: Queue the diagnostic on the device
setOutput(t('spoolbuddy.diagnostic.queuing', 'Queuing diagnostic on device...\n'));
await spoolbuddyApi.queueDiagnostics(deviceId, type);
// Step 2: Poll for results with timeout
let result = null;
const maxRetries = 60; // 30s timeout with 500ms polling
let retryCount = 0;
while (retryCount < maxRetries && !result) {
// Wait a bit before polling
await new Promise(resolve => setTimeout(resolve, 500));
try {
result = await spoolbuddyApi.getDiagnosticResult(deviceId, type);
break;
} catch {
// Not ready yet, continue polling
retryCount++;
if (retryCount % 4 === 0) {
// Update every 2 seconds (after 4 retries of 500ms)
setOutput(prev => prev + '.');
}
}
}
if (!result) {
throw new Error('Diagnostic timed out - device did not report results');
}
setOutput(result.output);
if (!result.success) {
setError(`Exit code: ${result.exit_code}`);
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Unknown error');
setOutput('');
} finally {
setIsRunning(false);
}
}, [type, deviceId, t]);
const title = type === 'scale'
? t('spoolbuddy.diagnostic.scaleTitle', 'Scale Diagnostic')
: type === 'read_tag'
? t('spoolbuddy.diagnostic.readTagTitle', 'Read Tag Diagnostic')
: t('spoolbuddy.diagnostic.nfcTitle', 'NFC Reader Diagnostic');
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 animate-fade-in"
onClick={onClose}
>
<div
className="bg-zinc-800 rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[80vh] flex flex-col animate-slide-up"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex justify-between items-center p-4 border-b border-zinc-700">
<h2 className="text-lg font-semibold text-white">{title}</h2>
<button
onClick={onClose}
className="text-zinc-400 hover:text-white transition-colors"
aria-label="Close"
>
<X size={20} />
</button>
</div>
<div className="flex-1 overflow-auto p-4 bg-black/50 font-mono text-sm">
{isRunning ? (
<div className="flex items-center gap-2 text-green-400">
<div className="animate-spin w-4 h-4 border-2 border-green-400 border-t-transparent rounded-full" />
<span>{t('spoolbuddy.diagnostic.running', 'Running diagnostic on device...')}</span>
</div>
) : output ? (
<>
<div className="text-green-400 whitespace-pre-wrap break-words">
{output}
</div>
{error && (
<div className="text-red-400 mt-2">
❌ {error}
</div>
)}
</>
) : hasRun ? (
<div>
{error ? (
<div className="text-red-400">ERROR: {error}</div>
) : (
<span className="text-green-400">{t('spoolbuddy.diagnostic.completed', 'Diagnostic completed successfully.')}</span>
)}
</div>
) : (
<div className="text-zinc-500">
{t('spoolbuddy.diagnostic.clickStart', 'Click "Run Diagnostic" to start the hardware diagnostic on')} {deviceId}.
</div>
)}
</div>
{/* Footer */}
<div className="flex gap-2 p-4 border-t border-zinc-700 bg-zinc-800">
<button
onClick={runDiagnostic}
disabled={isRunning}
className="flex-1 flex items-center justify-center gap-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed px-4 py-2 rounded font-semibold text-white transition-colors"
>
{isRunning ? (
<>
<div className="animate-spin w-4 h-4 border-2 border-white border-t-transparent rounded-full" />
{t('spoolbuddy.diagnostic.runningBtn', 'Running...')}
</>
) : hasRun ? (
<>
<RotateCw size={16} />
{t('spoolbuddy.diagnostic.runAgain', 'Run Again')}
</>
) : (
<>
<Play size={16} />
{t('spoolbuddy.diagnostic.runBtn', 'Run Diagnostic')}
</>
)}
</button>
<button
onClick={onClose}
className="px-4 py-2 rounded bg-zinc-700 hover:bg-zinc-600 text-white font-semibold transition-colors"
>
{t('spoolbuddy.diagnostic.close', 'Close')}
</button>
</div>
</div>
</div>
);
}
@@ -33,6 +33,16 @@ const navItems = [
</svg>
),
},
{
to: '/spoolbuddy/inventory',
labelKey: 'spoolbuddy.nav.inventory',
fallback: 'Inventory',
icon: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
),
},
{
to: '/spoolbuddy/settings',
labelKey: 'spoolbuddy.nav.settings',
@@ -1,12 +1,12 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { Outlet } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useQuery, useQueries } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { SpoolBuddyTopBar } from './SpoolBuddyTopBar';
import { SpoolBuddyBottomNav } from './SpoolBuddyBottomNav';
import { SpoolBuddyStatusBar } from './SpoolBuddyStatusBar';
import { useSpoolBuddyState } from '../../hooks/useSpoolBuddyState';
import { api, spoolbuddyApi } from '../../api/client';
import { api, spoolbuddyApi, type Printer } from '../../api/client';
import { VirtualKeyboard } from '../VirtualKeyboard';
export function SpoolBuddyLayout() {
@@ -17,6 +17,8 @@ export function SpoolBuddyLayout() {
const [displayBlankTimeout, setDisplayBlankTimeout] = useState(0);
const lastActivityRef = useRef(Date.now());
const { i18n } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const sbState = useSpoolBuddyState();
// Sync language from backend settings (kiosk has its own browser with empty localStorage)
@@ -37,6 +39,11 @@ export function SpoolBuddyLayout() {
refetchInterval: 30000,
});
const device = devices[0];
const effectiveDeviceOnline = sbState.deviceOnline || Boolean(device?.online);
const sbStateForUi = useMemo(
() => ({ ...sbState, deviceOnline: effectiveDeviceOnline }),
[sbState, effectiveDeviceOnline]
);
// Sync display settings from device on initial load
const initializedRef = useRef(false);
@@ -61,22 +68,22 @@ export function SpoolBuddyLayout() {
// Auto-check for SpoolBuddy daemon updates
const { data: updateCheck } = useQuery({
queryKey: ['spoolbuddy-update-check', device?.device_id],
queryFn: () => device ? spoolbuddyApi.checkDaemonUpdate(device.device_id, true) : Promise.resolve(null),
queryFn: () => device ? spoolbuddyApi.checkDaemonUpdate(device.device_id) : Promise.resolve(null),
enabled: !!device,
refetchInterval: 5 * 60 * 1000, // re-check every 5 minutes
staleTime: 4 * 60 * 1000,
staleTime: 0,
});
// Update alert based on device state and available updates
useEffect(() => {
if (!sbState.deviceOnline) {
if (!effectiveDeviceOnline) {
setAlert({ type: 'warning', message: 'SpoolBuddy device disconnected' });
} else if (updateCheck?.update_available && updateCheck.latest_version) {
setAlert({ type: 'info', message: `Update available: v${updateCheck.latest_version}` });
} else {
setAlert(null);
}
}, [sbState.deviceOnline, updateCheck]);
}, [effectiveDeviceOnline, updateCheck?.update_available, updateCheck?.latest_version]);
// Track user activity for screen blank
const resetActivity = useCallback(() => {
@@ -93,6 +100,19 @@ export function SpoolBuddyLayout() {
};
}, [resetActivity]);
// Auto-navigate to dashboard when a NEW tag is detected (transition from no-tag to tag)
const tagDetected = Boolean(sbState.matchedSpool || sbState.unknownTagUid);
const prevTagDetected = useRef(false);
useEffect(() => {
if (tagDetected && !prevTagDetected.current) {
resetActivity();
if (location.pathname !== '/spoolbuddy') {
navigate('/spoolbuddy');
}
}
prevTagDetected.current = tagDetected;
}, [tagDetected, location.pathname, navigate, resetActivity]);
// Screen blank timer
useEffect(() => {
if (displayBlankTimeout <= 0) return;
@@ -104,6 +124,65 @@ export function SpoolBuddyLayout() {
return () => clearInterval(interval);
}, [displayBlankTimeout]);
// Online printers list for swipe-to-switch
const { data: printers = [] } = useQuery({
queryKey: ['printers'],
queryFn: () => api.getPrinters(),
});
const statusQueries = useQueries({
queries: printers.map((printer: Printer) => ({
queryKey: ['printerStatus', printer.id],
queryFn: () => api.getPrinterStatus(printer.id),
refetchInterval: 10000,
})),
});
const onlinePrinters = useMemo(() => {
return printers.filter((_: Printer, i: number) => statusQueries[i]?.data?.connected);
}, [printers, statusQueries]);
// Swipe left/right to cycle through online printers
const touchStartRef = useRef<{ x: number; y: number } | null>(null);
const swipeLockedRef = useRef(false);
const SWIPE_THRESHOLD = 50;
const rootRef = useRef<HTMLDivElement>(null);
const handleTouchStart = useCallback((e: React.TouchEvent) => {
touchStartRef.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
swipeLockedRef.current = false;
}, []);
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
if (!touchStartRef.current || onlinePrinters.length < 2) return;
const dx = e.changedTouches[0].clientX - touchStartRef.current.x;
const dy = e.changedTouches[0].clientY - touchStartRef.current.y;
touchStartRef.current = null;
swipeLockedRef.current = false;
if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dy) > Math.abs(dx)) return;
const currentIdx = onlinePrinters.findIndex((p: Printer) => p.id === selectedPrinterId);
const nextIdx = dx < 0
? (currentIdx + 1) % onlinePrinters.length // swipe left → next
: (currentIdx - 1 + onlinePrinters.length) % onlinePrinters.length; // swipe right → prev
setSelectedPrinterId(onlinePrinters[nextIdx].id);
}, [onlinePrinters, selectedPrinterId, setSelectedPrinterId]);
// Block browser back/forward swipe gesture with non-passive touchmove listener
useEffect(() => {
const el = rootRef.current;
if (!el) return;
const onTouchMove = (e: TouchEvent) => {
if (!touchStartRef.current) return;
const dx = Math.abs(e.touches[0].clientX - touchStartRef.current.x);
const dy = Math.abs(e.touches[0].clientY - touchStartRef.current.y);
// Once locked as horizontal, prevent default for the rest of this gesture
if (swipeLockedRef.current) { e.preventDefault(); return; }
if (dx > 10 && dx > dy) { swipeLockedRef.current = true; e.preventDefault(); }
};
el.addEventListener('touchmove', onTouchMove, { passive: false });
return () => el.removeEventListener('touchmove', onTouchMove);
}, []);
// Track virtual keyboard visibility to hide bottom bars
const [keyboardVisible, setKeyboardVisible] = useState(false);
// CSS brightness filter (software dimming)
const brightnessStyle = displayBrightness < 100
? { filter: `brightness(${displayBrightness / 100})` } as const
@@ -112,26 +191,30 @@ export function SpoolBuddyLayout() {
return (
<>
<div
ref={rootRef}
data-spoolbuddy-kiosk
className="w-screen h-screen bg-bambu-dark text-white flex flex-col overflow-hidden"
style={brightnessStyle}
style={{ ...brightnessStyle, overscrollBehaviorX: 'none' }}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
<SpoolBuddyTopBar
selectedPrinterId={selectedPrinterId}
onPrinterChange={setSelectedPrinterId}
deviceOnline={sbState.deviceOnline}
deviceOnline={effectiveDeviceOnline}
/>
<main className="flex-1 overflow-y-auto">
<Outlet context={{
selectedPrinterId, setSelectedPrinterId, sbState, setAlert,
selectedPrinterId, setSelectedPrinterId, sbState: sbStateForUi, setAlert,
displayBrightness, setDisplayBrightness,
displayBlankTimeout, setDisplayBlankTimeout,
}} />
</main>
<SpoolBuddyStatusBar alert={alert} />
<SpoolBuddyBottomNav />
<VirtualKeyboard />
{!keyboardVisible && <SpoolBuddyStatusBar alert={alert} />}
{!keyboardVisible && <SpoolBuddyBottomNav />}
<VirtualKeyboard onVisibilityChange={setKeyboardVisible} />
</div>
{/* Screen blank overlay — touch to wake */}