mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-05 05:31:31 +02:00
Reporter Kyobinoyo asked for the equivalent of the existing print-finish auto-off but triggered when AMS drying ends. Two new SmartPlug columns: auto_off_after_drying (default false), off_delay_after_drying_minutes (default 10 — AMS chamber is hot post-cycle so longer cooldown than the print-finish default of 5). SQLite + Postgres migrations both idempotent. Trigger lives in BambuMQTTClient — per-AMS _previous_dry_times tracks the dry_time > 0 → 0 falling edge and fires a new on_drying_complete(ams_id) callback. Plumbed through PrinterManager.set_drying_complete_callback to SmartPlugManager.on_drying_complete(printer_id, db), which walks linked plugs and respects the per-plug toggle. Catches queue, ambient and manual drying identically because it observes firmware state, not scheduler intent. Frontend: single "Auto Off After Drying" toggle + delay input on the smart plug card, next to the existing print-finish auto-off section. Per-AMS plug routing (separate plug for AMS only, per-AMS targeting on dual-AMS printers) deferred — Bambuddy's plug model is plug→printer, so the trigger fires whenever any AMS on the linked printer finishes a cycle.
563 lines
27 KiB
TypeScript
563 lines
27 KiB
TypeScript
import { useState } from 'react';
|
|
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
|
|
import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid, ExternalLink, Home, Radio, Eye, Globe } from 'lucide-react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { api } from '../api/client';
|
|
import type { SmartPlug, SmartPlugUpdate } from '../api/client';
|
|
import { Card, CardContent } from './Card';
|
|
import { Button } from './Button';
|
|
import { ConfirmModal } from './ConfirmModal';
|
|
import { useToast } from '../contexts/ToastContext';
|
|
|
|
interface SmartPlugCardProps {
|
|
plug: SmartPlug;
|
|
onEdit: (plug: SmartPlug) => void;
|
|
}
|
|
|
|
export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
|
|
const { t } = useTranslation();
|
|
const queryClient = useQueryClient();
|
|
const { showToast } = useToast();
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
const [showPowerOnConfirm, setShowPowerOnConfirm] = useState(false);
|
|
const [showPowerOffConfirm, setShowPowerOffConfirm] = useState(false);
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
|
|
// Fetch current status
|
|
const { data: status, isLoading: statusLoading } = useQuery({
|
|
queryKey: ['smart-plug-status', plug.id],
|
|
queryFn: () => api.getSmartPlugStatus(plug.id),
|
|
refetchInterval: 30000, // Refresh every 30 seconds
|
|
});
|
|
|
|
// Fetch printers for linking
|
|
const { data: printers } = useQuery({
|
|
queryKey: ['printers'],
|
|
queryFn: api.getPrinters,
|
|
});
|
|
|
|
const linkedPrinter = printers?.find(p => p.id === plug.printer_id);
|
|
|
|
// Control mutation with optimistic updates
|
|
const controlMutation = useMutation({
|
|
mutationFn: (action: 'on' | 'off' | 'toggle') => api.controlSmartPlug(plug.id, action),
|
|
onMutate: async (action) => {
|
|
// Cancel any outgoing refetches
|
|
await queryClient.cancelQueries({ queryKey: ['smart-plug-status', plug.id] });
|
|
|
|
// Snapshot the previous value
|
|
const previousStatus = queryClient.getQueryData(['smart-plug-status', plug.id]);
|
|
|
|
// Optimistically update to the new value
|
|
const newState = action === 'on' ? 'ON' : action === 'off' ? 'OFF' : (status?.state === 'ON' ? 'OFF' : 'ON');
|
|
queryClient.setQueryData(['smart-plug-status', plug.id], (old: typeof status) => ({
|
|
...old,
|
|
state: newState,
|
|
}));
|
|
|
|
return { previousStatus };
|
|
},
|
|
onError: (_err, action, context) => {
|
|
// Rollback on error
|
|
if (context?.previousStatus) {
|
|
queryClient.setQueryData(['smart-plug-status', plug.id], context.previousStatus);
|
|
}
|
|
showToast(t('smartPlugs.failedToTurn', { action, name: plug.name }), 'error');
|
|
},
|
|
onSettled: () => {
|
|
// Refetch after a short delay to get actual state
|
|
setTimeout(() => {
|
|
queryClient.invalidateQueries({ queryKey: ['smart-plug-status', plug.id] });
|
|
queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
|
|
}, 1000);
|
|
},
|
|
});
|
|
|
|
// Update mutation
|
|
const updateMutation = useMutation({
|
|
mutationFn: (data: SmartPlugUpdate) => api.updateSmartPlug(plug.id, data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
|
|
// Also invalidate printer-specific smart plug queries to keep PrintersPage in sync
|
|
if (plug.printer_id) {
|
|
queryClient.invalidateQueries({ queryKey: ['smartPlugByPrinter', plug.printer_id] });
|
|
queryClient.invalidateQueries({ queryKey: ['scriptPlugsByPrinter', plug.printer_id] });
|
|
}
|
|
},
|
|
});
|
|
|
|
// Delete mutation
|
|
const deleteMutation = useMutation({
|
|
mutationFn: () => api.deleteSmartPlug(plug.id),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
|
|
// Also invalidate printer card HA entity queries
|
|
if (plug.printer_id) {
|
|
queryClient.invalidateQueries({ queryKey: ['scriptPlugsByPrinter', plug.printer_id] });
|
|
}
|
|
},
|
|
});
|
|
|
|
const isOn = status?.state === 'ON';
|
|
// For MQTT plugs, consider reachable if we have power data (even if backend says not reachable)
|
|
const hasMqttData = plug.plug_type === 'mqtt' && (status?.energy?.power !== null && status?.energy?.power !== undefined);
|
|
const isReachable = (status?.reachable ?? false) || hasMqttData;
|
|
const isPending = controlMutation.isPending;
|
|
|
|
// Generate admin URL with auto-login credentials (Tasmota only)
|
|
const getAdminUrl = () => {
|
|
if (plug.plug_type !== 'tasmota' || !plug.ip_address) return null;
|
|
const ip = plug.ip_address;
|
|
if (plug.username && plug.password) {
|
|
// Use HTTP Basic Auth in URL for auto-login
|
|
return `http://${encodeURIComponent(plug.username)}:${encodeURIComponent(plug.password)}@${ip}/`;
|
|
}
|
|
return `http://${ip}/`;
|
|
};
|
|
|
|
const adminUrl = getAdminUrl();
|
|
|
|
return (
|
|
<>
|
|
<Card className="relative">
|
|
<CardContent className="p-4">
|
|
{/* Header Row */}
|
|
<div className="flex items-start justify-between gap-2 mb-3">
|
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
|
<div className={`p-2 rounded-lg flex-shrink-0 ${
|
|
plug.plug_type === 'mqtt'
|
|
? (isReachable ? 'bg-teal-500/20' : 'bg-red-500/20')
|
|
: (isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20')
|
|
}`}>
|
|
{plug.plug_type === 'mqtt' ? (
|
|
<Radio className={`w-5 h-5 ${isReachable ? 'text-teal-400' : 'text-red-400'}`} />
|
|
) : plug.plug_type === 'homeassistant' ? (
|
|
<Home className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
|
|
) : plug.plug_type === 'rest' ? (
|
|
<Globe className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
|
|
) : (
|
|
<Plug className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
|
|
)}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h3 className="font-medium text-white truncate">{plug.name}</h3>
|
|
<p
|
|
className="text-sm text-bambu-gray truncate"
|
|
title={plug.plug_type === 'mqtt' ? plug.mqtt_topic ?? undefined : plug.plug_type === 'homeassistant' ? plug.ha_entity_id ?? undefined : plug.plug_type === 'rest' ? plug.rest_on_url ?? plug.rest_off_url ?? undefined : plug.ip_address ?? undefined}
|
|
>
|
|
{plug.plug_type === 'mqtt' ? plug.mqtt_topic : plug.plug_type === 'homeassistant' ? plug.ha_entity_id : plug.plug_type === 'rest' ? (plug.rest_on_url || plug.rest_off_url) : plug.ip_address}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status indicator */}
|
|
<div className="flex flex-col items-end gap-1 flex-shrink-0">
|
|
{statusLoading ? (
|
|
<Loader2 className="w-4 h-4 text-bambu-gray animate-spin" />
|
|
) : plug.plug_type === 'mqtt' ? (
|
|
/* MQTT plugs - show badge and checkmark when receiving data */
|
|
<div className="flex items-center gap-1.5 text-sm whitespace-nowrap">
|
|
<span className="px-1.5 py-0.5 bg-teal-500/20 text-teal-400 text-[10px] font-medium rounded flex-shrink-0">MQTT</span>
|
|
{isReachable && <span className="text-status-ok">✓</span>}
|
|
</div>
|
|
) : plug.plug_type === 'homeassistant' ? (
|
|
<div className="flex items-center gap-1 text-sm">
|
|
<span className="px-1 py-0.5 bg-blue-500/20 text-blue-400 text-[10px] font-medium rounded">HA</span>
|
|
<span className={isReachable ? (isOn ? 'text-status-ok' : 'text-bambu-gray') : 'text-status-error'}>
|
|
{isReachable ? (status?.state || '?') : t('smartPlugs.offline')}
|
|
</span>
|
|
</div>
|
|
) : plug.plug_type === 'rest' ? (
|
|
<div className="flex items-center gap-1 text-sm">
|
|
<span className="px-1 py-0.5 bg-purple-500/20 text-purple-400 text-[10px] font-medium rounded">REST</span>
|
|
<span className={isReachable ? (isOn ? 'text-status-ok' : 'text-bambu-gray') : 'text-status-error'}>
|
|
{isReachable ? (status?.state || '?') : t('smartPlugs.offline')}
|
|
</span>
|
|
</div>
|
|
) : isReachable ? (
|
|
<div className="flex items-center gap-1 text-sm">
|
|
<Wifi className="w-4 h-4 text-status-ok" />
|
|
<span className={isOn ? 'text-status-ok' : 'text-bambu-gray'}>{status?.state || t('smartPlugs.unknown')}</span>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-1 text-sm text-status-error">
|
|
<WifiOff className="w-4 h-4" />
|
|
<span>{t('smartPlugs.offline')}</span>
|
|
</div>
|
|
)}
|
|
{/* Admin page link - only for Tasmota */}
|
|
{adminUrl && (
|
|
<a
|
|
href={adminUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-1 px-2 py-0.5 bg-bambu-dark hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white text-xs rounded-full transition-colors"
|
|
title={t('smartPlugs.openPlugAdminPage')}
|
|
>
|
|
<ExternalLink className="w-3 h-3" />
|
|
{t('smartPlugs.admin')}
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Linked Printer */}
|
|
{linkedPrinter && (
|
|
<div className="mb-3 px-2 py-1.5 bg-bambu-dark rounded-lg">
|
|
<span className="text-xs text-bambu-gray">{t('smartPlugs.linkedTo')} </span>
|
|
<span className="text-sm text-white">{linkedPrinter.name}</span>
|
|
</div>
|
|
)}
|
|
|
|
|
|
{/* Feature Badges */}
|
|
{(plug.power_alert_enabled || plug.schedule_enabled || plug.plug_type === 'mqtt') && (
|
|
<div className="flex flex-wrap gap-1.5 mb-3">
|
|
{plug.plug_type === 'mqtt' && (
|
|
<span className="flex items-center gap-1 px-2 py-0.5 bg-teal-500/20 text-teal-400 text-xs rounded-full">
|
|
<Eye className="w-3 h-3" />
|
|
{t('smartPlugs.monitorOnly')}
|
|
</span>
|
|
)}
|
|
{plug.power_alert_enabled && (
|
|
<span className="flex items-center gap-1 px-2 py-0.5 bg-yellow-500/20 text-yellow-400 text-xs rounded-full">
|
|
<Bell className="w-3 h-3" />
|
|
{t('smartPlugs.alerts')}
|
|
</span>
|
|
)}
|
|
{plug.schedule_enabled && (
|
|
<span className="flex items-center gap-1 px-2 py-0.5 bg-blue-500/20 text-blue-400 text-xs rounded-full">
|
|
<Calendar className="w-3 h-3" />
|
|
{plug.schedule_on_time && plug.schedule_off_time
|
|
? `${plug.schedule_on_time} - ${plug.schedule_off_time}`
|
|
: plug.schedule_on_time
|
|
? t('smartPlugs.scheduleOn', { time: plug.schedule_on_time })
|
|
: t('smartPlugs.scheduleOff', { time: plug.schedule_off_time })}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Quick Controls - hidden for MQTT plugs (monitor-only) */}
|
|
{plug.plug_type !== 'mqtt' && (
|
|
<div className="flex gap-2 mb-3">
|
|
<Button
|
|
size="sm"
|
|
variant={isOn ? 'primary' : 'secondary'}
|
|
disabled={!isReachable || isPending}
|
|
onClick={() => setShowPowerOnConfirm(true)}
|
|
className="flex-1"
|
|
>
|
|
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
|
|
{t('smartPlugs.on')}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant={!isOn ? 'primary' : 'secondary'}
|
|
disabled={!isReachable || isPending}
|
|
onClick={() => setShowPowerOffConfirm(true)}
|
|
className="flex-1"
|
|
>
|
|
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
|
|
{t('smartPlugs.off')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Energy display for MQTT plugs */}
|
|
{plug.plug_type === 'mqtt' && status?.energy && (
|
|
<div className="flex gap-2 mb-3 px-3 py-2 bg-bambu-dark rounded-lg">
|
|
{status.energy.power !== null && status.energy.power !== undefined && (
|
|
<div className="flex-1 text-center">
|
|
<p className="text-lg font-semibold text-white">{Math.round(status.energy.power)}W</p>
|
|
<p className="text-xs text-bambu-gray">{t('smartPlugs.power')}</p>
|
|
</div>
|
|
)}
|
|
{status.energy.today !== null && status.energy.today !== undefined && (
|
|
<div className="flex-1 text-center border-l border-bambu-dark-tertiary">
|
|
<p className="text-lg font-semibold text-white">{status.energy.today.toFixed(3)}</p>
|
|
<p className="text-xs text-bambu-gray">{t('smartPlugs.kwhToday')}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Toggle Settings Panel */}
|
|
<button
|
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
className="w-full flex items-center justify-between py-2 text-sm text-bambu-gray hover:text-white transition-colors"
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<Settings2 className="w-4 h-4" />
|
|
{plug.plug_type === 'mqtt' ? t('smartPlugs.settings') : t('smartPlugs.automationSettings')}
|
|
</span>
|
|
<span>{isExpanded ? '-' : '+'}</span>
|
|
</button>
|
|
|
|
{/* Expanded Settings */}
|
|
{isExpanded && (
|
|
<div className="pt-3 border-t border-bambu-dark-tertiary space-y-4">
|
|
{/* Show in Switchbar Toggle */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<LayoutGrid className="w-4 h-4 text-bambu-green" />
|
|
<div>
|
|
<p className="text-sm text-white">{t('smartPlugs.showInSwitchbar')}</p>
|
|
<p className="text-xs text-bambu-gray">{t('smartPlugs.quickAccessSidebar')}</p>
|
|
</div>
|
|
</div>
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={plug.show_in_switchbar}
|
|
onChange={(e) => updateMutation.mutate({ show_in_switchbar: 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>
|
|
|
|
{/* Automation controls - only for controllable plugs (not MQTT) */}
|
|
{plug.plug_type !== 'mqtt' && (
|
|
<>
|
|
{/* Enabled Toggle */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-white">{t('smartPlugs.enabled')}</p>
|
|
<p className="text-xs text-bambu-gray">{t('smartPlugs.enableAutomation')}</p>
|
|
</div>
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={plug.enabled}
|
|
onChange={(e) => updateMutation.mutate({ enabled: 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>
|
|
|
|
{/* Auto On */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-white">{t('smartPlugs.autoOn')}</p>
|
|
<p className="text-xs text-bambu-gray">{t('smartPlugs.autoOnDescription')}</p>
|
|
</div>
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={plug.auto_on}
|
|
onChange={(e) => updateMutation.mutate({ auto_on: 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>
|
|
|
|
{/* Auto Off */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-white">{t('smartPlugs.autoOff')}</p>
|
|
<p className="text-xs text-bambu-gray">{t('smartPlugs.autoOffDescription')}</p>
|
|
</div>
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={plug.auto_off}
|
|
onChange={(e) => updateMutation.mutate({ auto_off: 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>
|
|
|
|
{/* 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">
|
|
<div>
|
|
<p className="text-sm text-white mb-2">{t('smartPlugs.turnOffDelayMode')}</p>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => updateMutation.mutate({ off_delay_mode: 'time' })}
|
|
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
|
|
plug.off_delay_mode === 'time'
|
|
? 'bg-bambu-green text-white'
|
|
: 'bg-bambu-dark text-bambu-gray hover:text-white'
|
|
}`}
|
|
>
|
|
<Clock className="w-4 h-4" />
|
|
{t('smartPlugs.time')}
|
|
</button>
|
|
<button
|
|
onClick={() => updateMutation.mutate({ off_delay_mode: 'temperature' })}
|
|
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
|
|
plug.off_delay_mode === 'temperature'
|
|
? 'bg-bambu-green text-white'
|
|
: 'bg-bambu-dark text-bambu-gray hover:text-white'
|
|
}`}
|
|
>
|
|
<Thermometer className="w-4 h-4" />
|
|
{t('smartPlugs.temp')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{plug.off_delay_mode === 'time' ? (
|
|
<div>
|
|
<label className="block text-xs text-bambu-gray mb-1">{t('smartPlugs.delayMinutes')}</label>
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
max="60"
|
|
value={plug.off_delay_minutes}
|
|
onChange={(e) => updateMutation.mutate({ off_delay_minutes: parseInt(e.target.value) || 5 })}
|
|
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<label className="block text-xs text-bambu-gray mb-1">{t('smartPlugs.tempThreshold')}</label>
|
|
<input
|
|
type="number"
|
|
min="30"
|
|
max="100"
|
|
value={plug.off_temp_threshold}
|
|
onChange={(e) => updateMutation.mutate({ off_temp_threshold: parseInt(e.target.value) || 70 })}
|
|
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
|
|
/>
|
|
<p className="text-xs text-bambu-gray mt-1">{t('smartPlugs.tempThresholdDescription')}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Auto Off After Drying (#1349) — independent of the
|
|
print-finish auto-off above. Uses its own delay because
|
|
the AMS chamber is hot post-cycle and users often want
|
|
more cooldown than the print-finish default. Fires when
|
|
any AMS attached to the linked printer finishes a dry
|
|
cycle. */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-white">{t('smartPlugs.autoOffAfterDrying')}</p>
|
|
<p className="text-xs text-bambu-gray">{t('smartPlugs.autoOffAfterDryingDescription')}</p>
|
|
</div>
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={plug.auto_off_after_drying}
|
|
onChange={(e) => updateMutation.mutate({ auto_off_after_drying: 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>
|
|
|
|
{plug.auto_off_after_drying && (
|
|
<div className="pl-4 border-l-2 border-bambu-dark-tertiary">
|
|
<label className="block text-xs text-bambu-gray mb-1">{t('smartPlugs.delayAfterDryingMinutes')}</label>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
max="120"
|
|
value={plug.off_delay_after_drying_minutes}
|
|
onChange={(e) => updateMutation.mutate({ off_delay_after_drying_minutes: parseInt(e.target.value) || 10 })}
|
|
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Action Buttons */}
|
|
<div className="flex gap-2 pt-2">
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
onClick={() => onEdit(plug)}
|
|
className="flex-1"
|
|
>
|
|
<Edit2 className="w-4 h-4" />
|
|
{t('smartPlugs.edit')}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
onClick={() => setShowDeleteConfirm(true)}
|
|
className="text-red-400 hover:text-red-300"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Delete Confirmation */}
|
|
{showDeleteConfirm && (
|
|
<ConfirmModal
|
|
title={t('smartPlugs.deleteSmartPlug')}
|
|
message={t('smartPlugs.deleteConfirm', { name: plug.name })}
|
|
confirmText={t('smartPlugs.delete')}
|
|
variant="danger"
|
|
onConfirm={() => {
|
|
deleteMutation.mutate();
|
|
setShowDeleteConfirm(false);
|
|
}}
|
|
onCancel={() => setShowDeleteConfirm(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* Power On Confirmation */}
|
|
{showPowerOnConfirm && (
|
|
<ConfirmModal
|
|
title={t('smartPlugs.turnOnSmartPlug')}
|
|
message={t('smartPlugs.turnOnConfirm', { name: plug.name })}
|
|
confirmText={t('smartPlugs.turnOn')}
|
|
variant="default"
|
|
onConfirm={() => {
|
|
controlMutation.mutate('on');
|
|
setShowPowerOnConfirm(false);
|
|
}}
|
|
onCancel={() => setShowPowerOnConfirm(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* Power Off Confirmation */}
|
|
{showPowerOffConfirm && (
|
|
<ConfirmModal
|
|
title={t('smartPlugs.turnOffSmartPlug')}
|
|
message={t('smartPlugs.turnOffConfirm', { name: plug.name })}
|
|
confirmText={t('smartPlugs.turnOff')}
|
|
variant="danger"
|
|
onConfirm={() => {
|
|
controlMutation.mutate('off');
|
|
setShowPowerOffConfirm(false);
|
|
}}
|
|
onCancel={() => setShowPowerOffConfirm(false)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|