mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 11:56:01 +02:00
Implemented dual-nozzle AMS wiring visualization
- Added AMSSectionDual component with CSS border-based wiring - Wiring connects AMS slots to hub to extruder with proper 90° corners - Supports future dynamic filament color display - Added TemperatureColumn with dual nozzle L/R display - Added JogPad, BedControls, ExtruderControls components - Added various control page icons 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { api } from '../../api/client';
|
||||
import type { PrinterStatus, AMSUnit } from '../../api/client';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface AMSSectionDualProps {
|
||||
printerId: number;
|
||||
status: PrinterStatus | null | undefined;
|
||||
nozzleCount: number;
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string | null): string {
|
||||
if (!hex) return 'rgb(128, 128, 128)';
|
||||
const cleanHex = hex.replace('#', '').substring(0, 6);
|
||||
const r = parseInt(cleanHex.substring(0, 2), 16) || 128;
|
||||
const g = parseInt(cleanHex.substring(2, 4), 16) || 128;
|
||||
const b = parseInt(cleanHex.substring(4, 6), 16) || 128;
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
function isLightColor(hex: string | null): boolean {
|
||||
if (!hex) return false;
|
||||
const cleanHex = hex.replace('#', '').substring(0, 6);
|
||||
const r = parseInt(cleanHex.substring(0, 2), 16) || 0;
|
||||
const g = parseInt(cleanHex.substring(2, 4), 16) || 0;
|
||||
const b = parseInt(cleanHex.substring(4, 6), 16) || 0;
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return luminance > 0.5;
|
||||
}
|
||||
|
||||
interface AMSPanelContentProps {
|
||||
units: AMSUnit[];
|
||||
side: 'left' | 'right';
|
||||
isPrinting: boolean;
|
||||
selectedAmsIndex: number;
|
||||
onSelectAms: (index: number) => void;
|
||||
selectedTray: number | null;
|
||||
onSelectTray: (trayId: number | null) => void;
|
||||
}
|
||||
|
||||
function AMSPanelContent({
|
||||
units,
|
||||
side,
|
||||
isPrinting,
|
||||
selectedAmsIndex,
|
||||
onSelectAms,
|
||||
selectedTray,
|
||||
onSelectTray,
|
||||
}: AMSPanelContentProps) {
|
||||
const selectedUnit = units[selectedAmsIndex];
|
||||
const slotPrefix = side === 'left' ? 'A' : 'B';
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0 overflow-visible">
|
||||
<div className="text-center text-[11px] font-semibold text-bambu-gray uppercase mb-2">
|
||||
{side === 'left' ? 'Left Nozzle' : 'Right Nozzle'}
|
||||
</div>
|
||||
|
||||
{/* AMS Tab Selectors - only show connected units */}
|
||||
<div className="flex gap-1.5 mb-2.5 p-1.5 bg-bambu-dark/50 rounded-lg w-fit">
|
||||
{units.map((unit, index) => (
|
||||
<button
|
||||
key={unit.id}
|
||||
onClick={() => onSelectAms(index)}
|
||||
className={`flex items-center p-1.5 rounded border-2 transition-colors bg-bambu-dark ${
|
||||
selectedAmsIndex === index
|
||||
? 'border-bambu-green'
|
||||
: 'border-transparent hover:border-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-0.5">
|
||||
{unit.tray.map((tray) => (
|
||||
<div
|
||||
key={tray.id}
|
||||
className="w-2.5 h-2.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: tray.tray_color ? hexToRgb(tray.tray_color) : '#808080',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* AMS Content */}
|
||||
{selectedUnit && (
|
||||
<div className="bg-bambu-dark-secondary rounded-[10px] p-2.5 pb-0 overflow-visible">
|
||||
{/* AMS Header - Humidity & Temp */}
|
||||
<div className="flex items-center gap-2.5 text-xs text-bambu-gray mb-2">
|
||||
{selectedUnit.humidity !== null && (
|
||||
<span className="flex items-center gap-1">
|
||||
<img src="/icons/water.svg" alt="" className="w-3.5 icon-theme" />
|
||||
{selectedUnit.humidity} %
|
||||
</span>
|
||||
)}
|
||||
{selectedUnit.temp !== null && (
|
||||
<span className="flex items-center gap-1">
|
||||
<img src="/icons/temperature.svg" alt="" className="w-3.5 icon-theme" />
|
||||
{selectedUnit.temp}°C
|
||||
</span>
|
||||
)}
|
||||
<span className="text-yellow-500 text-sm">☀</span>
|
||||
</div>
|
||||
|
||||
{/* Slot Labels */}
|
||||
<div className="flex justify-center gap-1.5 mb-1.5">
|
||||
{selectedUnit.tray.map((tray, index) => (
|
||||
<div
|
||||
key={tray.id}
|
||||
className="w-12 flex items-center justify-center gap-0.5 text-[10px] text-bambu-gray px-1.5 py-[3px] bg-bambu-dark rounded-full border border-bambu-dark-tertiary"
|
||||
>
|
||||
{slotPrefix}{index + 1}
|
||||
<img src="/icons/reload.svg" alt="" className="w-2.5 h-2.5 icon-theme" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* AMS Slots with integrated wiring */}
|
||||
<div className="flex justify-center gap-1.5 mb-0">
|
||||
{selectedUnit.tray.map((tray) => {
|
||||
const globalTrayId = selectedUnit.id * 4 + tray.id;
|
||||
const isSelected = selectedTray === globalTrayId;
|
||||
const isEmpty = !tray.tray_type || tray.tray_type === '' || tray.tray_type === 'NONE';
|
||||
const isLight = isLightColor(tray.tray_color);
|
||||
|
||||
return (
|
||||
<div key={tray.id} className="flex flex-col items-center">
|
||||
<button
|
||||
onClick={() => !isEmpty && onSelectTray(isSelected ? null : globalTrayId)}
|
||||
disabled={isEmpty || isPrinting}
|
||||
className={`w-12 h-[70px] rounded-md border-2 overflow-hidden transition-all bg-bambu-dark ${
|
||||
isSelected
|
||||
? 'border-[#d4a84b]'
|
||||
: 'border-bambu-dark-tertiary hover:border-bambu-gray'
|
||||
} ${isEmpty ? 'opacity-50' : ''} disabled:cursor-not-allowed`}
|
||||
>
|
||||
<div
|
||||
className="w-full h-full flex flex-col items-center justify-end pb-[5px]"
|
||||
style={{
|
||||
backgroundColor: isEmpty ? undefined : hexToRgb(tray.tray_color),
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`text-[11px] font-semibold mb-1 ${
|
||||
isLight ? 'text-gray-800' : 'text-white'
|
||||
} ${isLight ? '' : 'drop-shadow-sm'}`}
|
||||
>
|
||||
{isEmpty ? '--' : tray.tray_type}
|
||||
</span>
|
||||
{!isEmpty && (
|
||||
<img
|
||||
src="/icons/eye.svg"
|
||||
alt=""
|
||||
className={`w-3.5 h-3.5 ${isLight ? '' : 'invert'}`}
|
||||
style={{ opacity: 0.8 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{/* Vertical wire from slot center down */}
|
||||
<div className="w-[2px] h-[14px] bg-[#909090]" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Wiring visualization - horizontal bar and hub */}
|
||||
<div className="flex justify-center">
|
||||
<div className="relative h-[50px]" style={{ width: '210px' }}>
|
||||
{/* Horizontal bar connecting all slots (spans from first to last slot center) */}
|
||||
<div className="absolute left-[24px] right-[24px] top-0 border-t-2 border-[#909090]" />
|
||||
|
||||
{/* Center hub box on the horizontal bar */}
|
||||
<div className="absolute left-1/2 -translate-x-1/2 top-[-6px] w-[28px] h-[14px] bg-[#c0c0c0] border border-[#909090] rounded-sm" />
|
||||
|
||||
{/* Vertical wire from hub going down */}
|
||||
<div className="absolute left-1/2 -translate-x-[1px] top-[8px] h-[14px] border-l-2 border-[#909090]" />
|
||||
|
||||
{/* Horizontal wire from hub toward the center of the panel (extends beyond panel edge) */}
|
||||
{side === 'left' && (
|
||||
<div className="absolute left-1/2 top-[21px] w-[calc(50%+30px)] border-t-2 border-[#909090]" />
|
||||
)}
|
||||
{side === 'right' && (
|
||||
<div className="absolute right-1/2 top-[21px] w-[calc(50%+30px)] border-t-2 border-[#909090]" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No AMS message */}
|
||||
{units.length === 0 && (
|
||||
<div className="bg-bambu-dark-secondary rounded-[10px] p-6 text-center text-bambu-gray text-sm">
|
||||
No AMS connected to {side} nozzle
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AMSSectionDual({ printerId, status, nozzleCount }: AMSSectionDualProps) {
|
||||
const isConnected = status?.connected ?? false;
|
||||
const isPrinting = status?.state === 'RUNNING';
|
||||
const isDualNozzle = nozzleCount > 1;
|
||||
const amsUnits: AMSUnit[] = status?.ams ?? [];
|
||||
|
||||
// For dual nozzle, split AMS units between left and right
|
||||
// In real implementation, this would be based on actual nozzle assignment
|
||||
const leftUnits = isDualNozzle ? amsUnits.filter((_, i) => i % 2 === 0) : amsUnits;
|
||||
const rightUnits = isDualNozzle ? amsUnits.filter((_, i) => i % 2 === 1) : [];
|
||||
|
||||
const [leftAmsIndex, setLeftAmsIndex] = useState(0);
|
||||
const [rightAmsIndex, setRightAmsIndex] = useState(0);
|
||||
const [selectedTray, setSelectedTray] = useState<number | null>(null);
|
||||
|
||||
const loadMutation = useMutation({
|
||||
mutationFn: (trayId: number) => api.amsLoadFilament(printerId, trayId),
|
||||
});
|
||||
|
||||
const unloadMutation = useMutation({
|
||||
mutationFn: () => api.amsUnloadFilament(printerId),
|
||||
});
|
||||
|
||||
const handleLoad = () => {
|
||||
if (selectedTray !== null) {
|
||||
loadMutation.mutate(selectedTray);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnload = () => {
|
||||
unloadMutation.mutate();
|
||||
};
|
||||
|
||||
const isLoading = loadMutation.isPending || unloadMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="bg-bambu-dark-tertiary rounded-[10px] p-3 relative overflow-visible">
|
||||
{/* Center wiring and Extruder - absolutely centered between the two AMS panels */}
|
||||
{isDualNozzle && (
|
||||
<>
|
||||
{/* Center wiring: two vertical lines going down to extruder inlets */}
|
||||
{/* Positioned to connect with horizontal wires from AMS panels */}
|
||||
<div className="absolute left-1/2 -translate-x-1/2 bottom-[62px] pointer-events-none" style={{ width: '24px', height: '30px' }}>
|
||||
{/* Left vertical line - connects to left AMS horizontal wire, goes to left extruder inlet */}
|
||||
<div className="absolute left-0 top-0 h-full border-l-2 border-[#909090]" />
|
||||
{/* Right vertical line - connects to right AMS horizontal wire, goes to right extruder inlet */}
|
||||
<div className="absolute right-0 top-0 h-full border-l-2 border-[#909090]" />
|
||||
</div>
|
||||
{/* Extruder */}
|
||||
<img
|
||||
src="/icons/extruder-left-right.png"
|
||||
alt="Extruder"
|
||||
className="absolute h-[50px] left-1/2 -translate-x-1/2 bottom-[12px]"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Dual Panel Layout */}
|
||||
<div className="flex gap-5 overflow-visible">
|
||||
{/* Left Nozzle Panel */}
|
||||
<AMSPanelContent
|
||||
units={leftUnits}
|
||||
side="left"
|
||||
isPrinting={isPrinting}
|
||||
selectedAmsIndex={leftAmsIndex}
|
||||
onSelectAms={setLeftAmsIndex}
|
||||
selectedTray={selectedTray}
|
||||
onSelectTray={setSelectedTray}
|
||||
/>
|
||||
|
||||
{/* Right Nozzle Panel - only for dual nozzle */}
|
||||
{isDualNozzle && (
|
||||
<AMSPanelContent
|
||||
units={rightUnits}
|
||||
side="right"
|
||||
isPrinting={isPrinting}
|
||||
selectedAmsIndex={rightAmsIndex}
|
||||
onSelectAms={setRightAmsIndex}
|
||||
selectedTray={selectedTray}
|
||||
onSelectTray={setSelectedTray}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons Row with Extruder */}
|
||||
<div className="flex items-start pt-2">
|
||||
{/* Left buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="w-10 h-10 rounded-lg bg-bambu-dark-secondary hover:bg-bambu-dark border border-bambu-dark-tertiary flex items-center justify-center">
|
||||
<img src="/icons/ams-settings.svg" alt="Settings" className="w-5 icon-theme" />
|
||||
</button>
|
||||
<button className="px-[18px] py-2.5 rounded-lg bg-bambu-dark-secondary hover:bg-bambu-dark border border-bambu-dark-tertiary text-sm text-bambu-gray flex items-center gap-1.5">
|
||||
Auto-refill
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Right buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleUnload}
|
||||
disabled={!isConnected || isPrinting || isLoading}
|
||||
className="px-7 py-2.5 rounded-lg bg-bambu-dark hover:bg-bambu-dark-secondary text-sm text-bambu-gray disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{unloadMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
'Unload'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLoad}
|
||||
disabled={!isConnected || isPrinting || selectedTray === null || isLoading}
|
||||
className="px-7 py-2.5 rounded-lg bg-bambu-dark hover:bg-bambu-dark-secondary text-sm text-bambu-gray disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loadMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
'Load'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error messages */}
|
||||
{(loadMutation.error || unloadMutation.error) && (
|
||||
<p className="mt-2 text-sm text-red-500 text-center">
|
||||
{(loadMutation.error || unloadMutation.error)?.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { api, isConfirmationRequired } from '../../api/client';
|
||||
import type { PrinterStatus } from '../../api/client';
|
||||
import { useState } from 'react';
|
||||
import { ConfirmModal } from '../ConfirmModal';
|
||||
|
||||
interface BedControlsProps {
|
||||
printerId: number;
|
||||
status: PrinterStatus | null | undefined;
|
||||
}
|
||||
|
||||
export function BedControls({ printerId, status }: BedControlsProps) {
|
||||
const isConnected = status?.connected ?? false;
|
||||
|
||||
const [confirmModal, setConfirmModal] = useState<{
|
||||
token: string;
|
||||
warning: string;
|
||||
distance: number;
|
||||
} | null>(null);
|
||||
|
||||
const moveMutation = useMutation({
|
||||
mutationFn: ({ distance, token }: { distance: number; token?: string }) =>
|
||||
api.moveAxis(printerId, 'Z', distance, 1000, token),
|
||||
onSuccess: (result, variables) => {
|
||||
if (isConfirmationRequired(result)) {
|
||||
setConfirmModal({
|
||||
token: result.token,
|
||||
warning: result.warning,
|
||||
distance: variables.distance,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleMove = (distance: number) => {
|
||||
moveMutation.mutate({ distance });
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (confirmModal) {
|
||||
moveMutation.mutate({ distance: confirmModal.distance, token: confirmModal.token });
|
||||
setConfirmModal(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isDisabled = !isConnected || moveMutation.isPending;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleMove(10)}
|
||||
disabled={isDisabled}
|
||||
className="px-3.5 py-2 rounded-md bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary text-sm text-bambu-gray disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Z+10"
|
||||
>
|
||||
↑10
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMove(1)}
|
||||
disabled={isDisabled}
|
||||
className="px-3.5 py-2 rounded-md bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary text-sm text-bambu-gray disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Z+1"
|
||||
>
|
||||
↑1
|
||||
</button>
|
||||
<span className="px-2 py-2 text-sm text-bambu-gray">Bed</span>
|
||||
<button
|
||||
onClick={() => handleMove(-1)}
|
||||
disabled={isDisabled}
|
||||
className="px-3.5 py-2 rounded-md bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary text-sm text-bambu-gray disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Z-1"
|
||||
>
|
||||
↓1
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMove(-10)}
|
||||
disabled={isDisabled}
|
||||
className="px-3.5 py-2 rounded-md bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary text-sm text-bambu-gray disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Z-10"
|
||||
>
|
||||
↓10
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
{confirmModal && (
|
||||
<ConfirmModal
|
||||
title="Confirm Bed Movement"
|
||||
message={confirmModal.warning}
|
||||
confirmText="Continue"
|
||||
variant="warning"
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={() => setConfirmModal(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -55,84 +55,76 @@ export function CameraFeed({ printerId, isConnected }: CameraFeedProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-bambu-dark-secondary rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-bambu-dark-tertiary">
|
||||
<div className="flex items-center gap-2">
|
||||
<Camera className="w-4 h-4 text-bambu-gray" />
|
||||
<span className="text-sm font-medium">Camera</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{streamEnabled && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
|
||||
title="Refresh stream"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleFullscreen}
|
||||
className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
|
||||
title="Fullscreen"
|
||||
>
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={handleToggleStream}
|
||||
disabled={!isConnected}
|
||||
className={`px-3 py-1 rounded text-sm transition-colors ${
|
||||
streamEnabled
|
||||
? 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
|
||||
: 'bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30'
|
||||
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
{streamEnabled ? 'Stop' : 'Start'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative aspect-video bg-bambu-dark">
|
||||
{!streamEnabled ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-bambu-gray">
|
||||
<CameraOff className="w-12 h-12 mb-2" />
|
||||
<span className="text-sm">
|
||||
<div className="relative w-full h-full bg-black">
|
||||
{!streamEnabled ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-bambu-gray">
|
||||
<div className="bg-bambu-dark-secondary rounded-lg p-6 flex flex-col items-center">
|
||||
<Camera className="w-8 h-8 mb-2" />
|
||||
<span className="text-sm mb-3">
|
||||
{isConnected ? 'Click Start to view camera' : 'Printer not connected'}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleToggleStream}
|
||||
disabled={!isConnected}
|
||||
className="px-4 py-1.5 rounded text-sm bg-bambu-green text-white hover:bg-bambu-green-dark disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-bambu-dark">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
|
||||
</div>
|
||||
)}
|
||||
{error ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-red-400">
|
||||
<CameraOff className="w-12 h-12 mb-2" />
|
||||
<span className="text-sm">{error}</span>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="mt-2 text-xs text-bambu-green hover:underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={streamUrl}
|
||||
alt="Camera stream"
|
||||
className="w-full h-full object-contain"
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
|
||||
</div>
|
||||
)}
|
||||
{error ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-red-400">
|
||||
<CameraOff className="w-12 h-12 mb-2" />
|
||||
<span className="text-sm">{error}</span>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="mt-2 text-xs text-bambu-green hover:underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={streamUrl}
|
||||
alt="Camera stream"
|
||||
className="w-full h-full object-contain"
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
)}
|
||||
{/* Overlay controls */}
|
||||
<div className="absolute top-2 right-2 flex gap-2">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="p-2 rounded bg-black/50 hover:bg-black/70 text-white transition-colors"
|
||||
title="Refresh stream"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleFullscreen}
|
||||
className="p-2 rounded bg-black/50 hover:bg-black/70 text-white transition-colors"
|
||||
title="Fullscreen"
|
||||
>
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleToggleStream}
|
||||
className="px-3 py-1.5 rounded bg-red-500/80 hover:bg-red-500 text-white text-sm transition-colors"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import type { PrinterStatus } from '../../api/client';
|
||||
import { ChevronUp, ChevronDown } from 'lucide-react';
|
||||
|
||||
interface ExtruderControlsProps {
|
||||
printerId: number;
|
||||
status: PrinterStatus | null | undefined;
|
||||
nozzleCount: number;
|
||||
}
|
||||
|
||||
export function ExtruderControls({ status, nozzleCount }: ExtruderControlsProps) {
|
||||
const isConnected = status?.connected ?? false;
|
||||
const isPrinting = status?.state === 'RUNNING' || status?.state === 'PAUSE';
|
||||
const isDualNozzle = nozzleCount > 1;
|
||||
|
||||
const [selectedNozzle, setSelectedNozzle] = useState<'left' | 'right'>('left');
|
||||
|
||||
// TODO: Add extrude/retract API calls when available
|
||||
const extrudeMutation = useMutation({
|
||||
mutationFn: async ({ distance }: { distance: number }) => {
|
||||
// Placeholder - implement when API is ready
|
||||
console.log(`Extrude ${distance}mm on ${selectedNozzle} nozzle`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleExtrude = (distance: number) => {
|
||||
extrudeMutation.mutate({ distance });
|
||||
};
|
||||
|
||||
const isDisabled = !isConnected || isPrinting || extrudeMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1.5 flex-1 justify-center">
|
||||
{/* Left/Right Toggle - only for dual nozzle */}
|
||||
{isDualNozzle && (
|
||||
<div className="flex rounded-md overflow-hidden border border-bambu-dark-tertiary mb-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setSelectedNozzle('left')}
|
||||
className={`px-3 py-1.5 text-sm border-r border-bambu-dark-tertiary transition-colors ${
|
||||
selectedNozzle === 'left'
|
||||
? 'bg-bambu-green text-white'
|
||||
: 'bg-bambu-dark-secondary text-bambu-gray hover:bg-bambu-dark-tertiary'
|
||||
}`}
|
||||
>
|
||||
Left
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedNozzle('right')}
|
||||
className={`px-3 py-1.5 text-sm transition-colors ${
|
||||
selectedNozzle === 'right'
|
||||
? 'bg-bambu-green text-white'
|
||||
: 'bg-bambu-dark-secondary text-bambu-gray hover:bg-bambu-dark-tertiary'
|
||||
}`}
|
||||
>
|
||||
Right
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extrude Up Button */}
|
||||
<button
|
||||
onClick={() => handleExtrude(5)}
|
||||
disabled={isDisabled}
|
||||
className="w-9 h-[30px] rounded-md bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary flex items-center justify-center text-bambu-gray disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Extrude 5mm"
|
||||
>
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Extruder Image */}
|
||||
<div className="h-[120px] flex items-center justify-center">
|
||||
<img
|
||||
src={isDualNozzle ? "/icons/dual-extruder.png" : "/icons/single-extruder1.png"}
|
||||
alt={isDualNozzle ? "Dual Extruder" : "Single Extruder"}
|
||||
className="h-full object-contain"
|
||||
onError={(e) => {
|
||||
// Fallback if image doesn't load
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Retract Down Button */}
|
||||
<button
|
||||
onClick={() => handleExtrude(-5)}
|
||||
disabled={isDisabled}
|
||||
className="w-9 h-[30px] rounded-md bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary flex items-center justify-center text-bambu-gray disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Retract 5mm"
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Label */}
|
||||
<span className="text-xs text-bambu-gray mt-0.5">Extruder</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { api, isConfirmationRequired } from '../../api/client';
|
||||
import type { PrinterStatus } from '../../api/client';
|
||||
import { useState } from 'react';
|
||||
import { ConfirmModal } from '../ConfirmModal';
|
||||
|
||||
interface JogPadProps {
|
||||
printerId: number;
|
||||
status: PrinterStatus | null | undefined;
|
||||
}
|
||||
|
||||
export function JogPad({ printerId, status }: JogPadProps) {
|
||||
const isConnected = status?.connected ?? false;
|
||||
|
||||
const [confirmModal, setConfirmModal] = useState<{
|
||||
action: string;
|
||||
token: string;
|
||||
warning: string;
|
||||
onConfirm: () => void;
|
||||
} | null>(null);
|
||||
|
||||
const homeMutation = useMutation({
|
||||
mutationFn: ({ axes, token }: { axes: string; token?: string }) =>
|
||||
api.homeAxes(printerId, axes, token),
|
||||
onSuccess: (result) => {
|
||||
if (isConfirmationRequired(result)) {
|
||||
setConfirmModal({
|
||||
action: 'home',
|
||||
token: result.token,
|
||||
warning: result.warning,
|
||||
onConfirm: () => homeMutation.mutate({ axes: 'XY', token: result.token }),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const moveMutation = useMutation({
|
||||
mutationFn: ({ axis, distance, token }: { axis: string; distance: number; token?: string }) =>
|
||||
api.moveAxis(printerId, axis, distance, 3000, token),
|
||||
onSuccess: (result, variables) => {
|
||||
if (isConfirmationRequired(result)) {
|
||||
setConfirmModal({
|
||||
action: 'move',
|
||||
token: result.token,
|
||||
warning: result.warning,
|
||||
onConfirm: () =>
|
||||
moveMutation.mutate({
|
||||
axis: variables.axis,
|
||||
distance: variables.distance,
|
||||
token: result.token,
|
||||
}),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleHome = () => {
|
||||
homeMutation.mutate({ axes: 'XY' });
|
||||
};
|
||||
|
||||
const handleMove = (axis: string, distance: number) => {
|
||||
moveMutation.mutate({ axis, distance });
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (confirmModal) {
|
||||
confirmModal.onConfirm();
|
||||
setConfirmModal(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = homeMutation.isPending || moveMutation.isPending;
|
||||
const isDisabled = !isConnected || isLoading;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative w-[220px] h-[220px] mb-3.5">
|
||||
{/* Use the actual jogpad.svg from mockup */}
|
||||
<img
|
||||
src="/icons/jogpad.svg"
|
||||
alt="Jog Pad"
|
||||
className="w-full h-full jogpad-theme"
|
||||
/>
|
||||
|
||||
{/* Invisible clickable areas overlaid on the SVG */}
|
||||
{/* Outer ring - 10mm moves */}
|
||||
<button
|
||||
onClick={() => handleMove('Y', 10)}
|
||||
disabled={isDisabled}
|
||||
className="absolute top-[8px] left-1/2 -translate-x-1/2 w-[40px] h-[30px] opacity-0 hover:opacity-10 hover:bg-white disabled:cursor-not-allowed"
|
||||
title="Y+10"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleMove('Y', -10)}
|
||||
disabled={isDisabled}
|
||||
className="absolute bottom-[8px] left-1/2 -translate-x-1/2 w-[40px] h-[30px] opacity-0 hover:opacity-10 hover:bg-white disabled:cursor-not-allowed"
|
||||
title="Y-10"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleMove('X', -10)}
|
||||
disabled={isDisabled}
|
||||
className="absolute left-[8px] top-1/2 -translate-y-1/2 w-[30px] h-[40px] opacity-0 hover:opacity-10 hover:bg-white disabled:cursor-not-allowed"
|
||||
title="X-10"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleMove('X', 10)}
|
||||
disabled={isDisabled}
|
||||
className="absolute right-[8px] top-1/2 -translate-y-1/2 w-[30px] h-[40px] opacity-0 hover:opacity-10 hover:bg-white disabled:cursor-not-allowed"
|
||||
title="X+10"
|
||||
/>
|
||||
|
||||
{/* Inner ring - 1mm moves */}
|
||||
<button
|
||||
onClick={() => handleMove('Y', 1)}
|
||||
disabled={isDisabled}
|
||||
className="absolute top-[42px] left-1/2 -translate-x-1/2 w-[35px] h-[25px] opacity-0 hover:opacity-10 hover:bg-white disabled:cursor-not-allowed"
|
||||
title="Y+1"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleMove('Y', -1)}
|
||||
disabled={isDisabled}
|
||||
className="absolute bottom-[42px] left-1/2 -translate-x-1/2 w-[35px] h-[25px] opacity-0 hover:opacity-10 hover:bg-white disabled:cursor-not-allowed"
|
||||
title="Y-1"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleMove('X', -1)}
|
||||
disabled={isDisabled}
|
||||
className="absolute left-[42px] top-1/2 -translate-y-1/2 w-[25px] h-[35px] opacity-0 hover:opacity-10 hover:bg-white disabled:cursor-not-allowed"
|
||||
title="X-1"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleMove('X', 1)}
|
||||
disabled={isDisabled}
|
||||
className="absolute right-[42px] top-1/2 -translate-y-1/2 w-[25px] h-[35px] opacity-0 hover:opacity-10 hover:bg-white disabled:cursor-not-allowed"
|
||||
title="X+1"
|
||||
/>
|
||||
|
||||
{/* Home button in center - clickable overlay */}
|
||||
<button
|
||||
onClick={handleHome}
|
||||
disabled={isDisabled}
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[50px] h-[50px] rounded-full opacity-0 hover:opacity-10 hover:bg-white disabled:cursor-not-allowed"
|
||||
title="Home XY"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
{confirmModal && (
|
||||
<ConfirmModal
|
||||
title="Confirm Action"
|
||||
message={confirmModal.warning}
|
||||
confirmText="Continue"
|
||||
variant="warning"
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={() => setConfirmModal(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export function PrintControls({ printerId, status }: PrintControlsProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-bambu-dark-secondary rounded-lg p-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-bambu-gray mb-3">Print Controls</h3>
|
||||
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -1,90 +1,190 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import type { PrinterStatus } from '../../api/client';
|
||||
import { Clock, Layers, FileText } from 'lucide-react';
|
||||
import { api, isConfirmationRequired } from '../../api/client';
|
||||
import { Pause, Square, Loader2 } from 'lucide-react';
|
||||
import { ConfirmModal } from '../ConfirmModal';
|
||||
|
||||
interface PrintStatusProps {
|
||||
printerId: number;
|
||||
status: PrinterStatus | null | undefined;
|
||||
}
|
||||
|
||||
function formatTime(seconds: number | null | undefined): string {
|
||||
if (!seconds) return '--:--';
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
return `${mins}m`;
|
||||
function formatFinishTime(seconds: number | null | undefined): string {
|
||||
if (!seconds) return 'N/A';
|
||||
const now = new Date();
|
||||
const finish = new Date(now.getTime() + seconds * 1000);
|
||||
return finish.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
export function PrintStatus({ status }: PrintStatusProps) {
|
||||
const isPrinting = status?.state === 'RUNNING' || status?.state === 'PAUSE';
|
||||
export function PrintStatus({ printerId, status }: PrintStatusProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [confirmModal, setConfirmModal] = useState<{
|
||||
action: string;
|
||||
token: string;
|
||||
warning: string;
|
||||
} | null>(null);
|
||||
|
||||
const isConnected = status?.connected ?? false;
|
||||
const isPrinting = status?.state === 'RUNNING';
|
||||
const isPaused = status?.state === 'PAUSE';
|
||||
const progress = status?.progress ?? 0;
|
||||
|
||||
const pauseMutation = useMutation({
|
||||
mutationFn: () => api.pausePrint(printerId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['printerStatuses'] });
|
||||
},
|
||||
});
|
||||
|
||||
const resumeMutation = useMutation({
|
||||
mutationFn: () => api.resumePrint(printerId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['printerStatuses'] });
|
||||
},
|
||||
});
|
||||
|
||||
const stopMutation = useMutation({
|
||||
mutationFn: (token?: string) => api.stopPrint(printerId, token),
|
||||
onSuccess: (result) => {
|
||||
if (isConfirmationRequired(result)) {
|
||||
setConfirmModal({
|
||||
action: 'stop',
|
||||
token: result.token,
|
||||
warning: result.warning,
|
||||
});
|
||||
} else {
|
||||
queryClient.invalidateQueries({ queryKey: ['printerStatuses'] });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handlePauseResume = () => {
|
||||
if (isPrinting) {
|
||||
pauseMutation.mutate();
|
||||
} else if (isPaused) {
|
||||
resumeMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
stopMutation.mutate(undefined);
|
||||
};
|
||||
|
||||
const handleConfirmStop = () => {
|
||||
if (confirmModal) {
|
||||
stopMutation.mutate(confirmModal.token);
|
||||
setConfirmModal(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = pauseMutation.isPending || resumeMutation.isPending || stopMutation.isPending;
|
||||
const canControl = isConnected && (isPrinting || isPaused);
|
||||
|
||||
return (
|
||||
<div className="bg-bambu-dark-secondary rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-bambu-gray">Print Status</h3>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
status?.state === 'RUNNING'
|
||||
? 'bg-bambu-green/20 text-bambu-green'
|
||||
: status?.state === 'PAUSE'
|
||||
? 'bg-yellow-500/20 text-yellow-500'
|
||||
: status?.state === 'FINISH'
|
||||
? 'bg-blue-500/20 text-blue-500'
|
||||
: status?.state === 'FAILED'
|
||||
? 'bg-red-500/20 text-red-500'
|
||||
: 'bg-bambu-dark-tertiary text-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
{status?.state || 'IDLE'}
|
||||
</span>
|
||||
<>
|
||||
<div className="text-xs text-bambu-gray mb-3">Printing Progress</div>
|
||||
<div className="flex gap-4 items-center">
|
||||
{/* Thumbnail */}
|
||||
<div className="w-20 h-20 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden">
|
||||
{status?.subtask_name ? (
|
||||
<img
|
||||
src={`/api/v1/archives/thumbnail/${encodeURIComponent(status.subtask_name)}`}
|
||||
alt=""
|
||||
className="w-full h-full object-contain"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
(e.target as HTMLImageElement).parentElement!.innerHTML = '<span class="text-xs text-bambu-gray">Bambu<br/>Lab</span>';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-bambu-gray text-center">Bambu<br/>Lab</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm text-white truncate mb-0.5">
|
||||
{status?.subtask_name || 'N/A'}
|
||||
</div>
|
||||
<div className={`text-sm mb-2 ${
|
||||
status?.state === 'RUNNING' || status?.state === 'PAUSE'
|
||||
? 'text-bambu-green'
|
||||
: 'text-bambu-gray'
|
||||
}`}>
|
||||
{status?.state || 'N/A'}
|
||||
</div>
|
||||
{/* Progress Bar */}
|
||||
<div className="h-1 bg-bambu-dark-tertiary rounded-full overflow-hidden mb-2">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${
|
||||
status?.state === 'PAUSE' ? 'bg-yellow-500' : 'bg-bambu-green'
|
||||
}`}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-bambu-gray mb-1">
|
||||
Layer: {status?.layer_num ?? 'N/A'} / {status?.total_layers ?? 'N/A'} {Math.round(progress)}%
|
||||
</div>
|
||||
<div className="text-xs text-bambu-gray">
|
||||
Estimated finish time: {formatFinishTime(status?.remaining_time)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Control Buttons */}
|
||||
<div className="flex gap-1.5 flex-shrink-0">
|
||||
<button
|
||||
onClick={handlePauseResume}
|
||||
disabled={!canControl || isLoading}
|
||||
className="w-8 h-8 rounded-md border border-bambu-dark-tertiary bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary flex items-center justify-center text-bambu-gray disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={isPrinting ? 'Pause' : 'Resume'}
|
||||
>
|
||||
{pauseMutation.isPending || resumeMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Pause className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStop}
|
||||
disabled={!canControl || isLoading}
|
||||
className="w-8 h-8 rounded-md border border-bambu-dark-tertiary bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary flex items-center justify-center text-bambu-gray disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Stop"
|
||||
>
|
||||
{stopMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Square className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
disabled={!canControl || isLoading}
|
||||
className="w-8 h-8 rounded-md border border-bambu-dark-tertiary bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary flex items-center justify-center text-bambu-gray disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Skip Objects"
|
||||
>
|
||||
<img src="/icons/skip-objects.svg" alt="Skip Objects" className="w-4 h-4 icon-theme" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPrinting && status?.subtask_name && (
|
||||
<div className="flex items-center gap-2 mb-3 text-sm">
|
||||
<FileText className="w-4 h-4 text-bambu-gray" />
|
||||
<span className="truncate" title={status.subtask_name}>
|
||||
{status.subtask_name}
|
||||
</span>
|
||||
</div>
|
||||
{/* Error Message */}
|
||||
{(pauseMutation.error || resumeMutation.error || stopMutation.error) && (
|
||||
<p className="mt-2 text-xs text-red-500">
|
||||
{(pauseMutation.error || resumeMutation.error || stopMutation.error)?.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-3">
|
||||
<div className="flex justify-between text-xs text-bambu-gray mb-1">
|
||||
<span>Progress</span>
|
||||
<span>{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-bambu-dark-tertiary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${
|
||||
status?.state === 'PAUSE' ? 'bg-yellow-500' : 'bg-bambu-green'
|
||||
}`}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-bambu-gray" />
|
||||
<div>
|
||||
<div className="text-xs text-bambu-gray">Remaining</div>
|
||||
<div className="font-medium">{formatTime(status?.remaining_time)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="w-4 h-4 text-bambu-gray" />
|
||||
<div>
|
||||
<div className="text-xs text-bambu-gray">Layer</div>
|
||||
<div className="font-medium">
|
||||
{status?.layer_num ?? 0} / {status?.total_layers ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Confirmation Modal */}
|
||||
{confirmModal && (
|
||||
<ConfirmModal
|
||||
title="Confirm Stop"
|
||||
message={confirmModal.warning}
|
||||
confirmText="Stop Print"
|
||||
variant="danger"
|
||||
onConfirm={handleConfirmStop}
|
||||
onCancel={() => setConfirmModal(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { PrinterStatus } from '../../api/client';
|
||||
|
||||
interface Temperatures {
|
||||
bed?: number;
|
||||
bed_target?: number;
|
||||
nozzle?: number;
|
||||
nozzle_target?: number;
|
||||
nozzle_2?: number;
|
||||
nozzle_2_target?: number;
|
||||
chamber?: number;
|
||||
}
|
||||
|
||||
interface TemperatureColumnProps {
|
||||
printerId?: number;
|
||||
status: PrinterStatus | null | undefined;
|
||||
nozzleCount: number;
|
||||
}
|
||||
|
||||
export function TemperatureColumn({ status, nozzleCount }: TemperatureColumnProps) {
|
||||
const temps = (status?.temperatures ?? {}) as Temperatures;
|
||||
const isDualNozzle = nozzleCount > 1;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-evenly min-w-[150px] pr-5 border-r border-bambu-dark-tertiary">
|
||||
{/* Nozzle 1 (Left) */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-5 h-5 flex items-center justify-center flex-shrink-0">
|
||||
<img src="/icons/hotend.svg" alt="" className="w-5 icon-theme" />
|
||||
</div>
|
||||
{isDualNozzle && (
|
||||
<span className="text-[11px] font-semibold text-bambu-green bg-bambu-green/20 px-1.5 py-0.5 rounded min-w-[18px] text-center flex-shrink-0">
|
||||
L
|
||||
</span>
|
||||
)}
|
||||
<span className="text-lg font-medium text-white">{Math.round(temps.nozzle ?? 0)}</span>
|
||||
<span className="text-sm text-bambu-gray">/{Math.round(temps.nozzle_target ?? 0)} °C</span>
|
||||
</div>
|
||||
|
||||
{/* Nozzle 2 (Right) - only for dual nozzle */}
|
||||
{isDualNozzle && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-5 h-5 flex items-center justify-center flex-shrink-0">
|
||||
<img src="/icons/hotend.svg" alt="" className="w-5 icon-theme" />
|
||||
</div>
|
||||
<span className="text-[11px] font-semibold text-bambu-green bg-bambu-green/20 px-1.5 py-0.5 rounded min-w-[18px] text-center flex-shrink-0">
|
||||
R
|
||||
</span>
|
||||
<span className="text-lg font-medium text-white">{Math.round(temps.nozzle_2 ?? 0)}</span>
|
||||
<span className="text-sm text-bambu-gray">/{Math.round(temps.nozzle_2_target ?? 0)} °C</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bed */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-5 h-5 flex items-center justify-center flex-shrink-0">
|
||||
<img src="/icons/heatbed.svg" alt="" className="w-5 icon-theme" />
|
||||
</div>
|
||||
{/* Spacer to align with L/R badge (min-w-[18px]) */}
|
||||
{isDualNozzle && <span className="min-w-[18px] flex-shrink-0" />}
|
||||
<span className="text-lg font-medium text-white">{Math.round(temps.bed ?? 0)}</span>
|
||||
<span className="text-sm text-bambu-gray">/{Math.round(temps.bed_target ?? 0)} °C</span>
|
||||
</div>
|
||||
|
||||
{/* Chamber */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-5 h-5 flex items-center justify-center flex-shrink-0">
|
||||
<img src="/icons/chamber.svg" alt="" className="w-5 icon-theme" />
|
||||
</div>
|
||||
{/* Spacer to align with L/R badge */}
|
||||
{isDualNozzle && <span className="min-w-[18px] flex-shrink-0" />}
|
||||
<span className="text-lg font-medium text-white">{Math.round(temps.chamber ?? 0)}</span>
|
||||
<span className="text-sm text-bambu-gray">/{0} °C</span>
|
||||
</div>
|
||||
|
||||
{/* Air Condition - button */}
|
||||
<button className="flex items-center gap-2 hover:opacity-80 transition-opacity">
|
||||
<div className="w-5 h-5 flex items-center justify-center flex-shrink-0">
|
||||
<img src="/icons/ventilation.svg" alt="" className="w-5 icon-theme" />
|
||||
</div>
|
||||
<span className="text-sm text-bambu-gray">Air Condition</span>
|
||||
</button>
|
||||
|
||||
{/* Lamp - button */}
|
||||
<button className="flex items-center gap-2 hover:opacity-80 transition-opacity">
|
||||
<div className="w-5 h-5 flex items-center justify-center flex-shrink-0">
|
||||
<img src="/icons/ventilation.svg" alt="" className="w-4 icon-theme" />
|
||||
</div>
|
||||
<span className="text-sm text-bambu-gray">Lamp</span>
|
||||
<div className="w-3.5 h-3.5 rounded-full bg-bambu-green" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user