mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 07:33:18 +02:00
Wired air condition, speed and light buttons
This commit is contained in:
@@ -273,6 +273,35 @@ async def set_nozzle_temperature(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{printer_id}/control/temperature/chamber", response_model=ControlResponse)
|
||||
async def set_chamber_temperature(
|
||||
printer_id: int,
|
||||
request: TemperatureRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Set the chamber target temperature."""
|
||||
await get_printer_or_404(printer_id, db)
|
||||
client = get_mqtt_client_or_503(printer_id)
|
||||
|
||||
# Warn for high temperatures (chamber typically maxes around 60°C)
|
||||
if request.target > 60 and not request.confirm_token:
|
||||
token = _create_confirmation_token(printer_id, "chamber_temp")
|
||||
return ConfirmationRequired(
|
||||
token=token,
|
||||
warning=f"Setting chamber to {request.target}°C is very high. Confirm?"
|
||||
)
|
||||
|
||||
if request.target > 60:
|
||||
if not _validate_confirmation_token(request.confirm_token, printer_id, "chamber_temp"):
|
||||
raise HTTPException(status_code=400, detail="Invalid or expired confirmation token")
|
||||
|
||||
success = client.set_chamber_temperature(request.target)
|
||||
return ControlResponse(
|
||||
success=success,
|
||||
message=f"Chamber temperature set to {request.target}°C" if success else "Failed to set chamber temperature"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Speed Control Endpoint
|
||||
# =============================================================================
|
||||
@@ -354,6 +383,38 @@ async def set_chamber_fan(
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Air Conditioning Control Endpoint
|
||||
# =============================================================================
|
||||
|
||||
class AirductModeRequest(BaseModel):
|
||||
mode: str # "cooling" or "heating"
|
||||
|
||||
|
||||
@router.post("/{printer_id}/control/airduct", response_model=ControlResponse)
|
||||
async def set_airduct_mode(
|
||||
printer_id: int,
|
||||
request: AirductModeRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Set air conditioning mode (cooling or heating).
|
||||
|
||||
- Cooling: Suitable for PLA/PETG/TPU, filters and cools chamber air
|
||||
- Heating: Suitable for ABS/ASA/PC/PA, circulates and heats chamber air, closes top exhaust flap
|
||||
"""
|
||||
await get_printer_or_404(printer_id, db)
|
||||
client = get_mqtt_client_or_503(printer_id)
|
||||
|
||||
if request.mode not in ("cooling", "heating"):
|
||||
raise HTTPException(status_code=400, detail="Mode must be 'cooling' or 'heating'")
|
||||
|
||||
success = client.set_airduct_mode(request.mode)
|
||||
return ControlResponse(
|
||||
success=success,
|
||||
message=f"Air conditioning set to {request.mode}" if success else "Failed to set air conditioning mode"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Light Control Endpoint
|
||||
# =============================================================================
|
||||
|
||||
@@ -241,6 +241,9 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
|
||||
stg_cur=state.stg_cur,
|
||||
stg_cur_name=get_stage_name(state.stg_cur) if state.stg_cur >= 0 else None,
|
||||
stg=state.stg,
|
||||
airduct_mode=state.airduct_mode,
|
||||
speed_level=state.speed_level,
|
||||
chamber_light=state.chamber_light,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -112,3 +112,9 @@ class PrinterStatus(BaseModel):
|
||||
stg_cur: int = -1 # Current stage number (-1 = not calibrating)
|
||||
stg_cur_name: str | None = None # Human-readable current stage name
|
||||
stg: list[int] = [] # List of stage numbers in calibration sequence
|
||||
# Air conditioning mode (0=cooling, 1=heating)
|
||||
airduct_mode: int = 0
|
||||
# Print speed level (1=silent, 2=standard, 3=sport, 4=ludicrous)
|
||||
speed_level: int = 2
|
||||
# Chamber light on/off
|
||||
chamber_light: bool = False
|
||||
|
||||
@@ -103,6 +103,12 @@ class PrinterState:
|
||||
# Calibration stage tracking (from stg_cur and stg fields)
|
||||
stg_cur: int = -1 # Current stage index (-1 = not calibrating)
|
||||
stg: list = field(default_factory=list) # List of stages to execute
|
||||
# Air conditioning mode (0=cooling, 1=heating)
|
||||
airduct_mode: int = 0
|
||||
# Print speed level (1=silent, 2=standard, 3=sport, 4=ludicrous)
|
||||
speed_level: int = 2
|
||||
# Chamber light on/off
|
||||
chamber_light: bool = False
|
||||
|
||||
|
||||
# Stage name mapping from BambuStudio DeviceManager.cpp
|
||||
@@ -781,6 +787,13 @@ class BambuMQTTClient:
|
||||
# Parse chamber temp from device.ctc.info.temp if not already set
|
||||
ctc_data = device.get("ctc", {})
|
||||
ctc_info = ctc_data.get("info", {})
|
||||
# Parse airduct mode (0=cooling, 1=heating)
|
||||
airduct_data = device.get("airduct", {})
|
||||
if "modeCur" in airduct_data:
|
||||
new_mode = airduct_data["modeCur"]
|
||||
if new_mode != self.state.airduct_mode:
|
||||
logger.info(f"[{self.serial_number}] airduct_mode changed: {self.state.airduct_mode} -> {new_mode}")
|
||||
self.state.airduct_mode = new_mode
|
||||
if "temp" in ctc_info and "chamber" not in temps:
|
||||
temps["chamber"] = float(ctc_info["temp"])
|
||||
# Parse chamber target from ctc.info.target if available
|
||||
@@ -860,6 +873,26 @@ class BambuMQTTClient:
|
||||
else:
|
||||
self.state.ipcam = ipcam_data is True
|
||||
|
||||
# Parse print speed level (1=silent, 2=standard, 3=sport, 4=ludicrous)
|
||||
if "spd_lvl" in data:
|
||||
new_speed = data["spd_lvl"]
|
||||
if new_speed != self.state.speed_level:
|
||||
logger.info(f"[{self.serial_number}] speed_level changed: {self.state.speed_level} -> {new_speed}")
|
||||
self.state.speed_level = new_speed
|
||||
|
||||
# Parse chamber light status from lights_report
|
||||
if "lights_report" in data:
|
||||
lights = data["lights_report"]
|
||||
logger.debug(f"[{self.serial_number}] lights_report: {lights}")
|
||||
if isinstance(lights, list):
|
||||
for light in lights:
|
||||
if isinstance(light, dict) and light.get("node") == "chamber_light":
|
||||
new_light_state = light.get("mode") == "on"
|
||||
if new_light_state != self.state.chamber_light:
|
||||
logger.info(f"[{self.serial_number}] chamber_light changed: {self.state.chamber_light} -> {new_light_state}")
|
||||
self.state.chamber_light = new_light_state
|
||||
break
|
||||
|
||||
# Parse nozzle hardware info (single nozzle printers)
|
||||
if "nozzle_type" in data:
|
||||
self.state.nozzles[0].nozzle_type = str(data["nozzle_type"])
|
||||
@@ -1772,6 +1805,18 @@ class BambuMQTTClient:
|
||||
logger.info(f"[{self.serial_number}] Tracking LEFT nozzle target locally: {target}°C")
|
||||
return result
|
||||
|
||||
def set_chamber_temperature(self, target: int) -> bool:
|
||||
"""Set the chamber target temperature.
|
||||
|
||||
Args:
|
||||
target: Target temperature in Celsius (0 to turn off heating)
|
||||
|
||||
Returns:
|
||||
True if command was sent, False otherwise
|
||||
"""
|
||||
# M141 sets chamber temperature
|
||||
return self.send_gcode(f"M141 S{target}")
|
||||
|
||||
def set_print_speed(self, mode: int) -> bool:
|
||||
"""Set the print speed mode.
|
||||
|
||||
@@ -1829,6 +1874,37 @@ class BambuMQTTClient:
|
||||
"""Set chamber fan speed (0-255)."""
|
||||
return self.set_fan_speed(3, speed)
|
||||
|
||||
def set_airduct_mode(self, mode: str) -> bool:
|
||||
"""Set air conditioning mode (cooling or heating).
|
||||
|
||||
Args:
|
||||
mode: "cooling" (modeId=0) or "heating" (modeId=1)
|
||||
- Cooling: Suitable for PLA/PETG/TPU, filters and cools chamber air
|
||||
- Heating: Suitable for ABS/ASA/PC/PA, circulates and heats chamber air,
|
||||
closes top exhaust flap
|
||||
|
||||
Returns:
|
||||
True if command was sent, False otherwise
|
||||
"""
|
||||
if not self._client or not self.state.connected:
|
||||
logger.warning(f"[{self.serial_number}] Cannot set airduct mode: not connected")
|
||||
return False
|
||||
|
||||
self._sequence_id += 1
|
||||
mode_id = 0 if mode == "cooling" else 1
|
||||
command = {
|
||||
"print": {
|
||||
"command": "set_airduct",
|
||||
"modeId": mode_id,
|
||||
"sequence_id": str(self._sequence_id),
|
||||
"submode": -1
|
||||
}
|
||||
}
|
||||
# Use QoS 1 for reliable delivery
|
||||
self._client.publish(self.topic_publish, json.dumps(command), qos=1)
|
||||
logger.info(f"[{self.serial_number}] Set airduct mode to {mode} (modeId={mode_id}, seq={self._sequence_id})")
|
||||
return True
|
||||
|
||||
def set_chamber_light(self, on: bool) -> bool:
|
||||
"""Turn chamber light on or off.
|
||||
|
||||
@@ -1842,20 +1918,24 @@ class BambuMQTTClient:
|
||||
logger.warning(f"[{self.serial_number}] Cannot set chamber light: not connected")
|
||||
return False
|
||||
|
||||
command = {
|
||||
"system": {
|
||||
"command": "ledctrl",
|
||||
"led_node": "chamber_light",
|
||||
"led_mode": "on" if on else "off",
|
||||
"led_on_time": 500,
|
||||
"led_off_time": 500,
|
||||
"loop_times": 0,
|
||||
"interval_time": 0,
|
||||
"sequence_id": "0"
|
||||
mode = "on" if on else "off"
|
||||
# Control both chamber lights (some printers like H2D have two)
|
||||
for led_node in ["chamber_light", "chamber_light2"]:
|
||||
self._sequence_id += 1
|
||||
command = {
|
||||
"system": {
|
||||
"command": "ledctrl",
|
||||
"led_node": led_node,
|
||||
"led_mode": mode,
|
||||
"led_on_time": 500,
|
||||
"led_off_time": 500,
|
||||
"loop_times": 0,
|
||||
"interval_time": 0,
|
||||
"sequence_id": str(self._sequence_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
self._client.publish(self.topic_publish, json.dumps(command))
|
||||
logger.info(f"[{self.serial_number}] Set chamber light {'on' if on else 'off'}")
|
||||
self._client.publish(self.topic_publish, json.dumps(command), qos=1)
|
||||
logger.info(f"[{self.serial_number}] Set chamber lights {'on' if on else 'off'} (seq={self._sequence_id})")
|
||||
return True
|
||||
|
||||
def home_axes(self, axes: str = "XYZ") -> bool:
|
||||
|
||||
@@ -115,6 +115,12 @@ export interface PrinterStatus {
|
||||
stg_cur: number; // Current stage number (-1 = not calibrating)
|
||||
stg_cur_name: string | null; // Human-readable current stage name
|
||||
stg: number[]; // List of stage numbers in calibration sequence
|
||||
// Air conditioning mode (0=cooling, 1=heating)
|
||||
airduct_mode: number;
|
||||
// Print speed level (1=silent, 2=standard, 3=sport, 4=ludicrous)
|
||||
speed_level: number;
|
||||
// Chamber light on/off
|
||||
chamber_light: boolean;
|
||||
}
|
||||
|
||||
export interface PrinterCreate {
|
||||
@@ -1138,6 +1144,11 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ target, nozzle, confirm_token: confirmToken }),
|
||||
}),
|
||||
setChamberTemperature: (printerId: number, target: number, confirmToken?: string) =>
|
||||
request<ControlResult>(`/printers/${printerId}/control/temperature/chamber`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ target, confirm_token: confirmToken }),
|
||||
}),
|
||||
setPrintSpeed: (printerId: number, mode: number) =>
|
||||
request<ControlResponse>(`/printers/${printerId}/control/speed`, {
|
||||
method: 'POST',
|
||||
@@ -1158,6 +1169,11 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ speed }),
|
||||
}),
|
||||
setAirductMode: (printerId: number, mode: 'cooling' | 'heating') =>
|
||||
request<ControlResponse>(`/printers/${printerId}/control/airduct`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mode }),
|
||||
}),
|
||||
setChamberLight: (printerId: number, on: boolean) =>
|
||||
request<ControlResponse>(`/printers/${printerId}/control/light`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { api } from '../../api/client';
|
||||
import type { Printer, PrinterStatus } from '../../api/client';
|
||||
import { X, Minus, Plus, Wind, Flame, AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface AirConditionModalProps {
|
||||
printer: Printer;
|
||||
status: PrinterStatus | null | undefined;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Mode = 'cooling' | 'heating';
|
||||
|
||||
// Toggle switch component
|
||||
function Toggle({
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => !disabled && onChange(!checked)}
|
||||
disabled={disabled}
|
||||
className={`w-11 h-6 rounded-full relative transition-all shadow-inner border ${
|
||||
checked ? 'bg-bambu-green border-bambu-green' : 'bg-bambu-dark-tertiary border-bambu-dark-tertiary'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<div
|
||||
className={`w-5 h-5 rounded-full bg-white shadow-md absolute top-0.5 transition-transform ${
|
||||
checked ? 'translate-x-5' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Fan control card component
|
||||
function FanCard({
|
||||
name,
|
||||
icon,
|
||||
speed,
|
||||
enabled,
|
||||
onToggle,
|
||||
onSpeedChange,
|
||||
disabled,
|
||||
showSpeedControl = true,
|
||||
}: {
|
||||
name: string;
|
||||
icon: React.ReactNode;
|
||||
speed: number;
|
||||
enabled: boolean;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
onSpeedChange: (speed: number) => void;
|
||||
disabled?: boolean;
|
||||
showSpeedControl?: boolean;
|
||||
}) {
|
||||
const increment = () => {
|
||||
const newSpeed = Math.min(100, speed + 10);
|
||||
onSpeedChange(newSpeed);
|
||||
};
|
||||
|
||||
const decrement = () => {
|
||||
const newSpeed = Math.max(0, speed - 10);
|
||||
onSpeedChange(newSpeed);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-bambu-dark rounded-xl p-4 shadow-lg border border-bambu-dark-tertiary/50">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-bambu-dark-tertiary flex items-center justify-center">
|
||||
{icon}
|
||||
</div>
|
||||
<span className="text-white font-medium">{name}</span>
|
||||
</div>
|
||||
{showSpeedControl && (
|
||||
<Toggle checked={enabled} onChange={onToggle} disabled={disabled} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls or Status */}
|
||||
{showSpeedControl ? (
|
||||
<div className="flex items-center justify-between bg-bambu-dark-secondary rounded-lg p-2">
|
||||
<button
|
||||
onClick={decrement}
|
||||
disabled={disabled || !enabled}
|
||||
className="w-9 h-9 rounded-lg bg-bambu-dark-tertiary flex items-center justify-center text-white hover:bg-bambu-dark transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="flex-1 text-center">
|
||||
<span className={`text-xl font-semibold ${enabled ? 'text-white' : 'text-bambu-gray'}`}>
|
||||
{speed}%
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={increment}
|
||||
disabled={disabled || !enabled}
|
||||
className="w-9 h-9 rounded-lg bg-bambu-dark-tertiary flex items-center justify-center text-white hover:bg-bambu-dark transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 text-center">
|
||||
<span className={`text-xl font-semibold ${enabled ? 'text-bambu-green' : 'text-bambu-green'}`}>
|
||||
{enabled ? 'Auto' : 'Off'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Warning dialog component
|
||||
function PrintWarningDialog({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="absolute inset-0 bg-black/80 flex items-center justify-center z-10 rounded-2xl">
|
||||
<div className="bg-bambu-dark-secondary rounded-xl p-5 m-4 max-w-sm border border-bambu-dark-tertiary shadow-xl">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-full bg-yellow-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white font-semibold">Print in Progress</h3>
|
||||
<p className="text-sm text-bambu-gray">The printer is currently controlling air conditioning.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-bambu-gray mb-5">
|
||||
Changing these settings during a print may affect print quality. Are you sure you want to continue?
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex-1 py-2.5 px-4 rounded-lg font-medium text-sm bg-bambu-dark-tertiary text-white hover:bg-bambu-dark transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="flex-1 py-2.5 px-4 rounded-lg font-medium text-sm bg-yellow-600 text-white hover:bg-yellow-700 transition-colors"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AirConditionModal({ printer, status, onClose }: AirConditionModalProps) {
|
||||
const isConnected = status?.connected ?? false;
|
||||
const isPrinting = status?.state === 'RUNNING' || status?.state === 'PRINTING';
|
||||
const isDisabled = !isConnected;
|
||||
|
||||
// Initialize mode from printer status (0=cooling, 1=heating)
|
||||
const initialMode: Mode = (status?.airduct_mode ?? 0) === 1 ? 'heating' : 'cooling';
|
||||
const [mode, setMode] = useState<Mode>(initialMode);
|
||||
const [showPrintWarning, setShowPrintWarning] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState<(() => void) | null>(null);
|
||||
|
||||
// Fan states
|
||||
const [partFanEnabled, setPartFanEnabled] = useState(false);
|
||||
const [partFanSpeed, setPartFanSpeed] = useState(0);
|
||||
const [auxFanEnabled, setAuxFanEnabled] = useState(false);
|
||||
const [auxFanSpeed, setAuxFanSpeed] = useState(0);
|
||||
const [exhaustFanEnabled, setExhaustFanEnabled] = useState(false);
|
||||
const [exhaustFanSpeed, setExhaustFanSpeed] = useState(0);
|
||||
|
||||
// Mutation for airduct mode
|
||||
const airductMutation = useMutation({
|
||||
mutationFn: (newMode: Mode) => api.setAirductMode(printer.id, newMode),
|
||||
});
|
||||
|
||||
// Mutations for fan control
|
||||
const partFanMutation = useMutation({
|
||||
mutationFn: (speed: number) => api.setPartFan(printer.id, speed),
|
||||
});
|
||||
|
||||
const auxFanMutation = useMutation({
|
||||
mutationFn: (speed: number) => api.setAuxFan(printer.id, speed),
|
||||
});
|
||||
|
||||
const chamberFanMutation = useMutation({
|
||||
mutationFn: (speed: number) => api.setChamberFan(printer.id, speed),
|
||||
});
|
||||
|
||||
// Wrapper to check for print warning before executing action
|
||||
const withPrintWarning = (action: () => void) => {
|
||||
if (isPrinting) {
|
||||
setPendingAction(() => action);
|
||||
setShowPrintWarning(true);
|
||||
} else {
|
||||
action();
|
||||
}
|
||||
};
|
||||
|
||||
const handleWarningConfirm = () => {
|
||||
if (pendingAction) {
|
||||
pendingAction();
|
||||
setPendingAction(null);
|
||||
}
|
||||
setShowPrintWarning(false);
|
||||
};
|
||||
|
||||
const handleWarningCancel = () => {
|
||||
setPendingAction(null);
|
||||
setShowPrintWarning(false);
|
||||
};
|
||||
|
||||
// Handle mode change
|
||||
const handleModeChange = (newMode: Mode) => {
|
||||
if (newMode === mode) return;
|
||||
|
||||
withPrintWarning(() => {
|
||||
setMode(newMode);
|
||||
airductMutation.mutate(newMode);
|
||||
});
|
||||
};
|
||||
|
||||
const handlePartFanToggle = (enabled: boolean) => {
|
||||
withPrintWarning(() => {
|
||||
setPartFanEnabled(enabled);
|
||||
if (!enabled) {
|
||||
setPartFanSpeed(0);
|
||||
partFanMutation.mutate(0);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handlePartFanSpeed = (speed: number) => {
|
||||
withPrintWarning(() => {
|
||||
setPartFanSpeed(speed);
|
||||
setPartFanEnabled(speed > 0);
|
||||
partFanMutation.mutate(speed);
|
||||
});
|
||||
};
|
||||
|
||||
const handleAuxFanToggle = (enabled: boolean) => {
|
||||
withPrintWarning(() => {
|
||||
setAuxFanEnabled(enabled);
|
||||
if (!enabled) {
|
||||
setAuxFanSpeed(0);
|
||||
auxFanMutation.mutate(0);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleAuxFanSpeed = (speed: number) => {
|
||||
withPrintWarning(() => {
|
||||
setAuxFanSpeed(speed);
|
||||
setAuxFanEnabled(speed > 0);
|
||||
auxFanMutation.mutate(speed);
|
||||
});
|
||||
};
|
||||
|
||||
const handleExhaustFanToggle = (enabled: boolean) => {
|
||||
withPrintWarning(() => {
|
||||
setExhaustFanEnabled(enabled);
|
||||
if (!enabled) {
|
||||
setExhaustFanSpeed(0);
|
||||
chamberFanMutation.mutate(0);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleExhaustFanSpeed = (speed: number) => {
|
||||
withPrintWarning(() => {
|
||||
setExhaustFanSpeed(speed);
|
||||
setExhaustFanEnabled(speed > 0);
|
||||
chamberFanMutation.mutate(speed);
|
||||
});
|
||||
};
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (showPrintWarning) {
|
||||
handleWarningCancel();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose, showPrintWarning]);
|
||||
|
||||
const modeDescriptions = {
|
||||
cooling: 'Suitable for PLA, PETG, TPU. Filters and cools the chamber air.',
|
||||
heating: 'Suitable for ABS, ASA, PC, PA. Circulates and heats chamber air.',
|
||||
};
|
||||
|
||||
const FanIcon = () => (
|
||||
<img src="/icons/ventilation.svg" alt="" className="w-5 h-5 icon-theme" />
|
||||
);
|
||||
|
||||
const HeatIcon = () => (
|
||||
<img src="/icons/chamber.svg" alt="" className="w-5 h-5 icon-theme" />
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-lg bg-bambu-dark-secondary rounded-2xl shadow-2xl border border-bambu-dark-tertiary overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Print Warning Overlay */}
|
||||
{showPrintWarning && (
|
||||
<PrintWarningDialog
|
||||
onConfirm={handleWarningConfirm}
|
||||
onCancel={handleWarningCancel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 bg-bambu-dark border-b border-bambu-dark-tertiary">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-bambu-green/20 flex items-center justify-center">
|
||||
<img src="/icons/ventilation.svg" alt="" className="w-5 h-5 icon-green" />
|
||||
</div>
|
||||
<span className="text-base font-semibold text-white">Air Condition</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-bambu-gray hover:bg-bambu-dark-tertiary hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-5 space-y-5">
|
||||
{/* Print Warning Banner */}
|
||||
{isPrinting && (
|
||||
<div className="flex items-center gap-2 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-500 flex-shrink-0" />
|
||||
<span className="text-xs text-yellow-500">
|
||||
Print in progress. Changes may affect print quality.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mode Toggle */}
|
||||
<div className="bg-bambu-dark rounded-xl p-1.5 flex gap-1.5 shadow-inner">
|
||||
<button
|
||||
onClick={() => handleModeChange('cooling')}
|
||||
disabled={isDisabled || airductMutation.isPending}
|
||||
className={`flex-1 py-3 px-4 rounded-lg flex items-center justify-center gap-2 font-medium transition-all disabled:opacity-50 ${
|
||||
mode === 'cooling'
|
||||
? 'bg-bambu-green text-white shadow-lg'
|
||||
: 'text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
|
||||
}`}
|
||||
>
|
||||
<Wind className="w-4 h-4" />
|
||||
Cooling
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleModeChange('heating')}
|
||||
disabled={isDisabled || airductMutation.isPending}
|
||||
className={`flex-1 py-3 px-4 rounded-lg flex items-center justify-center gap-2 font-medium transition-all disabled:opacity-50 ${
|
||||
mode === 'heating'
|
||||
? 'bg-bambu-green text-white shadow-lg'
|
||||
: 'text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
|
||||
}`}
|
||||
>
|
||||
<Flame className="w-4 h-4" />
|
||||
Heating
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mode Description */}
|
||||
<p className="text-sm text-bambu-gray text-center px-2">{modeDescriptions[mode]}</p>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="border-t border-bambu-dark-tertiary" />
|
||||
|
||||
{/* Fan Controls Grid */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Part Fan - always has speed control */}
|
||||
<FanCard
|
||||
name="Part"
|
||||
icon={<FanIcon />}
|
||||
speed={partFanSpeed}
|
||||
enabled={partFanEnabled}
|
||||
onToggle={handlePartFanToggle}
|
||||
onSpeedChange={handlePartFanSpeed}
|
||||
disabled={isDisabled}
|
||||
showSpeedControl={true}
|
||||
/>
|
||||
|
||||
{/* Aux Fan */}
|
||||
<FanCard
|
||||
name="Aux"
|
||||
icon={<FanIcon />}
|
||||
speed={auxFanSpeed}
|
||||
enabled={auxFanEnabled}
|
||||
onToggle={handleAuxFanToggle}
|
||||
onSpeedChange={handleAuxFanSpeed}
|
||||
disabled={isDisabled}
|
||||
showSpeedControl={mode === 'cooling'}
|
||||
/>
|
||||
|
||||
{/* Exhaust Fan */}
|
||||
<FanCard
|
||||
name="Exhaust"
|
||||
icon={<FanIcon />}
|
||||
speed={exhaustFanSpeed}
|
||||
enabled={exhaustFanEnabled}
|
||||
onToggle={handleExhaustFanToggle}
|
||||
onSpeedChange={handleExhaustFanSpeed}
|
||||
disabled={isDisabled}
|
||||
showSpeedControl={mode === 'cooling'}
|
||||
/>
|
||||
|
||||
{/* Heat Status */}
|
||||
<div className="bg-bambu-dark rounded-xl p-4 shadow-lg border border-bambu-dark-tertiary/50">
|
||||
<div className="flex items-center gap-2.5 mb-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-bambu-dark-tertiary flex items-center justify-center">
|
||||
<HeatIcon />
|
||||
</div>
|
||||
<span className="text-white font-medium">Heat</span>
|
||||
</div>
|
||||
<div className="p-3 text-center">
|
||||
<span className="text-xl font-semibold text-bambu-green">
|
||||
{mode === 'heating' ? 'Auto' : 'Off'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { api } from '../../api/client';
|
||||
import type { Printer, PrinterStatus } from '../../api/client';
|
||||
import { X, AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface SpeedModalProps {
|
||||
printer: Printer;
|
||||
status: PrinterStatus | null | undefined;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type SpeedMode = 1 | 2 | 3 | 4;
|
||||
|
||||
interface SpeedOption {
|
||||
mode: SpeedMode;
|
||||
name: string;
|
||||
description: string;
|
||||
percentage: string;
|
||||
}
|
||||
|
||||
const SPEED_OPTIONS: SpeedOption[] = [
|
||||
{ mode: 1, name: 'Silent', description: 'Quieter printing, slower speed', percentage: '50%' },
|
||||
{ mode: 2, name: 'Standard', description: 'Balanced speed and quality', percentage: '100%' },
|
||||
{ mode: 3, name: 'Sport', description: 'Faster printing, moderate noise', percentage: '124%' },
|
||||
{ mode: 4, name: 'Ludicrous', description: 'Maximum speed', percentage: '166%' },
|
||||
];
|
||||
|
||||
export function SpeedModal({ printer, status, onClose }: SpeedModalProps) {
|
||||
const isConnected = status?.connected ?? false;
|
||||
const isPrinting = status?.state === 'RUNNING' || status?.state === 'PRINTING';
|
||||
// Speed can only be changed during a print
|
||||
const isDisabled = !isConnected || !isPrinting;
|
||||
|
||||
// Initialize from printer status
|
||||
const initialSpeed = (status?.speed_level ?? 2) as SpeedMode;
|
||||
const [selectedSpeed, setSelectedSpeed] = useState<SpeedMode>(initialSpeed);
|
||||
|
||||
const speedMutation = useMutation({
|
||||
mutationFn: (mode: number) => api.setPrintSpeed(printer.id, mode),
|
||||
});
|
||||
|
||||
const handleSpeedChange = (mode: SpeedMode) => {
|
||||
if (mode === selectedSpeed || isDisabled) return;
|
||||
setSelectedSpeed(mode);
|
||||
speedMutation.mutate(mode);
|
||||
};
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-md bg-bambu-dark-secondary rounded-2xl shadow-2xl border border-bambu-dark-tertiary overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 bg-bambu-dark border-b border-bambu-dark-tertiary">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-bambu-green/20 flex items-center justify-center">
|
||||
<img src="/icons/speed.svg" alt="" className="w-5 h-5 icon-green" />
|
||||
</div>
|
||||
<span className="text-base font-semibold text-white">Print Speed</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-bambu-gray hover:bg-bambu-dark-tertiary hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-5 space-y-4">
|
||||
{/* Info Banner */}
|
||||
{!isPrinting ? (
|
||||
<div className="flex items-center gap-2 p-3 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg">
|
||||
<AlertTriangle className="w-4 h-4 text-bambu-gray flex-shrink-0" />
|
||||
<span className="text-xs text-bambu-gray">
|
||||
No print in progress. Speed can only be changed during printing.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-500 flex-shrink-0" />
|
||||
<span className="text-xs text-yellow-500">
|
||||
Speed changes take effect immediately.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Speed Options */}
|
||||
<div className="space-y-2">
|
||||
{SPEED_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.mode}
|
||||
onClick={() => handleSpeedChange(option.mode)}
|
||||
disabled={isDisabled || speedMutation.isPending}
|
||||
className={`w-full p-4 rounded-xl flex items-center justify-between transition-all disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
selectedSpeed === option.mode
|
||||
? 'bg-bambu-green text-white shadow-lg'
|
||||
: 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="font-medium">{option.name}</span>
|
||||
<span className={`text-xs ${selectedSpeed === option.mode ? 'text-white/80' : 'text-bambu-gray'}`}>
|
||||
{option.description}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`text-lg font-semibold ${selectedSpeed === option.mode ? 'text-white' : 'text-bambu-gray'}`}>
|
||||
{option.percentage}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { api } from '../../api/client';
|
||||
import type { PrinterStatus } from '../../api/client';
|
||||
import type { Printer, PrinterStatus } from '../../api/client';
|
||||
import { AirConditionModal } from './AirConditionModal';
|
||||
import { SpeedModal } from './SpeedModal';
|
||||
|
||||
interface Temperatures {
|
||||
bed?: number;
|
||||
@@ -19,33 +21,38 @@ interface Temperatures {
|
||||
}
|
||||
|
||||
interface TemperatureColumnProps {
|
||||
printerId: number;
|
||||
printer: Printer;
|
||||
status: PrinterStatus | null | undefined;
|
||||
nozzleCount: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
type EditingField = 'nozzle' | 'nozzle_2' | 'bed' | null;
|
||||
type EditingField = 'nozzle' | 'nozzle_2' | 'bed' | 'chamber' | null;
|
||||
|
||||
export function TemperatureColumn({ printerId, status, nozzleCount, disabled = false }: TemperatureColumnProps) {
|
||||
export function TemperatureColumn({ printer, status, disabled = false }: TemperatureColumnProps) {
|
||||
const temps = (status?.temperatures ?? {}) as Temperatures;
|
||||
const isDualNozzle = nozzleCount > 1;
|
||||
const isDualNozzle = printer.nozzle_count > 1;
|
||||
const isConnected = (status?.connected ?? false) && !disabled;
|
||||
|
||||
const [editing, setEditing] = useState<EditingField>(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [showAirConditionModal, setShowAirConditionModal] = useState(false);
|
||||
const [showSpeedModal, setShowSpeedModal] = useState(false);
|
||||
|
||||
const bedMutation = useMutation({
|
||||
mutationFn: (target: number) => api.setBedTemperature(printerId, target),
|
||||
mutationFn: (target: number) => api.setBedTemperature(printer.id, target),
|
||||
});
|
||||
|
||||
const nozzleMutation = useMutation({
|
||||
mutationFn: ({ target, nozzle }: { target: number; nozzle: number }) =>
|
||||
api.setNozzleTemperature(printerId, target, nozzle),
|
||||
api.setNozzleTemperature(printer.id, target, nozzle),
|
||||
});
|
||||
|
||||
const chamberMutation = useMutation({
|
||||
mutationFn: (target: number) => api.setChamberTemperature(printer.id, target),
|
||||
});
|
||||
|
||||
const lightMutation = useMutation({
|
||||
mutationFn: (on: boolean) => api.setChamberLight(printerId, on),
|
||||
mutationFn: (on: boolean) => api.setChamberLight(printer.id, on),
|
||||
});
|
||||
|
||||
const startEditing = (field: EditingField, currentValue: number) => {
|
||||
@@ -76,6 +83,8 @@ export function TemperatureColumn({ printerId, status, nozzleCount, disabled = f
|
||||
// nozzle_2 field = RIGHT nozzle display
|
||||
// H2D: RIGHT is T0/default (index 0)
|
||||
nozzleMutation.mutate({ target, nozzle: 0 });
|
||||
} else if (editing === 'chamber') {
|
||||
chamberMutation.mutate(target);
|
||||
}
|
||||
cancelEditing();
|
||||
};
|
||||
@@ -132,6 +141,7 @@ export function TemperatureColumn({ printerId, status, nozzleCount, disabled = f
|
||||
};
|
||||
|
||||
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">
|
||||
@@ -183,38 +193,69 @@ export function TemperatureColumn({ printerId, status, nozzleCount, disabled = f
|
||||
{renderTargetTemp('bed', temps.bed_target ?? 0)}
|
||||
</div>
|
||||
|
||||
{/* Chamber - read only (target set by print file or display) */}
|
||||
{/* Chamber - editable target */}
|
||||
<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 ${isChamberHeating ? 'icon-heating' : 'icon-theme'}`} />
|
||||
</div>
|
||||
{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">/{Math.round(temps.chamber_target ?? 0)} °C</span>
|
||||
{renderTargetTemp('chamber', temps.chamber_target ?? 0)}
|
||||
</div>
|
||||
|
||||
{/* Air Condition - button */}
|
||||
{/* Air Condition - full width button */}
|
||||
<button
|
||||
onClick={() => setShowAirConditionModal(true)}
|
||||
disabled={isDisabled}
|
||||
className="flex items-center gap-2 hover:opacity-80 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="flex items-center justify-center gap-2 w-full py-2 rounded-md bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<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>
|
||||
<img src="/icons/ventilation.svg" alt="" className="w-5 h-5 icon-theme" />
|
||||
<span className="text-xs text-bambu-gray">Air Condition</span>
|
||||
</button>
|
||||
|
||||
{/* Lamp - button (toggle, state not tracked in status yet) */}
|
||||
<button
|
||||
onClick={() => lightMutation.mutate(true)}
|
||||
disabled={isDisabled || lightMutation.isPending}
|
||||
className="flex items-center gap-2 hover:opacity-80 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<div className="w-5 h-5 flex items-center justify-center flex-shrink-0">
|
||||
<img src="/icons/lamp.svg" alt="" className="w-4 icon-theme" />
|
||||
</div>
|
||||
<span className="text-sm text-bambu-gray">Lamp</span>
|
||||
</button>
|
||||
{/* Speed & Lamp - half width each */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Speed */}
|
||||
<button
|
||||
onClick={() => setShowSpeedModal(true)}
|
||||
disabled={isDisabled}
|
||||
className="flex-1 flex flex-col items-center py-2 rounded-md bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<img src="/icons/speed.svg" alt="" className="w-5 h-5 icon-theme" />
|
||||
<span className="text-[10px] text-bambu-gray mt-0.5">
|
||||
{status?.speed_level === 1 ? '50%' : status?.speed_level === 3 ? '124%' : status?.speed_level === 4 ? '166%' : '100%'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Lamp */}
|
||||
<button
|
||||
onClick={() => lightMutation.mutate(!(status?.chamber_light ?? false))}
|
||||
disabled={isDisabled || lightMutation.isPending}
|
||||
className="flex-1 flex flex-col items-center py-2 rounded-md bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary border border-bambu-dark-tertiary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<img src="/icons/lamp.svg" alt="" className={`w-5 h-5 ${status?.chamber_light ? 'icon-green' : 'icon-theme'}`} />
|
||||
<span className="text-[10px] text-bambu-gray mt-0.5">Lamp</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Air Condition Modal */}
|
||||
{showAirConditionModal && (
|
||||
<AirConditionModal
|
||||
printer={printer}
|
||||
status={status}
|
||||
onClose={() => setShowAirConditionModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Speed Modal */}
|
||||
{showSpeedModal && (
|
||||
<SpeedModal
|
||||
printer={printer}
|
||||
status={status}
|
||||
onClose={() => setShowSpeedModal(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -220,11 +220,10 @@ export function ControlPage() {
|
||||
<div className="flex gap-4 bg-bambu-dark-secondary rounded-[8px] p-4 overflow-hidden" style={{ minHeight: '300px' }}>
|
||||
{/* Temperature Column */}
|
||||
<TemperatureColumn
|
||||
printerId={selectedPrinter.id}
|
||||
status={selectedStatus}
|
||||
nozzleCount={selectedPrinter.nozzle_count}
|
||||
disabled={isCalibrating}
|
||||
/>
|
||||
printer={selectedPrinter}
|
||||
status={selectedStatus}
|
||||
disabled={isCalibrating}
|
||||
/>
|
||||
|
||||
{/* Movement Column */}
|
||||
<div className="flex-1 flex gap-6 items-center justify-center">
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -7,8 +7,8 @@
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
|
||||
<script type="module" crossorigin src="/assets/index-1LVQBXOF.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CH7yvvuS.css">
|
||||
<script type="module" crossorigin src="/assets/index-DzdPY90c.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DyjgQh1m.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user