mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 07:01:40 +02:00
355 lines
15 KiB
TypeScript
355 lines
15 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 } from 'lucide-react';
|
|
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';
|
|
|
|
interface SmartPlugCardProps {
|
|
plug: SmartPlug;
|
|
onEdit: (plug: SmartPlug) => void;
|
|
}
|
|
|
|
export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
|
|
const queryClient = useQueryClient();
|
|
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, refetch: refetchStatus } = 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
|
|
const controlMutation = useMutation({
|
|
mutationFn: (action: 'on' | 'off' | 'toggle') => api.controlSmartPlug(plug.id, action),
|
|
onSuccess: () => {
|
|
refetchStatus();
|
|
},
|
|
});
|
|
|
|
// 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] });
|
|
}
|
|
},
|
|
});
|
|
|
|
// Delete mutation
|
|
const deleteMutation = useMutation({
|
|
mutationFn: () => api.deleteSmartPlug(plug.id),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
|
|
},
|
|
});
|
|
|
|
const isOn = status?.state === 'ON';
|
|
const isReachable = status?.reachable ?? false;
|
|
const isPending = controlMutation.isPending;
|
|
|
|
return (
|
|
<>
|
|
<Card className="relative">
|
|
<CardContent className="p-4">
|
|
{/* Header Row */}
|
|
<div className="flex items-start justify-between mb-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className={`p-2 rounded-lg ${isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20'}`}>
|
|
<Plug className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
|
|
</div>
|
|
<div>
|
|
<h3 className="font-medium text-white">{plug.name}</h3>
|
|
<p className="text-sm text-bambu-gray">{plug.ip_address}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status indicator */}
|
|
<div className="flex items-center gap-2">
|
|
{statusLoading ? (
|
|
<Loader2 className="w-4 h-4 text-bambu-gray animate-spin" />
|
|
) : isReachable ? (
|
|
<div className="flex items-center gap-1 text-sm">
|
|
<Wifi className="w-4 h-4 text-bambu-green" />
|
|
<span className={isOn ? 'text-bambu-green' : 'text-bambu-gray'}>{status?.state || 'Unknown'}</span>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-1 text-sm text-red-400">
|
|
<WifiOff className="w-4 h-4" />
|
|
<span>Offline</span>
|
|
</div>
|
|
)}
|
|
</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">Linked to: </span>
|
|
<span className="text-sm text-white">{linkedPrinter.name}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Feature Badges */}
|
|
{(plug.power_alert_enabled || plug.schedule_enabled) && (
|
|
<div className="flex flex-wrap gap-1.5 mb-3">
|
|
{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" />
|
|
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
|
|
? `On ${plug.schedule_on_time}`
|
|
: `Off ${plug.schedule_off_time}`}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Quick Controls */}
|
|
<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" />}
|
|
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" />}
|
|
Off
|
|
</Button>
|
|
</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" />
|
|
Automation Settings
|
|
</span>
|
|
<span>{isExpanded ? '-' : '+'}</span>
|
|
</button>
|
|
|
|
{/* Expanded Settings */}
|
|
{isExpanded && (
|
|
<div className="pt-3 border-t border-bambu-dark-tertiary space-y-4">
|
|
{/* Enabled Toggle */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-white">Enabled</p>
|
|
<p className="text-xs text-bambu-gray">Enable automation for this plug</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">Auto On</p>
|
|
<p className="text-xs text-bambu-gray">Turn on when print starts</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">Auto Off</p>
|
|
<p className="text-xs text-bambu-gray">Turn off when print completes (one-shot)</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>
|
|
|
|
{/* 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">Turn Off Delay Mode</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" />
|
|
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" />
|
|
Temp
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{plug.off_delay_mode === 'time' ? (
|
|
<div>
|
|
<label className="block text-xs text-bambu-gray mb-1">Delay (minutes)</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">Temperature threshold (C)</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">Turns off when nozzle cools below this temperature</p>
|
|
</div>
|
|
)}
|
|
</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" />
|
|
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="Delete Smart Plug"
|
|
message={`Are you sure you want to delete "${plug.name}"? This cannot be undone.`}
|
|
confirmText="Delete"
|
|
variant="danger"
|
|
onConfirm={() => {
|
|
deleteMutation.mutate();
|
|
setShowDeleteConfirm(false);
|
|
}}
|
|
onCancel={() => setShowDeleteConfirm(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* Power On Confirmation */}
|
|
{showPowerOnConfirm && (
|
|
<ConfirmModal
|
|
title="Turn On Smart Plug"
|
|
message={`Are you sure you want to turn on "${plug.name}"?`}
|
|
confirmText="Turn On"
|
|
variant="default"
|
|
onConfirm={() => {
|
|
controlMutation.mutate('on');
|
|
setShowPowerOnConfirm(false);
|
|
}}
|
|
onCancel={() => setShowPowerOnConfirm(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* Power Off Confirmation */}
|
|
{showPowerOffConfirm && (
|
|
<ConfirmModal
|
|
title="Turn Off Smart Plug"
|
|
message={`Are you sure you want to turn off "${plug.name}"? This will cut power to the connected device.`}
|
|
confirmText="Turn Off"
|
|
variant="danger"
|
|
onConfirm={() => {
|
|
controlMutation.mutate('off');
|
|
setShowPowerOffConfirm(false);
|
|
}}
|
|
onCancel={() => setShowPowerOffConfirm(false)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|