| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525 |
- 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>
- )}
- </>
- )}
- {/* 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)}
- />
- )}
- </>
- );
- }
|