SmartPlugCard.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. import { useState } from 'react';
  2. import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
  3. import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid, ExternalLink } from 'lucide-react';
  4. import { api } from '../api/client';
  5. import type { SmartPlug, SmartPlugUpdate } from '../api/client';
  6. import { Card, CardContent } from './Card';
  7. import { Button } from './Button';
  8. import { ConfirmModal } from './ConfirmModal';
  9. interface SmartPlugCardProps {
  10. plug: SmartPlug;
  11. onEdit: (plug: SmartPlug) => void;
  12. }
  13. export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
  14. const queryClient = useQueryClient();
  15. const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
  16. const [showPowerOnConfirm, setShowPowerOnConfirm] = useState(false);
  17. const [showPowerOffConfirm, setShowPowerOffConfirm] = useState(false);
  18. const [isExpanded, setIsExpanded] = useState(false);
  19. // Fetch current status
  20. const { data: status, isLoading: statusLoading, refetch: refetchStatus } = useQuery({
  21. queryKey: ['smart-plug-status', plug.id],
  22. queryFn: () => api.getSmartPlugStatus(plug.id),
  23. refetchInterval: 30000, // Refresh every 30 seconds
  24. });
  25. // Fetch printers for linking
  26. const { data: printers } = useQuery({
  27. queryKey: ['printers'],
  28. queryFn: api.getPrinters,
  29. });
  30. const linkedPrinter = printers?.find(p => p.id === plug.printer_id);
  31. // Control mutation
  32. const controlMutation = useMutation({
  33. mutationFn: (action: 'on' | 'off' | 'toggle') => api.controlSmartPlug(plug.id, action),
  34. onSuccess: () => {
  35. refetchStatus();
  36. },
  37. });
  38. // Update mutation
  39. const updateMutation = useMutation({
  40. mutationFn: (data: SmartPlugUpdate) => api.updateSmartPlug(plug.id, data),
  41. onSuccess: () => {
  42. queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
  43. // Also invalidate printer-specific smart plug queries to keep PrintersPage in sync
  44. if (plug.printer_id) {
  45. queryClient.invalidateQueries({ queryKey: ['smartPlugByPrinter', plug.printer_id] });
  46. }
  47. },
  48. });
  49. // Delete mutation
  50. const deleteMutation = useMutation({
  51. mutationFn: () => api.deleteSmartPlug(plug.id),
  52. onSuccess: () => {
  53. queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
  54. },
  55. });
  56. const isOn = status?.state === 'ON';
  57. const isReachable = status?.reachable ?? false;
  58. const isPending = controlMutation.isPending;
  59. // Generate admin URL with auto-login credentials
  60. const getAdminUrl = () => {
  61. const ip = plug.ip_address;
  62. if (plug.username && plug.password) {
  63. // Use HTTP Basic Auth in URL for auto-login
  64. return `http://${encodeURIComponent(plug.username)}:${encodeURIComponent(plug.password)}@${ip}/`;
  65. }
  66. return `http://${ip}/`;
  67. };
  68. return (
  69. <>
  70. <Card className="relative">
  71. <CardContent className="p-4">
  72. {/* Header Row */}
  73. <div className="flex items-start justify-between mb-3">
  74. <div className="flex items-center gap-3">
  75. <div className={`p-2 rounded-lg ${isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20'}`}>
  76. <Plug className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
  77. </div>
  78. <div>
  79. <h3 className="font-medium text-white">{plug.name}</h3>
  80. <p className="text-sm text-bambu-gray">{plug.ip_address}</p>
  81. </div>
  82. </div>
  83. {/* Status indicator */}
  84. <div className="flex flex-col items-end gap-1">
  85. {statusLoading ? (
  86. <Loader2 className="w-4 h-4 text-bambu-gray animate-spin" />
  87. ) : isReachable ? (
  88. <div className="flex items-center gap-1 text-sm">
  89. <Wifi className="w-4 h-4 text-bambu-green" />
  90. <span className={isOn ? 'text-bambu-green' : 'text-bambu-gray'}>{status?.state || 'Unknown'}</span>
  91. </div>
  92. ) : (
  93. <div className="flex items-center gap-1 text-sm text-red-400">
  94. <WifiOff className="w-4 h-4" />
  95. <span>Offline</span>
  96. </div>
  97. )}
  98. {/* Admin page link */}
  99. <a
  100. href={getAdminUrl()}
  101. target="_blank"
  102. rel="noopener noreferrer"
  103. 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"
  104. title="Open plug admin page"
  105. >
  106. <ExternalLink className="w-3 h-3" />
  107. Admin
  108. </a>
  109. </div>
  110. </div>
  111. {/* Linked Printer */}
  112. {linkedPrinter && (
  113. <div className="mb-3 px-2 py-1.5 bg-bambu-dark rounded-lg">
  114. <span className="text-xs text-bambu-gray">Linked to: </span>
  115. <span className="text-sm text-white">{linkedPrinter.name}</span>
  116. </div>
  117. )}
  118. {/* Feature Badges */}
  119. {(plug.power_alert_enabled || plug.schedule_enabled) && (
  120. <div className="flex flex-wrap gap-1.5 mb-3">
  121. {plug.power_alert_enabled && (
  122. <span className="flex items-center gap-1 px-2 py-0.5 bg-yellow-500/20 text-yellow-400 text-xs rounded-full">
  123. <Bell className="w-3 h-3" />
  124. Alerts
  125. </span>
  126. )}
  127. {plug.schedule_enabled && (
  128. <span className="flex items-center gap-1 px-2 py-0.5 bg-blue-500/20 text-blue-400 text-xs rounded-full">
  129. <Calendar className="w-3 h-3" />
  130. {plug.schedule_on_time && plug.schedule_off_time
  131. ? `${plug.schedule_on_time} - ${plug.schedule_off_time}`
  132. : plug.schedule_on_time
  133. ? `On ${plug.schedule_on_time}`
  134. : `Off ${plug.schedule_off_time}`}
  135. </span>
  136. )}
  137. </div>
  138. )}
  139. {/* Quick Controls */}
  140. <div className="flex gap-2 mb-3">
  141. <Button
  142. size="sm"
  143. variant={isOn ? 'primary' : 'secondary'}
  144. disabled={!isReachable || isPending}
  145. onClick={() => setShowPowerOnConfirm(true)}
  146. className="flex-1"
  147. >
  148. {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
  149. On
  150. </Button>
  151. <Button
  152. size="sm"
  153. variant={!isOn ? 'primary' : 'secondary'}
  154. disabled={!isReachable || isPending}
  155. onClick={() => setShowPowerOffConfirm(true)}
  156. className="flex-1"
  157. >
  158. {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
  159. Off
  160. </Button>
  161. </div>
  162. {/* Toggle Settings Panel */}
  163. <button
  164. onClick={() => setIsExpanded(!isExpanded)}
  165. className="w-full flex items-center justify-between py-2 text-sm text-bambu-gray hover:text-white transition-colors"
  166. >
  167. <span className="flex items-center gap-2">
  168. <Settings2 className="w-4 h-4" />
  169. Automation Settings
  170. </span>
  171. <span>{isExpanded ? '-' : '+'}</span>
  172. </button>
  173. {/* Expanded Settings */}
  174. {isExpanded && (
  175. <div className="pt-3 border-t border-bambu-dark-tertiary space-y-4">
  176. {/* Show in Switchbar Toggle */}
  177. <div className="flex items-center justify-between">
  178. <div className="flex items-center gap-2">
  179. <LayoutGrid className="w-4 h-4 text-bambu-green" />
  180. <div>
  181. <p className="text-sm text-white">Show in Switchbar</p>
  182. <p className="text-xs text-bambu-gray">Quick access from sidebar</p>
  183. </div>
  184. </div>
  185. <label className="relative inline-flex items-center cursor-pointer">
  186. <input
  187. type="checkbox"
  188. checked={plug.show_in_switchbar}
  189. onChange={(e) => updateMutation.mutate({ show_in_switchbar: e.target.checked })}
  190. className="sr-only peer"
  191. />
  192. <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>
  193. </label>
  194. </div>
  195. {/* Enabled Toggle */}
  196. <div className="flex items-center justify-between">
  197. <div>
  198. <p className="text-sm text-white">Enabled</p>
  199. <p className="text-xs text-bambu-gray">Enable automation for this plug</p>
  200. </div>
  201. <label className="relative inline-flex items-center cursor-pointer">
  202. <input
  203. type="checkbox"
  204. checked={plug.enabled}
  205. onChange={(e) => updateMutation.mutate({ enabled: e.target.checked })}
  206. className="sr-only peer"
  207. />
  208. <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>
  209. </label>
  210. </div>
  211. {/* Auto On */}
  212. <div className="flex items-center justify-between">
  213. <div>
  214. <p className="text-sm text-white">Auto On</p>
  215. <p className="text-xs text-bambu-gray">Turn on when print starts</p>
  216. </div>
  217. <label className="relative inline-flex items-center cursor-pointer">
  218. <input
  219. type="checkbox"
  220. checked={plug.auto_on}
  221. onChange={(e) => updateMutation.mutate({ auto_on: e.target.checked })}
  222. className="sr-only peer"
  223. />
  224. <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>
  225. </label>
  226. </div>
  227. {/* Auto Off */}
  228. <div className="flex items-center justify-between">
  229. <div>
  230. <p className="text-sm text-white">Auto Off</p>
  231. <p className="text-xs text-bambu-gray">Turn off when print completes (one-shot)</p>
  232. </div>
  233. <label className="relative inline-flex items-center cursor-pointer">
  234. <input
  235. type="checkbox"
  236. checked={plug.auto_off}
  237. onChange={(e) => updateMutation.mutate({ auto_off: e.target.checked })}
  238. className="sr-only peer"
  239. />
  240. <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>
  241. </label>
  242. </div>
  243. {/* Delay Mode */}
  244. {plug.auto_off && (
  245. <div className="space-y-3 pl-4 border-l-2 border-bambu-dark-tertiary">
  246. <div>
  247. <p className="text-sm text-white mb-2">Turn Off Delay Mode</p>
  248. <div className="flex gap-2">
  249. <button
  250. onClick={() => updateMutation.mutate({ off_delay_mode: 'time' })}
  251. className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
  252. plug.off_delay_mode === 'time'
  253. ? 'bg-bambu-green text-white'
  254. : 'bg-bambu-dark text-bambu-gray hover:text-white'
  255. }`}
  256. >
  257. <Clock className="w-4 h-4" />
  258. Time
  259. </button>
  260. <button
  261. onClick={() => updateMutation.mutate({ off_delay_mode: 'temperature' })}
  262. className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
  263. plug.off_delay_mode === 'temperature'
  264. ? 'bg-bambu-green text-white'
  265. : 'bg-bambu-dark text-bambu-gray hover:text-white'
  266. }`}
  267. >
  268. <Thermometer className="w-4 h-4" />
  269. Temp
  270. </button>
  271. </div>
  272. </div>
  273. {plug.off_delay_mode === 'time' ? (
  274. <div>
  275. <label className="block text-xs text-bambu-gray mb-1">Delay (minutes)</label>
  276. <input
  277. type="number"
  278. min="1"
  279. max="60"
  280. value={plug.off_delay_minutes}
  281. onChange={(e) => updateMutation.mutate({ off_delay_minutes: parseInt(e.target.value) || 5 })}
  282. 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"
  283. />
  284. </div>
  285. ) : (
  286. <div>
  287. <label className="block text-xs text-bambu-gray mb-1">Temperature threshold (C)</label>
  288. <input
  289. type="number"
  290. min="30"
  291. max="100"
  292. value={plug.off_temp_threshold}
  293. onChange={(e) => updateMutation.mutate({ off_temp_threshold: parseInt(e.target.value) || 70 })}
  294. 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"
  295. />
  296. <p className="text-xs text-bambu-gray mt-1">Turns off when nozzle cools below this temperature</p>
  297. </div>
  298. )}
  299. </div>
  300. )}
  301. {/* Action Buttons */}
  302. <div className="flex gap-2 pt-2">
  303. <Button
  304. size="sm"
  305. variant="secondary"
  306. onClick={() => onEdit(plug)}
  307. className="flex-1"
  308. >
  309. <Edit2 className="w-4 h-4" />
  310. Edit
  311. </Button>
  312. <Button
  313. size="sm"
  314. variant="secondary"
  315. onClick={() => setShowDeleteConfirm(true)}
  316. className="text-red-400 hover:text-red-300"
  317. >
  318. <Trash2 className="w-4 h-4" />
  319. </Button>
  320. </div>
  321. </div>
  322. )}
  323. </CardContent>
  324. </Card>
  325. {/* Delete Confirmation */}
  326. {showDeleteConfirm && (
  327. <ConfirmModal
  328. title="Delete Smart Plug"
  329. message={`Are you sure you want to delete "${plug.name}"? This cannot be undone.`}
  330. confirmText="Delete"
  331. variant="danger"
  332. onConfirm={() => {
  333. deleteMutation.mutate();
  334. setShowDeleteConfirm(false);
  335. }}
  336. onCancel={() => setShowDeleteConfirm(false)}
  337. />
  338. )}
  339. {/* Power On Confirmation */}
  340. {showPowerOnConfirm && (
  341. <ConfirmModal
  342. title="Turn On Smart Plug"
  343. message={`Are you sure you want to turn on "${plug.name}"?`}
  344. confirmText="Turn On"
  345. variant="default"
  346. onConfirm={() => {
  347. controlMutation.mutate('on');
  348. setShowPowerOnConfirm(false);
  349. }}
  350. onCancel={() => setShowPowerOnConfirm(false)}
  351. />
  352. )}
  353. {/* Power Off Confirmation */}
  354. {showPowerOffConfirm && (
  355. <ConfirmModal
  356. title="Turn Off Smart Plug"
  357. message={`Are you sure you want to turn off "${plug.name}"? This will cut power to the connected device.`}
  358. confirmText="Turn Off"
  359. variant="danger"
  360. onConfirm={() => {
  361. controlMutation.mutate('off');
  362. setShowPowerOffConfirm(false);
  363. }}
  364. onCancel={() => setShowPowerOffConfirm(false)}
  365. />
  366. )}
  367. </>
  368. );
  369. }