SpoolBuddyQuickMenu.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. import { useState, useEffect, useCallback } from 'react';
  2. import { useQuery } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import { PowerOff, RotateCw, Monitor, ChevronDown, Loader2 } from 'lucide-react';
  5. import { api, spoolbuddyApi, type Printer, type SmartPlug, type SmartPlugStatus } from '../../api/client';
  6. interface SpoolBuddyQuickMenuProps {
  7. isOpen: boolean;
  8. onClose: () => void;
  9. deviceId: string | null;
  10. deviceOnline: boolean;
  11. }
  12. type SystemCommand = 'reboot' | 'shutdown' | 'restart_daemon' | 'restart_browser';
  13. type PendingConfirm =
  14. | { type: 'system'; command: SystemCommand }
  15. | { type: 'plug'; plug: SmartPlug; printer: Printer; currentState: string | null };
  16. interface PlugState {
  17. plug: SmartPlug;
  18. printer: Printer;
  19. status: SmartPlugStatus | null;
  20. loading: boolean;
  21. }
  22. export function SpoolBuddyQuickMenu({ isOpen, onClose, deviceId, deviceOnline }: SpoolBuddyQuickMenuProps) {
  23. const { t } = useTranslation();
  24. const [pendingConfirm, setPendingConfirm] = useState<PendingConfirm | null>(null);
  25. const [commandBusy, setCommandBusy] = useState(false);
  26. const [plugStates, setPlugStates] = useState<Map<number, { loading: boolean; state: string | null }>>(new Map());
  27. // Fetch printers and smart plugs
  28. const { data: printers = [] } = useQuery({
  29. queryKey: ['printers'],
  30. queryFn: () => api.getPrinters(),
  31. enabled: isOpen,
  32. });
  33. const { data: smartPlugs = [] } = useQuery({
  34. queryKey: ['smart-plugs'],
  35. queryFn: () => api.getSmartPlugs(),
  36. enabled: isOpen,
  37. });
  38. // Build printer-plug pairs (only main power plugs linked to printers)
  39. const printerPlugs: PlugState[] = printers
  40. .map((printer) => {
  41. const plug = smartPlugs.find(
  42. (p) => p.printer_id === printer.id && p.plug_type !== 'mqtt' && p.enabled
  43. );
  44. if (!plug) return null;
  45. const state = plugStates.get(plug.id);
  46. return {
  47. plug,
  48. printer,
  49. status: state ? { state: state.state, reachable: true, device_name: null, energy: null } : null,
  50. loading: state?.loading ?? false,
  51. };
  52. })
  53. .filter(Boolean) as PlugState[];
  54. // Fetch plug statuses when menu opens
  55. useEffect(() => {
  56. if (!isOpen || smartPlugs.length === 0) return;
  57. const linkedPlugs = smartPlugs.filter(
  58. (p) => p.printer_id !== null && p.plug_type !== 'mqtt' && p.enabled
  59. );
  60. linkedPlugs.forEach(async (plug) => {
  61. try {
  62. const status = await api.getSmartPlugStatus(plug.id);
  63. setPlugStates((prev) => {
  64. const next = new Map(prev);
  65. next.set(plug.id, { loading: false, state: status.state });
  66. return next;
  67. });
  68. } catch {
  69. setPlugStates((prev) => {
  70. const next = new Map(prev);
  71. next.set(plug.id, { loading: false, state: null });
  72. return next;
  73. });
  74. }
  75. });
  76. }, [isOpen, smartPlugs]);
  77. // Clear state when menu closes
  78. useEffect(() => {
  79. if (!isOpen) {
  80. setPendingConfirm(null);
  81. setCommandBusy(false);
  82. }
  83. }, [isOpen]);
  84. const handleTogglePlug = useCallback(async (plug: SmartPlug) => {
  85. setPlugStates((prev) => {
  86. const next = new Map(prev);
  87. const current = next.get(plug.id);
  88. next.set(plug.id, { loading: true, state: current?.state ?? null });
  89. return next;
  90. });
  91. try {
  92. await api.controlSmartPlug(plug.id, 'toggle');
  93. const status = await api.getSmartPlugStatus(plug.id);
  94. setPlugStates((prev) => {
  95. const next = new Map(prev);
  96. next.set(plug.id, { loading: false, state: status.state });
  97. return next;
  98. });
  99. } catch {
  100. setPlugStates((prev) => {
  101. const next = new Map(prev);
  102. const current = next.get(plug.id);
  103. next.set(plug.id, { loading: false, state: current?.state ?? null });
  104. return next;
  105. });
  106. }
  107. }, []);
  108. const handleSystemCommand = useCallback(async (command: SystemCommand) => {
  109. if (!deviceId) return;
  110. setCommandBusy(true);
  111. try {
  112. await spoolbuddyApi.systemCommand(deviceId, command);
  113. // Close menu after successful command
  114. setTimeout(() => onClose(), 500);
  115. } catch {
  116. setCommandBusy(false);
  117. }
  118. }, [deviceId, onClose]);
  119. const executeConfirmed = useCallback(() => {
  120. if (!pendingConfirm) return;
  121. if (pendingConfirm.type === 'system') {
  122. handleSystemCommand(pendingConfirm.command);
  123. } else {
  124. handleTogglePlug(pendingConfirm.plug);
  125. }
  126. setPendingConfirm(null);
  127. }, [pendingConfirm, handleSystemCommand, handleTogglePlug]);
  128. if (!isOpen) return null;
  129. const isPlugOn = (state: string | null) => state === 'ON' || state === 'on';
  130. return (
  131. <>
  132. {/* Backdrop */}
  133. <div className="fixed inset-0 z-40 bg-black/50" onPointerDown={onClose} />
  134. {/* Slide-down panel */}
  135. <div className="fixed top-0 left-0 right-0 z-50 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary rounded-b-2xl shadow-2xl animate-slide-down">
  136. {/* Handle bar */}
  137. <div className="flex justify-center pt-2 pb-1">
  138. <div className="w-10 h-1 rounded-full bg-zinc-600" />
  139. </div>
  140. <div className="px-4 pb-4 max-h-[80vh] overflow-y-auto">
  141. {/* Printer Power Section */}
  142. {printerPlugs.length > 0 && (
  143. <div className="mb-4">
  144. <h3 className="text-xs font-semibold text-zinc-500 uppercase tracking-wide mb-2">
  145. {t('spoolbuddy.quickMenu.printerPower', 'Printer Power')}
  146. </h3>
  147. <div className="space-y-1">
  148. {printerPlugs.map(({ plug, printer, loading }) => {
  149. const state = plugStates.get(plug.id);
  150. const on = isPlugOn(state?.state ?? null);
  151. return (
  152. <button
  153. key={plug.id}
  154. onClick={() => setPendingConfirm({ type: 'plug', plug, printer, currentState: state?.state ?? null })}
  155. disabled={loading}
  156. className="w-full flex items-center gap-2 px-3 py-1.5 rounded-lg bg-zinc-800/60 hover:bg-zinc-700/60 transition-colors min-h-[36px]"
  157. >
  158. <div className={`w-2 h-2 rounded-full shrink-0 ${on ? 'bg-green-500' : 'bg-zinc-600'}`} />
  159. {loading && <Loader2 className="w-3 h-3 animate-spin text-zinc-400 shrink-0" />}
  160. <span className="flex-1 text-sm text-zinc-200 text-left truncate">{printer.name}</span>
  161. <span className={`text-xs font-medium ${on ? 'text-green-400' : 'text-zinc-500'}`}>
  162. {state?.state ?? '—'}
  163. </span>
  164. </button>
  165. );
  166. })}
  167. </div>
  168. </div>
  169. )}
  170. {/* System Controls Section */}
  171. <div>
  172. <h3 className="text-xs font-semibold text-zinc-500 uppercase tracking-wide mb-2">
  173. {t('spoolbuddy.quickMenu.systemControls', 'System')}
  174. </h3>
  175. <div className="grid grid-cols-2 gap-2">
  176. <SystemButton
  177. icon={<RotateCw className="w-4 h-4" />}
  178. label={t('spoolbuddy.quickMenu.restartDaemon', 'Restart Daemon')}
  179. onClick={() => setPendingConfirm({ type: 'system', command: 'restart_daemon' })}
  180. disabled={!deviceId || !deviceOnline || commandBusy}
  181. />
  182. <SystemButton
  183. icon={<Monitor className="w-4 h-4" />}
  184. label={t('spoolbuddy.quickMenu.restartBrowser', 'Restart Browser')}
  185. onClick={() => setPendingConfirm({ type: 'system', command: 'restart_browser' })}
  186. disabled={!deviceId || !deviceOnline || commandBusy}
  187. />
  188. <SystemButton
  189. icon={<RotateCw className="w-4 h-4" />}
  190. label={t('spoolbuddy.quickMenu.reboot', 'Reboot')}
  191. onClick={() => setPendingConfirm({ type: 'system', command: 'reboot' })}
  192. disabled={!deviceId || !deviceOnline || commandBusy}
  193. variant="warning"
  194. />
  195. <SystemButton
  196. icon={<PowerOff className="w-4 h-4" />}
  197. label={t('spoolbuddy.quickMenu.shutdown', 'Shutdown')}
  198. onClick={() => setPendingConfirm({ type: 'system', command: 'shutdown' })}
  199. disabled={!deviceId || !deviceOnline || commandBusy}
  200. variant="danger"
  201. />
  202. </div>
  203. </div>
  204. {/* Swipe hint */}
  205. <div className="flex justify-center mt-3">
  206. <div className="flex items-center gap-1 text-xs text-zinc-600">
  207. <ChevronDown className="w-3 h-3" />
  208. <span>{t('spoolbuddy.quickMenu.swipeToClose', 'Swipe down to close')}</span>
  209. </div>
  210. </div>
  211. </div>
  212. </div>
  213. {/* Confirmation Dialog */}
  214. {pendingConfirm && (
  215. <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60">
  216. <div className="bg-zinc-800 rounded-2xl p-5 mx-4 max-w-sm w-full border border-zinc-700">
  217. <h3 className="text-lg font-semibold text-zinc-100 mb-2">
  218. {t('spoolbuddy.quickMenu.confirmTitle', 'Confirm')}
  219. </h3>
  220. <p className="text-sm text-zinc-400 mb-5">
  221. {pendingConfirm.type === 'plug'
  222. ? (isPlugOn(pendingConfirm.currentState)
  223. ? t('spoolbuddy.quickMenu.confirmPlugOff', 'Turn off {{name}}?', { name: pendingConfirm.printer.name })
  224. : t('spoolbuddy.quickMenu.confirmPlugOn', 'Turn on {{name}}?', { name: pendingConfirm.printer.name }))
  225. : pendingConfirm.command === 'shutdown'
  226. ? t('spoolbuddy.quickMenu.confirmShutdown', 'Are you sure you want to shut down the SpoolBuddy? You will need physical access to turn it back on.')
  227. : pendingConfirm.command === 'reboot'
  228. ? t('spoolbuddy.quickMenu.confirmReboot', 'Are you sure you want to reboot the SpoolBuddy?')
  229. : pendingConfirm.command === 'restart_daemon'
  230. ? t('spoolbuddy.quickMenu.confirmRestartDaemon', 'Restart the SpoolBuddy daemon? NFC and scale will be temporarily unavailable.')
  231. : t('spoolbuddy.quickMenu.confirmRestartBrowser', 'Restart the kiosk browser? The display will briefly go blank.')}
  232. </p>
  233. <div className="flex gap-3">
  234. <button
  235. onClick={() => setPendingConfirm(null)}
  236. className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]"
  237. >
  238. {t('common.cancel', 'Cancel')}
  239. </button>
  240. <button
  241. onClick={executeConfirmed}
  242. disabled={commandBusy}
  243. className={`flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-white transition-colors min-h-[44px] ${
  244. pendingConfirm.type === 'plug'
  245. ? (isPlugOn(pendingConfirm.currentState) ? 'bg-red-600 hover:bg-red-700' : 'bg-green-600 hover:bg-green-700')
  246. : pendingConfirm.command === 'shutdown' ? 'bg-red-600 hover:bg-red-700'
  247. : pendingConfirm.command === 'reboot' ? 'bg-amber-600 hover:bg-amber-700'
  248. : 'bg-blue-600 hover:bg-blue-700'
  249. } disabled:opacity-50`}
  250. >
  251. {commandBusy ? <Loader2 className="w-4 h-4 animate-spin mx-auto" /> :
  252. pendingConfirm.type === 'plug'
  253. ? (isPlugOn(pendingConfirm.currentState)
  254. ? t('spoolbuddy.quickMenu.turnOff', 'Turn Off')
  255. : t('spoolbuddy.quickMenu.turnOn', 'Turn On'))
  256. : t('spoolbuddy.quickMenu.confirm', 'Confirm')}
  257. </button>
  258. </div>
  259. </div>
  260. </div>
  261. )}
  262. </>
  263. );
  264. }
  265. function SystemButton({
  266. icon,
  267. label,
  268. onClick,
  269. disabled,
  270. variant = 'default',
  271. }: {
  272. icon: React.ReactNode;
  273. label: string;
  274. onClick: () => void;
  275. disabled: boolean;
  276. variant?: 'default' | 'warning' | 'danger';
  277. }) {
  278. const variantClasses = {
  279. default: 'bg-zinc-800/60 hover:bg-zinc-700/60 text-zinc-300',
  280. warning: 'bg-amber-900/30 hover:bg-amber-900/50 text-amber-400',
  281. danger: 'bg-red-900/30 hover:bg-red-900/50 text-red-400',
  282. };
  283. return (
  284. <button
  285. onClick={onClick}
  286. disabled={disabled}
  287. className={`flex items-center gap-2.5 p-3 rounded-xl transition-colors min-h-[48px] disabled:opacity-40 ${variantClasses[variant]}`}
  288. >
  289. {icon}
  290. <span className="text-sm font-medium">{label}</span>
  291. </button>
  292. );
  293. }