SettingsPage.tsx 80 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836
  1. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  2. import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database } from 'lucide-react';
  3. import { useTranslation } from 'react-i18next';
  4. import { api } from '../api/client';
  5. import type { AppSettings, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus } from '../api/client';
  6. import { Card, CardContent, CardHeader } from '../components/Card';
  7. import { Button } from '../components/Button';
  8. import { SmartPlugCard } from '../components/SmartPlugCard';
  9. import { AddSmartPlugModal } from '../components/AddSmartPlugModal';
  10. import { NotificationProviderCard } from '../components/NotificationProviderCard';
  11. import { AddNotificationModal } from '../components/AddNotificationModal';
  12. import { NotificationTemplateEditor } from '../components/NotificationTemplateEditor';
  13. import { NotificationLogViewer } from '../components/NotificationLogViewer';
  14. import { ConfirmModal } from '../components/ConfirmModal';
  15. import { BackupModal } from '../components/BackupModal';
  16. import { RestoreModal } from '../components/RestoreModal';
  17. import { SpoolmanSettings } from '../components/SpoolmanSettings';
  18. import { ExternalLinksSettings } from '../components/ExternalLinksSettings';
  19. import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
  20. import { availableLanguages } from '../i18n';
  21. import { useToast } from '../contexts/ToastContext';
  22. import { useState, useEffect, useRef, useCallback } from 'react';
  23. export function SettingsPage() {
  24. const queryClient = useQueryClient();
  25. const { t, i18n } = useTranslation();
  26. const { showToast } = useToast();
  27. const [localSettings, setLocalSettings] = useState<AppSettings | null>(null);
  28. const [showPlugModal, setShowPlugModal] = useState(false);
  29. const [editingPlug, setEditingPlug] = useState<SmartPlug | null>(null);
  30. const [showNotificationModal, setShowNotificationModal] = useState(false);
  31. const [editingProvider, setEditingProvider] = useState<NotificationProvider | null>(null);
  32. const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(null);
  33. const [showLogViewer, setShowLogViewer] = useState(false);
  34. const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());
  35. const [activeTab, setActiveTab] = useState<'general' | 'plugs' | 'notifications' | 'apikeys'>('general');
  36. const [showCreateAPIKey, setShowCreateAPIKey] = useState(false);
  37. const [newAPIKeyName, setNewAPIKeyName] = useState('');
  38. const [newAPIKeyPermissions, setNewAPIKeyPermissions] = useState({
  39. can_queue: true,
  40. can_control_printer: false,
  41. can_read_status: true,
  42. });
  43. const [createdAPIKey, setCreatedAPIKey] = useState<string | null>(null);
  44. const [showDeleteAPIKeyConfirm, setShowDeleteAPIKeyConfirm] = useState<number | null>(null);
  45. // Confirm modal states
  46. const [showClearLogsConfirm, setShowClearLogsConfirm] = useState(false);
  47. const [showClearStorageConfirm, setShowClearStorageConfirm] = useState(false);
  48. const [showBulkPlugConfirm, setShowBulkPlugConfirm] = useState<'on' | 'off' | null>(null);
  49. const [showBackupModal, setShowBackupModal] = useState(false);
  50. const [showRestoreModal, setShowRestoreModal] = useState(false);
  51. const handleDefaultViewChange = (path: string) => {
  52. setDefaultViewState(path);
  53. setDefaultView(path);
  54. };
  55. const handleResetSidebarOrder = () => {
  56. localStorage.removeItem('sidebarOrder');
  57. window.location.reload();
  58. };
  59. const { data: settings, isLoading } = useQuery({
  60. queryKey: ['settings'],
  61. queryFn: api.getSettings,
  62. });
  63. const { data: smartPlugs, isLoading: plugsLoading } = useQuery({
  64. queryKey: ['smart-plugs'],
  65. queryFn: api.getSmartPlugs,
  66. });
  67. // Fetch energy data for all smart plugs when on the plugs tab
  68. const { data: plugEnergySummary, isLoading: energyLoading } = useQuery({
  69. queryKey: ['smart-plugs-energy', smartPlugs?.map(p => p.id)],
  70. queryFn: async () => {
  71. if (!smartPlugs || smartPlugs.length === 0) return null;
  72. const statuses = await Promise.all(
  73. smartPlugs.filter(p => p.enabled).map(async (plug) => {
  74. try {
  75. const status = await api.getSmartPlugStatus(plug.id);
  76. return { plug, status };
  77. } catch {
  78. return { plug, status: null as SmartPlugStatus | null };
  79. }
  80. })
  81. );
  82. // Aggregate energy data
  83. let totalPower = 0;
  84. let totalToday = 0;
  85. let totalYesterday = 0;
  86. let totalLifetime = 0;
  87. let reachableCount = 0;
  88. for (const { status } of statuses) {
  89. if (status?.reachable && status.energy) {
  90. reachableCount++;
  91. if (status.energy.power != null) totalPower += status.energy.power;
  92. if (status.energy.today != null) totalToday += status.energy.today;
  93. if (status.energy.yesterday != null) totalYesterday += status.energy.yesterday;
  94. if (status.energy.total != null) totalLifetime += status.energy.total;
  95. }
  96. }
  97. return {
  98. totalPower,
  99. totalToday,
  100. totalYesterday,
  101. totalLifetime,
  102. reachableCount,
  103. totalPlugs: smartPlugs.filter(p => p.enabled).length,
  104. };
  105. },
  106. enabled: activeTab === 'plugs' && !!smartPlugs && smartPlugs.length > 0,
  107. refetchInterval: activeTab === 'plugs' ? 10000 : false, // Refresh every 10s when on plugs tab
  108. });
  109. const { data: notificationProviders, isLoading: providersLoading } = useQuery({
  110. queryKey: ['notification-providers'],
  111. queryFn: api.getNotificationProviders,
  112. });
  113. const { data: apiKeys, isLoading: apiKeysLoading } = useQuery({
  114. queryKey: ['api-keys'],
  115. queryFn: api.getAPIKeys,
  116. enabled: activeTab === 'apikeys',
  117. });
  118. const createAPIKeyMutation = useMutation({
  119. mutationFn: (data: { name: string; can_queue: boolean; can_control_printer: boolean; can_read_status: boolean }) =>
  120. api.createAPIKey(data),
  121. onSuccess: (data) => {
  122. setCreatedAPIKey(data.key || null);
  123. setShowCreateAPIKey(false);
  124. setNewAPIKeyName('');
  125. queryClient.invalidateQueries({ queryKey: ['api-keys'] });
  126. showToast('API key created');
  127. },
  128. onError: (error: Error) => {
  129. showToast(`Failed to create API key: ${error.message}`, 'error');
  130. },
  131. });
  132. const deleteAPIKeyMutation = useMutation({
  133. mutationFn: (id: number) => api.deleteAPIKey(id),
  134. onSuccess: () => {
  135. queryClient.invalidateQueries({ queryKey: ['api-keys'] });
  136. showToast('API key deleted');
  137. },
  138. onError: (error: Error) => {
  139. showToast(`Failed to delete API key: ${error.message}`, 'error');
  140. },
  141. });
  142. const { data: printers } = useQuery({
  143. queryKey: ['printers'],
  144. queryFn: api.getPrinters,
  145. });
  146. const { data: notificationTemplates, isLoading: templatesLoading } = useQuery({
  147. queryKey: ['notification-templates'],
  148. queryFn: api.getNotificationTemplates,
  149. });
  150. const { data: ffmpegStatus } = useQuery({
  151. queryKey: ['ffmpeg-status'],
  152. queryFn: api.checkFfmpeg,
  153. });
  154. const { data: versionInfo } = useQuery({
  155. queryKey: ['version'],
  156. queryFn: api.getVersion,
  157. });
  158. const { data: updateCheck, refetch: refetchUpdateCheck, isRefetching: isCheckingUpdate } = useQuery({
  159. queryKey: ['updateCheck'],
  160. queryFn: api.checkForUpdates,
  161. staleTime: 5 * 60 * 1000,
  162. });
  163. const { data: updateStatus, refetch: refetchUpdateStatus } = useQuery({
  164. queryKey: ['updateStatus'],
  165. queryFn: api.getUpdateStatus,
  166. refetchInterval: (query) => {
  167. const status = query.state.data as UpdateStatus | undefined;
  168. // Poll while update is in progress
  169. if (status?.status === 'downloading' || status?.status === 'installing') {
  170. return 1000;
  171. }
  172. return false;
  173. },
  174. });
  175. const applyUpdateMutation = useMutation({
  176. mutationFn: api.applyUpdate,
  177. onSuccess: () => {
  178. refetchUpdateStatus();
  179. },
  180. });
  181. // Test all notification providers
  182. const [testAllResult, setTestAllResult] = useState<{
  183. tested: number;
  184. success: number;
  185. failed: number;
  186. results: Array<{
  187. provider_id: number;
  188. provider_name: string;
  189. provider_type: string;
  190. success: boolean;
  191. message: string;
  192. }>;
  193. } | null>(null);
  194. const testAllMutation = useMutation({
  195. mutationFn: api.testAllNotificationProviders,
  196. onSuccess: (data) => {
  197. setTestAllResult(data);
  198. queryClient.invalidateQueries({ queryKey: ['notification-providers'] });
  199. if (data.failed === 0) {
  200. showToast(`All ${data.tested} providers tested successfully!`, 'success');
  201. } else {
  202. showToast(`${data.success}/${data.tested} providers succeeded`, data.failed > 0 ? 'error' : 'success');
  203. }
  204. },
  205. onError: (error: Error) => {
  206. showToast(`Failed to test providers: ${error.message}`, 'error');
  207. },
  208. });
  209. // Bulk action for smart plugs
  210. const bulkPlugActionMutation = useMutation({
  211. mutationFn: async (action: 'on' | 'off') => {
  212. if (!smartPlugs) return { success: 0, failed: 0 };
  213. const enabledPlugs = smartPlugs.filter(p => p.enabled);
  214. const results = await Promise.all(
  215. enabledPlugs.map(async (plug) => {
  216. try {
  217. await api.controlSmartPlug(plug.id, action);
  218. return { success: true };
  219. } catch {
  220. return { success: false };
  221. }
  222. })
  223. );
  224. return {
  225. success: results.filter(r => r.success).length,
  226. failed: results.filter(r => !r.success).length,
  227. };
  228. },
  229. onSuccess: (data, action) => {
  230. queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
  231. queryClient.invalidateQueries({ queryKey: ['smart-plugs-energy'] });
  232. if (data.failed === 0) {
  233. showToast(`All ${data.success} plugs turned ${action}`, 'success');
  234. } else {
  235. showToast(`${data.success} plugs turned ${action}, ${data.failed} failed`, 'error');
  236. }
  237. },
  238. onError: (error: Error) => {
  239. showToast(`Failed: ${error.message}`, 'error');
  240. },
  241. });
  242. // Ref for debounce timeout
  243. const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  244. const isInitialLoadRef = useRef(true);
  245. // Sync local state when settings load
  246. useEffect(() => {
  247. if (settings && !localSettings) {
  248. setLocalSettings(settings);
  249. // Mark initial load complete after a short delay
  250. setTimeout(() => {
  251. isInitialLoadRef.current = false;
  252. }, 100);
  253. }
  254. }, [settings, localSettings]);
  255. const updateMutation = useMutation({
  256. mutationFn: api.updateSettings,
  257. onSuccess: (data) => {
  258. queryClient.setQueryData(['settings'], data);
  259. // Invalidate archive stats to reflect energy tracking mode change
  260. queryClient.invalidateQueries({ queryKey: ['archiveStats'] });
  261. showToast('Settings saved', 'success');
  262. },
  263. onError: (error: Error) => {
  264. showToast(`Failed to save: ${error.message}`, 'error');
  265. },
  266. });
  267. // Debounced auto-save when localSettings change
  268. useEffect(() => {
  269. // Skip if initial load or no settings
  270. if (isInitialLoadRef.current || !localSettings || !settings) {
  271. return;
  272. }
  273. // Check if there are actual changes
  274. const hasChanges =
  275. settings.auto_archive !== localSettings.auto_archive ||
  276. settings.save_thumbnails !== localSettings.save_thumbnails ||
  277. settings.capture_finish_photo !== localSettings.capture_finish_photo ||
  278. settings.default_filament_cost !== localSettings.default_filament_cost ||
  279. settings.currency !== localSettings.currency ||
  280. settings.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh ||
  281. settings.energy_tracking_mode !== localSettings.energy_tracking_mode ||
  282. settings.check_updates !== localSettings.check_updates ||
  283. settings.notification_language !== localSettings.notification_language ||
  284. settings.ams_humidity_good !== localSettings.ams_humidity_good ||
  285. settings.ams_humidity_fair !== localSettings.ams_humidity_fair ||
  286. settings.ams_temp_good !== localSettings.ams_temp_good ||
  287. settings.ams_temp_fair !== localSettings.ams_temp_fair ||
  288. settings.ams_history_retention_days !== localSettings.ams_history_retention_days ||
  289. settings.date_format !== localSettings.date_format ||
  290. settings.time_format !== localSettings.time_format ||
  291. settings.default_printer_id !== localSettings.default_printer_id;
  292. if (!hasChanges) {
  293. return;
  294. }
  295. // Clear existing timeout
  296. if (saveTimeoutRef.current) {
  297. clearTimeout(saveTimeoutRef.current);
  298. }
  299. // Set new debounced save (500ms delay)
  300. saveTimeoutRef.current = setTimeout(() => {
  301. updateMutation.mutate(localSettings);
  302. }, 500);
  303. // Cleanup on unmount or when localSettings changes again
  304. return () => {
  305. if (saveTimeoutRef.current) {
  306. clearTimeout(saveTimeoutRef.current);
  307. }
  308. };
  309. }, [localSettings, settings, updateMutation]);
  310. const updateSetting = useCallback(<K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
  311. setLocalSettings(prev => prev ? { ...prev, [key]: value } : null);
  312. }, []);
  313. if (isLoading || !localSettings) {
  314. return (
  315. <div className="p-4 md:p-8 flex justify-center">
  316. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  317. </div>
  318. );
  319. }
  320. return (
  321. <div className="p-4 md:p-8">
  322. <div className="mb-8">
  323. <h1 className="text-2xl font-bold text-white">Settings</h1>
  324. <p className="text-bambu-gray">Configure Bambuddy</p>
  325. </div>
  326. {/* Tab Navigation */}
  327. <div className="flex gap-1 mb-6 border-b border-bambu-dark-tertiary">
  328. <button
  329. onClick={() => setActiveTab('general')}
  330. className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
  331. activeTab === 'general'
  332. ? 'text-bambu-green border-bambu-green'
  333. : 'text-bambu-gray hover:text-white border-transparent'
  334. }`}
  335. >
  336. General
  337. </button>
  338. <button
  339. onClick={() => setActiveTab('plugs')}
  340. className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
  341. activeTab === 'plugs'
  342. ? 'text-bambu-green border-bambu-green'
  343. : 'text-bambu-gray hover:text-white border-transparent'
  344. }`}
  345. >
  346. <Plug className="w-4 h-4" />
  347. Smart Plugs
  348. {smartPlugs && smartPlugs.length > 0 && (
  349. <span className="text-xs bg-bambu-dark-tertiary px-1.5 py-0.5 rounded-full">
  350. {smartPlugs.length}
  351. </span>
  352. )}
  353. </button>
  354. <button
  355. onClick={() => setActiveTab('notifications')}
  356. className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
  357. activeTab === 'notifications'
  358. ? 'text-bambu-green border-bambu-green'
  359. : 'text-bambu-gray hover:text-white border-transparent'
  360. }`}
  361. >
  362. <Bell className="w-4 h-4" />
  363. Notifications
  364. {notificationProviders && notificationProviders.length > 0 && (
  365. <span className="text-xs bg-bambu-dark-tertiary px-1.5 py-0.5 rounded-full">
  366. {notificationProviders.length}
  367. </span>
  368. )}
  369. </button>
  370. <button
  371. onClick={() => setActiveTab('apikeys')}
  372. className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
  373. activeTab === 'apikeys'
  374. ? 'text-bambu-green border-bambu-green'
  375. : 'text-bambu-gray hover:text-white border-transparent'
  376. }`}
  377. >
  378. <Key className="w-4 h-4" />
  379. API Keys
  380. {apiKeys && apiKeys.length > 0 && (
  381. <span className="text-xs bg-bambu-dark-tertiary px-1.5 py-0.5 rounded-full">
  382. {apiKeys.length}
  383. </span>
  384. )}
  385. </button>
  386. </div>
  387. {/* General Tab */}
  388. {activeTab === 'general' && (
  389. <div className="flex flex-col lg:flex-row gap-6 lg:gap-8">
  390. {/* Left Column - General Settings */}
  391. <div className="space-y-6 flex-1 lg:max-w-xl">
  392. <Card>
  393. <CardHeader>
  394. <h2 className="text-lg font-semibold text-white">{t('settings.general')}</h2>
  395. </CardHeader>
  396. <CardContent className="space-y-4">
  397. <div>
  398. <label className="block text-sm text-bambu-gray mb-1">
  399. <Globe className="w-4 h-4 inline mr-1" />
  400. {t('settings.language')}
  401. </label>
  402. <select
  403. value={i18n.language}
  404. onChange={(e) => i18n.changeLanguage(e.target.value)}
  405. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  406. >
  407. {availableLanguages.map((lang) => (
  408. <option key={lang.code} value={lang.code}>
  409. {lang.nativeName} ({lang.name})
  410. </option>
  411. ))}
  412. </select>
  413. <p className="text-xs text-bambu-gray mt-1">
  414. {t('settings.languageDescription')}
  415. </p>
  416. </div>
  417. <div>
  418. <label className="block text-sm text-bambu-gray mb-1">
  419. {t('settings.defaultView')}
  420. </label>
  421. <select
  422. value={defaultView}
  423. onChange={(e) => handleDefaultViewChange(e.target.value)}
  424. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  425. >
  426. {defaultNavItems.map((item) => (
  427. <option key={item.id} value={item.to}>
  428. {t(item.labelKey)}
  429. </option>
  430. ))}
  431. </select>
  432. <p className="text-xs text-bambu-gray mt-1">
  433. {t('settings.defaultViewDescription')}
  434. </p>
  435. </div>
  436. <div className="grid grid-cols-2 gap-3">
  437. <div>
  438. <label className="block text-sm text-bambu-gray mb-1">
  439. Date Format
  440. </label>
  441. <select
  442. value={localSettings.date_format || 'system'}
  443. onChange={(e) => updateSetting('date_format', e.target.value as 'system' | 'us' | 'eu' | 'iso')}
  444. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  445. >
  446. <option value="system">System Default</option>
  447. <option value="us">US (MM/DD/YYYY)</option>
  448. <option value="eu">EU (DD/MM/YYYY)</option>
  449. <option value="iso">ISO (YYYY-MM-DD)</option>
  450. </select>
  451. </div>
  452. <div>
  453. <label className="block text-sm text-bambu-gray mb-1">
  454. Time Format
  455. </label>
  456. <select
  457. value={localSettings.time_format || 'system'}
  458. onChange={(e) => updateSetting('time_format', e.target.value as 'system' | '12h' | '24h')}
  459. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  460. >
  461. <option value="system">System Default</option>
  462. <option value="12h">12-hour (3:30 PM)</option>
  463. <option value="24h">24-hour (15:30)</option>
  464. </select>
  465. </div>
  466. </div>
  467. <div>
  468. <label className="block text-sm text-bambu-gray mb-1">
  469. Default Printer
  470. </label>
  471. <select
  472. value={localSettings.default_printer_id ?? ''}
  473. onChange={(e) => updateSetting('default_printer_id', e.target.value ? Number(e.target.value) : null)}
  474. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  475. >
  476. <option value="">No default (ask each time)</option>
  477. {printers?.map((printer) => (
  478. <option key={printer.id} value={printer.id}>
  479. {printer.name}
  480. </option>
  481. ))}
  482. </select>
  483. <p className="text-xs text-bambu-gray mt-1">
  484. Pre-select this printer for uploads, reprints, and other operations.
  485. </p>
  486. </div>
  487. <div className="flex items-center justify-between">
  488. <div>
  489. <p className="text-white">Sidebar order</p>
  490. <p className="text-sm text-bambu-gray">
  491. Drag items in the sidebar to reorder. Reset to default order here.
  492. </p>
  493. </div>
  494. <Button
  495. variant="secondary"
  496. size="sm"
  497. onClick={handleResetSidebarOrder}
  498. >
  499. <RotateCcw className="w-4 h-4" />
  500. Reset
  501. </Button>
  502. </div>
  503. </CardContent>
  504. </Card>
  505. <Card>
  506. <CardHeader>
  507. <h2 className="text-lg font-semibold text-white">Archive Settings</h2>
  508. </CardHeader>
  509. <CardContent className="space-y-4">
  510. <div className="flex items-center justify-between">
  511. <div>
  512. <p className="text-white">Auto-archive prints</p>
  513. <p className="text-sm text-bambu-gray">
  514. Automatically save 3MF files when prints complete
  515. </p>
  516. </div>
  517. <label className="relative inline-flex items-center cursor-pointer">
  518. <input
  519. type="checkbox"
  520. checked={localSettings.auto_archive}
  521. onChange={(e) => updateSetting('auto_archive', e.target.checked)}
  522. className="sr-only peer"
  523. />
  524. <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
  525. </label>
  526. </div>
  527. <div className="flex items-center justify-between">
  528. <div>
  529. <p className="text-white">Save thumbnails</p>
  530. <p className="text-sm text-bambu-gray">
  531. Extract and save preview images from 3MF files
  532. </p>
  533. </div>
  534. <label className="relative inline-flex items-center cursor-pointer">
  535. <input
  536. type="checkbox"
  537. checked={localSettings.save_thumbnails}
  538. onChange={(e) => updateSetting('save_thumbnails', e.target.checked)}
  539. className="sr-only peer"
  540. />
  541. <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
  542. </label>
  543. </div>
  544. <div className="flex items-center justify-between">
  545. <div>
  546. <p className="text-white">Capture finish photo</p>
  547. <p className="text-sm text-bambu-gray">
  548. Take a photo from printer camera when print completes
  549. </p>
  550. </div>
  551. <label className="relative inline-flex items-center cursor-pointer">
  552. <input
  553. type="checkbox"
  554. checked={localSettings.capture_finish_photo}
  555. onChange={(e) => updateSetting('capture_finish_photo', e.target.checked)}
  556. className="sr-only peer"
  557. />
  558. <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
  559. </label>
  560. </div>
  561. {localSettings.capture_finish_photo && ffmpegStatus && !ffmpegStatus.installed && (
  562. <div className="flex items-start gap-2 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
  563. <AlertTriangle className="w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5" />
  564. <div className="text-sm">
  565. <p className="text-yellow-500 font-medium">ffmpeg not installed</p>
  566. <p className="text-bambu-gray mt-1">
  567. Camera capture requires ffmpeg. Install it via{' '}
  568. <code className="bg-bambu-dark-tertiary px-1 rounded">brew install ffmpeg</code> (macOS) or{' '}
  569. <code className="bg-bambu-dark-tertiary px-1 rounded">apt install ffmpeg</code> (Linux).
  570. </p>
  571. </div>
  572. </div>
  573. )}
  574. </CardContent>
  575. </Card>
  576. <Card>
  577. <CardHeader>
  578. <h2 className="text-lg font-semibold text-white">Cost Tracking</h2>
  579. </CardHeader>
  580. <CardContent className="space-y-4">
  581. <div>
  582. <label className="block text-sm text-bambu-gray mb-1">
  583. Default filament cost (per kg)
  584. </label>
  585. <input
  586. type="number"
  587. step="0.01"
  588. min="0"
  589. value={localSettings.default_filament_cost}
  590. onChange={(e) =>
  591. updateSetting('default_filament_cost', parseFloat(e.target.value) || 0)
  592. }
  593. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  594. />
  595. </div>
  596. <div>
  597. <label className="block text-sm text-bambu-gray mb-1">Currency</label>
  598. <select
  599. value={localSettings.currency}
  600. onChange={(e) => updateSetting('currency', e.target.value)}
  601. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  602. >
  603. <option value="USD">USD ($)</option>
  604. <option value="EUR">EUR (€)</option>
  605. <option value="GBP">GBP (£)</option>
  606. <option value="CHF">CHF (Fr.)</option>
  607. <option value="JPY">JPY (¥)</option>
  608. <option value="CNY">CNY (¥)</option>
  609. <option value="CAD">CAD ($)</option>
  610. <option value="AUD">AUD ($)</option>
  611. </select>
  612. </div>
  613. <div>
  614. <label className="block text-sm text-bambu-gray mb-1">
  615. Electricity cost per kWh
  616. </label>
  617. <input
  618. type="number"
  619. step="0.01"
  620. min="0"
  621. value={localSettings.energy_cost_per_kwh}
  622. onChange={(e) =>
  623. updateSetting('energy_cost_per_kwh', parseFloat(e.target.value) || 0)
  624. }
  625. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  626. />
  627. </div>
  628. <div>
  629. <label className="block text-sm text-bambu-gray mb-1">
  630. Energy display mode
  631. </label>
  632. <select
  633. value={localSettings.energy_tracking_mode || 'total'}
  634. onChange={(e) => updateSetting('energy_tracking_mode', e.target.value as 'print' | 'total')}
  635. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  636. >
  637. <option value="print">Prints Only</option>
  638. <option value="total">Total Consumption</option>
  639. </select>
  640. <p className="text-xs text-bambu-gray mt-1">
  641. {localSettings.energy_tracking_mode === 'print'
  642. ? 'Dashboard shows sum of energy used during prints'
  643. : 'Dashboard shows lifetime energy from smart plugs'}
  644. </p>
  645. </div>
  646. </CardContent>
  647. </Card>
  648. </div>
  649. {/* Second Column - AMS & Spoolman */}
  650. <div className="space-y-6 flex-1 lg:max-w-md">
  651. <Card>
  652. <CardHeader>
  653. <h2 className="text-lg font-semibold text-white">AMS Display Thresholds</h2>
  654. </CardHeader>
  655. <CardContent className="space-y-4">
  656. <p className="text-sm text-bambu-gray">
  657. Configure color thresholds for AMS humidity and temperature indicators.
  658. </p>
  659. {/* Humidity Thresholds */}
  660. <div className="space-y-3">
  661. <div className="flex items-center gap-2 text-white">
  662. <Droplets className="w-4 h-4 text-blue-400" />
  663. <span className="font-medium">Humidity</span>
  664. </div>
  665. <div className="grid grid-cols-2 gap-3">
  666. <div>
  667. <label className="block text-sm text-bambu-gray mb-1">
  668. Good (green) ≤
  669. </label>
  670. <div className="flex items-center gap-2">
  671. <input
  672. type="number"
  673. min="0"
  674. max="100"
  675. value={localSettings.ams_humidity_good ?? 40}
  676. onChange={(e) => updateSetting('ams_humidity_good', parseInt(e.target.value) || 40)}
  677. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  678. />
  679. <span className="text-bambu-gray">%</span>
  680. </div>
  681. </div>
  682. <div>
  683. <label className="block text-sm text-bambu-gray mb-1">
  684. Fair (orange) ≤
  685. </label>
  686. <div className="flex items-center gap-2">
  687. <input
  688. type="number"
  689. min="0"
  690. max="100"
  691. value={localSettings.ams_humidity_fair ?? 60}
  692. onChange={(e) => updateSetting('ams_humidity_fair', parseInt(e.target.value) || 60)}
  693. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  694. />
  695. <span className="text-bambu-gray">%</span>
  696. </div>
  697. </div>
  698. </div>
  699. <p className="text-xs text-bambu-gray">
  700. Above fair threshold shows as red (bad)
  701. </p>
  702. </div>
  703. {/* Temperature Thresholds */}
  704. <div className="space-y-3 pt-2 border-t border-bambu-dark-tertiary">
  705. <div className="flex items-center gap-2 text-white">
  706. <Thermometer className="w-4 h-4 text-orange-400" />
  707. <span className="font-medium">Temperature</span>
  708. </div>
  709. <div className="grid grid-cols-2 gap-3">
  710. <div>
  711. <label className="block text-sm text-bambu-gray mb-1">
  712. Good (blue) ≤
  713. </label>
  714. <div className="flex items-center gap-2">
  715. <input
  716. type="number"
  717. step="0.5"
  718. min="0"
  719. max="60"
  720. value={localSettings.ams_temp_good ?? 28}
  721. onChange={(e) => updateSetting('ams_temp_good', parseFloat(e.target.value) || 28)}
  722. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  723. />
  724. <span className="text-bambu-gray">°C</span>
  725. </div>
  726. </div>
  727. <div>
  728. <label className="block text-sm text-bambu-gray mb-1">
  729. Fair (orange) ≤
  730. </label>
  731. <div className="flex items-center gap-2">
  732. <input
  733. type="number"
  734. step="0.5"
  735. min="0"
  736. max="60"
  737. value={localSettings.ams_temp_fair ?? 35}
  738. onChange={(e) => updateSetting('ams_temp_fair', parseFloat(e.target.value) || 35)}
  739. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  740. />
  741. <span className="text-bambu-gray">°C</span>
  742. </div>
  743. </div>
  744. </div>
  745. <p className="text-xs text-bambu-gray">
  746. Above fair threshold shows as red (hot)
  747. </p>
  748. </div>
  749. {/* History Retention */}
  750. <div className="space-y-3 pt-4 border-t border-bambu-dark-tertiary">
  751. <div className="flex items-center gap-2 text-white">
  752. <Database className="w-4 h-4 text-purple-400" />
  753. <span className="font-medium">History Retention</span>
  754. </div>
  755. <div>
  756. <label className="block text-sm text-bambu-gray mb-1">
  757. Keep sensor history for
  758. </label>
  759. <div className="flex items-center gap-2">
  760. <input
  761. type="number"
  762. min="1"
  763. max="365"
  764. value={localSettings.ams_history_retention_days ?? 30}
  765. onChange={(e) => updateSetting('ams_history_retention_days', parseInt(e.target.value) || 30)}
  766. className="w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  767. />
  768. <span className="text-bambu-gray">days</span>
  769. </div>
  770. </div>
  771. <p className="text-xs text-bambu-gray">
  772. Older humidity and temperature data will be automatically deleted
  773. </p>
  774. </div>
  775. </CardContent>
  776. </Card>
  777. <SpoolmanSettings />
  778. <ExternalLinksSettings />
  779. </div>
  780. {/* Third Column - Updates */}
  781. <div className="space-y-6 flex-1 lg:max-w-sm">
  782. <Card>
  783. <CardHeader>
  784. <h2 className="text-lg font-semibold text-white">Updates</h2>
  785. </CardHeader>
  786. <CardContent className="space-y-4">
  787. <div className="flex items-center justify-between">
  788. <div>
  789. <p className="text-white">Check for updates</p>
  790. <p className="text-sm text-bambu-gray">
  791. Automatically check for new versions on startup
  792. </p>
  793. </div>
  794. <label className="relative inline-flex items-center cursor-pointer">
  795. <input
  796. type="checkbox"
  797. checked={localSettings.check_updates}
  798. onChange={(e) => updateSetting('check_updates', e.target.checked)}
  799. className="sr-only peer"
  800. />
  801. <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
  802. </label>
  803. </div>
  804. <div className="border-t border-bambu-dark-tertiary pt-4">
  805. <div className="flex items-center justify-between mb-2">
  806. <div>
  807. <p className="text-white">Current version</p>
  808. <p className="text-sm text-bambu-gray">v{versionInfo?.version || '...'}</p>
  809. </div>
  810. <Button
  811. variant="secondary"
  812. size="sm"
  813. onClick={() => refetchUpdateCheck()}
  814. disabled={isCheckingUpdate}
  815. >
  816. {isCheckingUpdate ? (
  817. <Loader2 className="w-4 h-4 animate-spin" />
  818. ) : (
  819. <RefreshCw className="w-4 h-4" />
  820. )}
  821. Check now
  822. </Button>
  823. </div>
  824. {updateCheck?.update_available ? (
  825. <div className="mt-4 p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg">
  826. <div className="flex items-start justify-between">
  827. <div>
  828. <p className="text-bambu-green font-medium">
  829. Update available: v{updateCheck.latest_version}
  830. </p>
  831. {updateCheck.release_name && updateCheck.release_name !== updateCheck.latest_version && (
  832. <p className="text-sm text-bambu-gray mt-1">{updateCheck.release_name}</p>
  833. )}
  834. {updateCheck.release_notes && (
  835. <p className="text-sm text-bambu-gray mt-2 whitespace-pre-line line-clamp-3">
  836. {updateCheck.release_notes}
  837. </p>
  838. )}
  839. </div>
  840. {updateCheck.release_url && (
  841. <a
  842. href={updateCheck.release_url}
  843. target="_blank"
  844. rel="noopener noreferrer"
  845. className="text-bambu-gray hover:text-white transition-colors"
  846. title="View release on GitHub"
  847. >
  848. <ExternalLink className="w-4 h-4" />
  849. </a>
  850. )}
  851. </div>
  852. {updateStatus?.status === 'downloading' || updateStatus?.status === 'installing' ? (
  853. <div className="mt-3">
  854. <div className="flex items-center gap-2 text-sm text-bambu-gray">
  855. <Loader2 className="w-4 h-4 animate-spin" />
  856. <span>{updateStatus.message}</span>
  857. </div>
  858. <div className="mt-2 w-full bg-bambu-dark-tertiary rounded-full h-2">
  859. <div
  860. className="bg-bambu-green h-2 rounded-full transition-all duration-300"
  861. style={{ width: `${updateStatus.progress}%` }}
  862. />
  863. </div>
  864. </div>
  865. ) : updateStatus?.status === 'complete' ? (
  866. <div className="mt-3 p-2 bg-bambu-green/20 rounded text-sm text-bambu-green">
  867. {updateStatus.message}
  868. </div>
  869. ) : updateStatus?.status === 'error' ? (
  870. <div className="mt-3 p-2 bg-red-500/20 rounded text-sm text-red-400">
  871. {updateStatus.error || updateStatus.message}
  872. </div>
  873. ) : (
  874. <Button
  875. className="mt-3"
  876. onClick={() => applyUpdateMutation.mutate()}
  877. disabled={applyUpdateMutation.isPending}
  878. >
  879. {applyUpdateMutation.isPending ? (
  880. <Loader2 className="w-4 h-4 animate-spin" />
  881. ) : (
  882. <Download className="w-4 h-4" />
  883. )}
  884. Install Update
  885. </Button>
  886. )}
  887. </div>
  888. ) : updateCheck?.error ? (
  889. <div className="mt-2 p-2 bg-red-500/10 border border-red-500/30 rounded text-sm text-red-400">
  890. Failed to check for updates: {updateCheck.error}
  891. </div>
  892. ) : updateCheck && !updateCheck.update_available ? (
  893. <p className="mt-2 text-sm text-bambu-gray">
  894. You're running the latest version
  895. </p>
  896. ) : null}
  897. </div>
  898. </CardContent>
  899. </Card>
  900. {/* Data Management */}
  901. <Card>
  902. <CardHeader>
  903. <h2 className="text-lg font-semibold text-white">Data Management</h2>
  904. </CardHeader>
  905. <CardContent className="space-y-4">
  906. {/* Backup/Restore */}
  907. <div className="flex items-center justify-between">
  908. <div>
  909. <p className="text-white">Backup Data</p>
  910. <p className="text-sm text-bambu-gray">
  911. Export settings, providers, printers, and more
  912. </p>
  913. </div>
  914. <Button
  915. variant="secondary"
  916. size="sm"
  917. onClick={() => setShowBackupModal(true)}
  918. >
  919. <Download className="w-4 h-4" />
  920. Export
  921. </Button>
  922. </div>
  923. <div className="flex items-center justify-between">
  924. <div>
  925. <p className="text-white">Restore Backup</p>
  926. <p className="text-sm text-bambu-gray">
  927. Import settings from a backup file with duplicate handling options
  928. </p>
  929. </div>
  930. <Button
  931. variant="secondary"
  932. size="sm"
  933. onClick={() => setShowRestoreModal(true)}
  934. >
  935. <Upload className="w-4 h-4" />
  936. Restore
  937. </Button>
  938. </div>
  939. <div className="border-t border-bambu-dark-tertiary pt-4">
  940. <div className="flex items-center justify-between">
  941. <div>
  942. <p className="text-white">Clear Notification Logs</p>
  943. <p className="text-sm text-bambu-gray">
  944. Delete notification logs older than 30 days
  945. </p>
  946. </div>
  947. <Button
  948. variant="secondary"
  949. size="sm"
  950. onClick={() => setShowClearLogsConfirm(true)}
  951. >
  952. <Trash2 className="w-4 h-4" />
  953. Clear
  954. </Button>
  955. </div>
  956. </div>
  957. <div className="flex items-center justify-between">
  958. <div>
  959. <p className="text-white">Reset UI Preferences</p>
  960. <p className="text-sm text-bambu-gray">
  961. Reset sidebar order, theme, view modes, and layout preferences. Printers, archives, and settings are not affected.
  962. </p>
  963. </div>
  964. <Button
  965. variant="secondary"
  966. size="sm"
  967. onClick={() => setShowClearStorageConfirm(true)}
  968. >
  969. <Trash2 className="w-4 h-4" />
  970. Reset
  971. </Button>
  972. </div>
  973. </CardContent>
  974. </Card>
  975. </div>
  976. </div>
  977. )}
  978. {/* Smart Plugs Tab */}
  979. {activeTab === 'plugs' && (
  980. <div className="max-w-4xl">
  981. <div className="flex items-start justify-between mb-6">
  982. <div>
  983. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  984. <Plug className="w-5 h-5 text-bambu-green" />
  985. Smart Plugs
  986. </h2>
  987. <p className="text-sm text-bambu-gray mt-1">
  988. Connect Tasmota-based smart plugs to automate power control and track energy usage for your printers.
  989. </p>
  990. </div>
  991. <div className="flex items-center gap-2 pt-1 shrink-0">
  992. {smartPlugs && smartPlugs.filter(p => p.enabled).length > 1 && (
  993. <>
  994. <Button
  995. variant="secondary"
  996. size="sm"
  997. className="whitespace-nowrap"
  998. onClick={() => setShowBulkPlugConfirm('on')}
  999. disabled={bulkPlugActionMutation.isPending}
  1000. title="Turn all plugs on"
  1001. >
  1002. {bulkPlugActionMutation.isPending ? (
  1003. <Loader2 className="w-4 h-4 animate-spin" />
  1004. ) : (
  1005. <Power className="w-4 h-4 text-bambu-green" />
  1006. )}
  1007. All On
  1008. </Button>
  1009. <Button
  1010. variant="secondary"
  1011. size="sm"
  1012. className="whitespace-nowrap"
  1013. onClick={() => setShowBulkPlugConfirm('off')}
  1014. disabled={bulkPlugActionMutation.isPending}
  1015. title="Turn all plugs off"
  1016. >
  1017. {bulkPlugActionMutation.isPending ? (
  1018. <Loader2 className="w-4 h-4 animate-spin" />
  1019. ) : (
  1020. <PowerOff className="w-4 h-4 text-red-400" />
  1021. )}
  1022. All Off
  1023. </Button>
  1024. </>
  1025. )}
  1026. <Button
  1027. className="whitespace-nowrap"
  1028. onClick={() => {
  1029. setEditingPlug(null);
  1030. setShowPlugModal(true);
  1031. }}
  1032. >
  1033. <Plus className="w-4 h-4" />
  1034. Add Smart Plug
  1035. </Button>
  1036. </div>
  1037. </div>
  1038. {/* Energy Summary Card */}
  1039. {smartPlugs && smartPlugs.length > 0 && (
  1040. <Card className="mb-6">
  1041. <CardHeader>
  1042. <h3 className="text-base font-semibold text-white flex items-center gap-2">
  1043. <Zap className="w-4 h-4 text-yellow-400" />
  1044. Energy Summary
  1045. {energyLoading && (
  1046. <Loader2 className="w-4 h-4 animate-spin text-bambu-gray ml-2" />
  1047. )}
  1048. </h3>
  1049. </CardHeader>
  1050. <CardContent>
  1051. {plugEnergySummary ? (
  1052. <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
  1053. {/* Current Power */}
  1054. <div className="bg-bambu-dark rounded-lg p-3">
  1055. <div className="flex items-center gap-2 text-bambu-gray text-xs mb-1">
  1056. <Zap className="w-3 h-3" />
  1057. Current Power
  1058. </div>
  1059. <div className="text-xl font-bold text-white">
  1060. {plugEnergySummary.totalPower.toFixed(1)}
  1061. <span className="text-sm font-normal text-bambu-gray ml-1">W</span>
  1062. </div>
  1063. <div className="text-xs text-bambu-gray mt-1">
  1064. {plugEnergySummary.reachableCount}/{plugEnergySummary.totalPlugs} plugs online
  1065. </div>
  1066. </div>
  1067. {/* Today */}
  1068. <div className="bg-bambu-dark rounded-lg p-3">
  1069. <div className="flex items-center gap-2 text-bambu-gray text-xs mb-1">
  1070. <Calendar className="w-3 h-3" />
  1071. Today
  1072. </div>
  1073. <div className="text-xl font-bold text-white">
  1074. {plugEnergySummary.totalToday.toFixed(2)}
  1075. <span className="text-sm font-normal text-bambu-gray ml-1">kWh</span>
  1076. </div>
  1077. {localSettings && localSettings.energy_cost_per_kwh > 0 && (
  1078. <div className="text-xs text-bambu-gray mt-1">
  1079. ~{(plugEnergySummary.totalToday * localSettings.energy_cost_per_kwh).toFixed(2)} {localSettings.currency}
  1080. </div>
  1081. )}
  1082. </div>
  1083. {/* Yesterday */}
  1084. <div className="bg-bambu-dark rounded-lg p-3">
  1085. <div className="flex items-center gap-2 text-bambu-gray text-xs mb-1">
  1086. <TrendingUp className="w-3 h-3" />
  1087. Yesterday
  1088. </div>
  1089. <div className="text-xl font-bold text-white">
  1090. {plugEnergySummary.totalYesterday.toFixed(2)}
  1091. <span className="text-sm font-normal text-bambu-gray ml-1">kWh</span>
  1092. </div>
  1093. {localSettings && localSettings.energy_cost_per_kwh > 0 && (
  1094. <div className="text-xs text-bambu-gray mt-1">
  1095. ~{(plugEnergySummary.totalYesterday * localSettings.energy_cost_per_kwh).toFixed(2)} {localSettings.currency}
  1096. </div>
  1097. )}
  1098. </div>
  1099. {/* Total Lifetime */}
  1100. <div className="bg-bambu-dark rounded-lg p-3">
  1101. <div className="flex items-center gap-2 text-bambu-gray text-xs mb-1">
  1102. <DollarSign className="w-3 h-3" />
  1103. Total
  1104. </div>
  1105. <div className="text-xl font-bold text-white">
  1106. {plugEnergySummary.totalLifetime.toFixed(1)}
  1107. <span className="text-sm font-normal text-bambu-gray ml-1">kWh</span>
  1108. </div>
  1109. {localSettings && localSettings.energy_cost_per_kwh > 0 && (
  1110. <div className="text-xs text-bambu-gray mt-1">
  1111. ~{(plugEnergySummary.totalLifetime * localSettings.energy_cost_per_kwh).toFixed(2)} {localSettings.currency}
  1112. </div>
  1113. )}
  1114. </div>
  1115. </div>
  1116. ) : !energyLoading ? (
  1117. <p className="text-sm text-bambu-gray">
  1118. Enable plugs to see energy summary
  1119. </p>
  1120. ) : null}
  1121. </CardContent>
  1122. </Card>
  1123. )}
  1124. {plugsLoading ? (
  1125. <div className="flex justify-center py-12">
  1126. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  1127. </div>
  1128. ) : smartPlugs && smartPlugs.length > 0 ? (
  1129. <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
  1130. {smartPlugs.map((plug) => (
  1131. <SmartPlugCard
  1132. key={plug.id}
  1133. plug={plug}
  1134. onEdit={(p) => {
  1135. setEditingPlug(p);
  1136. setShowPlugModal(true);
  1137. }}
  1138. />
  1139. ))}
  1140. </div>
  1141. ) : (
  1142. <Card>
  1143. <CardContent className="py-12">
  1144. <div className="text-center text-bambu-gray">
  1145. <Plug className="w-16 h-16 mx-auto mb-4 opacity-30" />
  1146. <p className="text-lg font-medium text-white mb-2">No smart plugs configured</p>
  1147. <p className="text-sm mb-4">Add a Tasmota-based smart plug to track energy usage and automate power control.</p>
  1148. <Button
  1149. onClick={() => {
  1150. setEditingPlug(null);
  1151. setShowPlugModal(true);
  1152. }}
  1153. >
  1154. <Plus className="w-4 h-4" />
  1155. Add Your First Smart Plug
  1156. </Button>
  1157. </div>
  1158. </CardContent>
  1159. </Card>
  1160. )}
  1161. </div>
  1162. )}
  1163. {/* Notifications Tab */}
  1164. {activeTab === 'notifications' && (
  1165. <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
  1166. {/* Left Column: Providers */}
  1167. <div>
  1168. <div className="flex items-center justify-between mb-4">
  1169. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  1170. <Bell className="w-5 h-5 text-bambu-green" />
  1171. Providers
  1172. </h2>
  1173. <div className="flex items-center gap-2">
  1174. <Button
  1175. size="sm"
  1176. variant="secondary"
  1177. onClick={() => setShowLogViewer(true)}
  1178. >
  1179. <History className="w-4 h-4" />
  1180. Log
  1181. </Button>
  1182. {notificationProviders && notificationProviders.length > 0 && (
  1183. <Button
  1184. size="sm"
  1185. variant="secondary"
  1186. onClick={() => {
  1187. setTestAllResult(null);
  1188. testAllMutation.mutate();
  1189. }}
  1190. disabled={testAllMutation.isPending}
  1191. >
  1192. {testAllMutation.isPending ? (
  1193. <Loader2 className="w-4 h-4 animate-spin" />
  1194. ) : (
  1195. <Send className="w-4 h-4" />
  1196. )}
  1197. Test All
  1198. </Button>
  1199. )}
  1200. <Button
  1201. size="sm"
  1202. onClick={() => {
  1203. setEditingProvider(null);
  1204. setShowNotificationModal(true);
  1205. }}
  1206. >
  1207. <Plus className="w-4 h-4" />
  1208. Add
  1209. </Button>
  1210. </div>
  1211. </div>
  1212. {/* Notification Language Setting */}
  1213. <Card className="mb-4">
  1214. <CardContent className="py-3">
  1215. <div className="flex items-center justify-between">
  1216. <div>
  1217. <p className="text-white text-sm font-medium">{t('settings.notificationLanguage')}</p>
  1218. <p className="text-xs text-bambu-gray">{t('settings.notificationLanguageDescription')}</p>
  1219. </div>
  1220. <select
  1221. value={localSettings.notification_language || 'en'}
  1222. onChange={(e) => updateSetting('notification_language', e.target.value)}
  1223. className="px-2 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:ring-1 focus:ring-bambu-green"
  1224. >
  1225. {availableLanguages.map((lang) => (
  1226. <option key={lang.code} value={lang.code}>
  1227. {lang.nativeName}
  1228. </option>
  1229. ))}
  1230. </select>
  1231. </div>
  1232. </CardContent>
  1233. </Card>
  1234. {/* Test All Results */}
  1235. {testAllResult && (
  1236. <Card className="mb-4">
  1237. <CardContent className="py-3">
  1238. <div className="flex items-center justify-between mb-2">
  1239. <span className="text-sm font-medium text-white">Test Results</span>
  1240. <button
  1241. onClick={() => setTestAllResult(null)}
  1242. className="text-bambu-gray hover:text-white text-xs"
  1243. >
  1244. Dismiss
  1245. </button>
  1246. </div>
  1247. <div className="flex items-center gap-4 text-sm mb-2">
  1248. <span className="flex items-center gap-1 text-bambu-green">
  1249. <CheckCircle className="w-4 h-4" />
  1250. {testAllResult.success} passed
  1251. </span>
  1252. {testAllResult.failed > 0 && (
  1253. <span className="flex items-center gap-1 text-red-400">
  1254. <XCircle className="w-4 h-4" />
  1255. {testAllResult.failed} failed
  1256. </span>
  1257. )}
  1258. </div>
  1259. {testAllResult.results.filter(r => !r.success).length > 0 && (
  1260. <div className="space-y-1 mt-2 pt-2 border-t border-bambu-dark-tertiary">
  1261. {testAllResult.results.filter(r => !r.success).map((result) => (
  1262. <div key={result.provider_id} className="text-xs text-red-400">
  1263. <span className="font-medium">{result.provider_name}:</span> {result.message}
  1264. </div>
  1265. ))}
  1266. </div>
  1267. )}
  1268. </CardContent>
  1269. </Card>
  1270. )}
  1271. {providersLoading ? (
  1272. <div className="flex justify-center py-12">
  1273. <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
  1274. </div>
  1275. ) : notificationProviders && notificationProviders.length > 0 ? (
  1276. <div className="space-y-3">
  1277. {notificationProviders.map((provider) => (
  1278. <NotificationProviderCard
  1279. key={provider.id}
  1280. provider={provider}
  1281. onEdit={(p) => {
  1282. setEditingProvider(p);
  1283. setShowNotificationModal(true);
  1284. }}
  1285. />
  1286. ))}
  1287. </div>
  1288. ) : (
  1289. <Card>
  1290. <CardContent className="py-8">
  1291. <div className="text-center text-bambu-gray">
  1292. <Bell className="w-12 h-12 mx-auto mb-3 opacity-30" />
  1293. <p className="text-sm font-medium text-white mb-2">No providers configured</p>
  1294. <p className="text-xs mb-3">Add a provider to receive alerts.</p>
  1295. <Button
  1296. size="sm"
  1297. onClick={() => {
  1298. setEditingProvider(null);
  1299. setShowNotificationModal(true);
  1300. }}
  1301. >
  1302. <Plus className="w-4 h-4" />
  1303. Add Provider
  1304. </Button>
  1305. </div>
  1306. </CardContent>
  1307. </Card>
  1308. )}
  1309. </div>
  1310. {/* Right Column: Templates */}
  1311. <div>
  1312. <h2 className="text-lg font-semibold text-white flex items-center gap-2 mb-4">
  1313. <FileText className="w-5 h-5 text-bambu-green" />
  1314. Message Templates
  1315. </h2>
  1316. <p className="text-sm text-bambu-gray mb-4">
  1317. Customize notification messages for each event.
  1318. </p>
  1319. {templatesLoading ? (
  1320. <div className="flex justify-center py-8">
  1321. <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
  1322. </div>
  1323. ) : notificationTemplates && notificationTemplates.length > 0 ? (
  1324. <div className="space-y-2">
  1325. {notificationTemplates.map((template) => (
  1326. <Card
  1327. key={template.id}
  1328. className="cursor-pointer hover:border-bambu-green/50 transition-colors"
  1329. onClick={() => setEditingTemplate(template)}
  1330. >
  1331. <CardContent className="py-2.5 px-3">
  1332. <div className="flex items-center justify-between">
  1333. <div className="min-w-0 flex-1">
  1334. <p className="text-white font-medium text-sm truncate">{template.name}</p>
  1335. <p className="text-bambu-gray text-xs truncate mt-0.5">
  1336. {template.title_template}
  1337. </p>
  1338. </div>
  1339. <button
  1340. className="p-1.5 hover:bg-bambu-dark-tertiary rounded transition-colors shrink-0 ml-2"
  1341. onClick={(e) => {
  1342. e.stopPropagation();
  1343. setEditingTemplate(template);
  1344. }}
  1345. >
  1346. <Edit2 className="w-4 h-4 text-bambu-gray" />
  1347. </button>
  1348. </div>
  1349. </CardContent>
  1350. </Card>
  1351. ))}
  1352. </div>
  1353. ) : (
  1354. <Card>
  1355. <CardContent className="py-8">
  1356. <div className="text-center text-bambu-gray">
  1357. <FileText className="w-12 h-12 mx-auto mb-3 opacity-30" />
  1358. <p className="text-sm">No templates available. Restart the backend to seed default templates.</p>
  1359. </div>
  1360. </CardContent>
  1361. </Card>
  1362. )}
  1363. </div>
  1364. </div>
  1365. )}
  1366. {/* API Keys Tab */}
  1367. {activeTab === 'apikeys' && (
  1368. <div className="max-w-3xl">
  1369. <div className="flex items-start justify-between gap-4 mb-6">
  1370. <div className="flex-1">
  1371. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  1372. <Key className="w-5 h-5 text-bambu-green" />
  1373. API Keys
  1374. </h2>
  1375. <p className="text-sm text-bambu-gray mt-1">
  1376. Create API keys for external integrations and webhooks. Use these keys to control your printers from automation tools like Home Assistant.
  1377. </p>
  1378. </div>
  1379. <Button size="sm" onClick={() => setShowCreateAPIKey(true)} className="flex-shrink-0">
  1380. <Plus className="w-4 h-4" />
  1381. Create Key
  1382. </Button>
  1383. </div>
  1384. {/* Created Key Display */}
  1385. {createdAPIKey && (
  1386. <Card className="mb-6 border-bambu-green">
  1387. <CardContent className="py-4">
  1388. <div className="flex items-start gap-3">
  1389. <CheckCircle className="w-5 h-5 text-bambu-green flex-shrink-0 mt-0.5" />
  1390. <div className="flex-1">
  1391. <p className="text-white font-medium mb-1">API Key Created Successfully</p>
  1392. <p className="text-sm text-bambu-gray mb-2">
  1393. Copy this key now - it won't be shown again!
  1394. </p>
  1395. <div className="flex items-center gap-2 bg-bambu-dark rounded-lg p-2">
  1396. <code className="flex-1 text-sm text-bambu-green font-mono break-all">
  1397. {createdAPIKey}
  1398. </code>
  1399. <Button
  1400. variant="secondary"
  1401. size="sm"
  1402. onClick={async () => {
  1403. try {
  1404. if (navigator.clipboard && navigator.clipboard.writeText) {
  1405. await navigator.clipboard.writeText(createdAPIKey);
  1406. } else {
  1407. // Fallback for non-HTTPS contexts
  1408. const textArea = document.createElement('textarea');
  1409. textArea.value = createdAPIKey;
  1410. textArea.style.position = 'fixed';
  1411. textArea.style.left = '-999999px';
  1412. document.body.appendChild(textArea);
  1413. textArea.select();
  1414. document.execCommand('copy');
  1415. document.body.removeChild(textArea);
  1416. }
  1417. showToast('Key copied to clipboard');
  1418. } catch {
  1419. showToast('Failed to copy key', 'error');
  1420. }
  1421. }}
  1422. >
  1423. <Copy className="w-4 h-4" />
  1424. </Button>
  1425. </div>
  1426. <Button
  1427. variant="secondary"
  1428. size="sm"
  1429. className="mt-3"
  1430. onClick={() => setCreatedAPIKey(null)}
  1431. >
  1432. Dismiss
  1433. </Button>
  1434. </div>
  1435. </div>
  1436. </CardContent>
  1437. </Card>
  1438. )}
  1439. {/* Create Key Form */}
  1440. {showCreateAPIKey && (
  1441. <Card className="mb-6">
  1442. <CardHeader>
  1443. <h3 className="text-base font-semibold text-white">Create New API Key</h3>
  1444. </CardHeader>
  1445. <CardContent className="space-y-4">
  1446. <div>
  1447. <label className="block text-sm text-bambu-gray mb-1">Key Name</label>
  1448. <input
  1449. type="text"
  1450. value={newAPIKeyName}
  1451. onChange={(e) => setNewAPIKeyName(e.target.value)}
  1452. placeholder="e.g., Home Assistant, OctoPrint"
  1453. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  1454. />
  1455. </div>
  1456. <div>
  1457. <label className="block text-sm text-bambu-gray mb-2">Permissions</label>
  1458. <div className="space-y-2">
  1459. <label className="flex items-center gap-3 cursor-pointer">
  1460. <input
  1461. type="checkbox"
  1462. checked={newAPIKeyPermissions.can_read_status}
  1463. onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_read_status: e.target.checked }))}
  1464. className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
  1465. />
  1466. <div>
  1467. <span className="text-white">Read Status</span>
  1468. <p className="text-xs text-bambu-gray">View printer status and queue</p>
  1469. </div>
  1470. </label>
  1471. <label className="flex items-center gap-3 cursor-pointer">
  1472. <input
  1473. type="checkbox"
  1474. checked={newAPIKeyPermissions.can_queue}
  1475. onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_queue: e.target.checked }))}
  1476. className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
  1477. />
  1478. <div>
  1479. <span className="text-white">Manage Queue</span>
  1480. <p className="text-xs text-bambu-gray">Add and remove items from print queue</p>
  1481. </div>
  1482. </label>
  1483. <label className="flex items-center gap-3 cursor-pointer">
  1484. <input
  1485. type="checkbox"
  1486. checked={newAPIKeyPermissions.can_control_printer}
  1487. onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_control_printer: e.target.checked }))}
  1488. className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
  1489. />
  1490. <div>
  1491. <span className="text-white">Control Printer</span>
  1492. <p className="text-xs text-bambu-gray">Pause, resume, and stop prints</p>
  1493. </div>
  1494. </label>
  1495. </div>
  1496. </div>
  1497. <div className="flex items-center gap-2 pt-2">
  1498. <Button
  1499. onClick={() => createAPIKeyMutation.mutate({
  1500. name: newAPIKeyName || 'Unnamed Key',
  1501. ...newAPIKeyPermissions,
  1502. })}
  1503. disabled={createAPIKeyMutation.isPending}
  1504. >
  1505. {createAPIKeyMutation.isPending ? (
  1506. <Loader2 className="w-4 h-4 animate-spin" />
  1507. ) : (
  1508. <Plus className="w-4 h-4" />
  1509. )}
  1510. Create Key
  1511. </Button>
  1512. <Button variant="secondary" onClick={() => setShowCreateAPIKey(false)}>
  1513. Cancel
  1514. </Button>
  1515. </div>
  1516. </CardContent>
  1517. </Card>
  1518. )}
  1519. {/* Existing Keys List */}
  1520. {apiKeysLoading ? (
  1521. <div className="flex justify-center py-12">
  1522. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  1523. </div>
  1524. ) : apiKeys && apiKeys.length > 0 ? (
  1525. <div className="space-y-3">
  1526. {apiKeys.map((key) => (
  1527. <Card key={key.id}>
  1528. <CardContent className="py-3">
  1529. <div className="flex items-center justify-between">
  1530. <div className="flex items-center gap-3">
  1531. <Key className={`w-5 h-5 ${key.enabled ? 'text-bambu-green' : 'text-bambu-gray'}`} />
  1532. <div>
  1533. <p className="text-white font-medium">{key.name}</p>
  1534. <p className="text-xs text-bambu-gray">
  1535. {key.key_prefix}••••••••
  1536. {key.last_used && ` · Last used: ${new Date(key.last_used).toLocaleDateString()}`}
  1537. </p>
  1538. </div>
  1539. </div>
  1540. <div className="flex items-center gap-2">
  1541. <div className="flex gap-1 text-xs">
  1542. {key.can_read_status && (
  1543. <span className="px-1.5 py-0.5 bg-blue-500/20 text-blue-400 rounded">Read</span>
  1544. )}
  1545. {key.can_queue && (
  1546. <span className="px-1.5 py-0.5 bg-green-500/20 text-green-400 rounded">Queue</span>
  1547. )}
  1548. {key.can_control_printer && (
  1549. <span className="px-1.5 py-0.5 bg-orange-500/20 text-orange-400 rounded">Control</span>
  1550. )}
  1551. </div>
  1552. <Button
  1553. variant="secondary"
  1554. size="sm"
  1555. onClick={() => setShowDeleteAPIKeyConfirm(key.id)}
  1556. >
  1557. <Trash2 className="w-4 h-4 text-red-400" />
  1558. </Button>
  1559. </div>
  1560. </div>
  1561. </CardContent>
  1562. </Card>
  1563. ))}
  1564. </div>
  1565. ) : (
  1566. <Card>
  1567. <CardContent className="py-12">
  1568. <div className="text-center text-bambu-gray">
  1569. <Key className="w-16 h-16 mx-auto mb-4 opacity-30" />
  1570. <p className="text-lg font-medium text-white mb-2">No API keys</p>
  1571. <p className="text-sm mb-4">Create an API key to integrate with external services.</p>
  1572. <Button onClick={() => setShowCreateAPIKey(true)}>
  1573. <Plus className="w-4 h-4" />
  1574. Create Your First Key
  1575. </Button>
  1576. </div>
  1577. </CardContent>
  1578. </Card>
  1579. )}
  1580. {/* Webhook Documentation */}
  1581. <Card className="mt-6">
  1582. <CardHeader>
  1583. <h3 className="text-base font-semibold text-white">Webhook Endpoints</h3>
  1584. </CardHeader>
  1585. <CardContent className="space-y-3 text-sm">
  1586. <p className="text-bambu-gray">
  1587. Use your API key in the <code className="text-bambu-green">X-API-Key</code> header.
  1588. </p>
  1589. <div className="space-y-2 font-mono text-xs">
  1590. <div className="p-2 bg-bambu-dark rounded">
  1591. <span className="text-blue-400">GET</span>{' '}
  1592. <span className="text-white">/api/v1/webhook/status</span>
  1593. <span className="text-bambu-gray"> - Get all printer status</span>
  1594. </div>
  1595. <div className="p-2 bg-bambu-dark rounded">
  1596. <span className="text-blue-400">GET</span>{' '}
  1597. <span className="text-white">/api/v1/webhook/status/:id</span>
  1598. <span className="text-bambu-gray"> - Get specific printer status</span>
  1599. </div>
  1600. <div className="p-2 bg-bambu-dark rounded">
  1601. <span className="text-green-400">POST</span>{' '}
  1602. <span className="text-white">/api/v1/webhook/queue</span>
  1603. <span className="text-bambu-gray"> - Add to print queue</span>
  1604. </div>
  1605. <div className="p-2 bg-bambu-dark rounded">
  1606. <span className="text-orange-400">POST</span>{' '}
  1607. <span className="text-white">/api/v1/webhook/printer/:id/pause</span>
  1608. <span className="text-bambu-gray"> - Pause print</span>
  1609. </div>
  1610. <div className="p-2 bg-bambu-dark rounded">
  1611. <span className="text-orange-400">POST</span>{' '}
  1612. <span className="text-white">/api/v1/webhook/printer/:id/resume</span>
  1613. <span className="text-bambu-gray"> - Resume print</span>
  1614. </div>
  1615. <div className="p-2 bg-bambu-dark rounded">
  1616. <span className="text-red-400">POST</span>{' '}
  1617. <span className="text-white">/api/v1/webhook/printer/:id/stop</span>
  1618. <span className="text-bambu-gray"> - Stop print</span>
  1619. </div>
  1620. </div>
  1621. </CardContent>
  1622. </Card>
  1623. </div>
  1624. )}
  1625. {/* Delete API Key Confirmation */}
  1626. {showDeleteAPIKeyConfirm !== null && (
  1627. <ConfirmModal
  1628. title="Delete API Key"
  1629. message="Are you sure you want to delete this API key? Any integrations using this key will stop working."
  1630. confirmText="Delete Key"
  1631. variant="danger"
  1632. onConfirm={() => {
  1633. deleteAPIKeyMutation.mutate(showDeleteAPIKeyConfirm);
  1634. setShowDeleteAPIKeyConfirm(null);
  1635. }}
  1636. onCancel={() => setShowDeleteAPIKeyConfirm(null)}
  1637. />
  1638. )}
  1639. {/* Smart Plug Modal */}
  1640. {showPlugModal && (
  1641. <AddSmartPlugModal
  1642. plug={editingPlug}
  1643. onClose={() => {
  1644. setShowPlugModal(false);
  1645. setEditingPlug(null);
  1646. }}
  1647. />
  1648. )}
  1649. {/* Notification Modal */}
  1650. {showNotificationModal && (
  1651. <AddNotificationModal
  1652. provider={editingProvider}
  1653. onClose={() => {
  1654. setShowNotificationModal(false);
  1655. setEditingProvider(null);
  1656. }}
  1657. />
  1658. )}
  1659. {/* Template Editor Modal */}
  1660. {editingTemplate && (
  1661. <NotificationTemplateEditor
  1662. template={editingTemplate}
  1663. onClose={() => setEditingTemplate(null)}
  1664. />
  1665. )}
  1666. {/* Notification Log Viewer */}
  1667. {showLogViewer && (
  1668. <NotificationLogViewer
  1669. onClose={() => setShowLogViewer(false)}
  1670. />
  1671. )}
  1672. {/* Confirm Modal: Clear Notification Logs */}
  1673. {showClearLogsConfirm && (
  1674. <ConfirmModal
  1675. title="Clear Notification Logs"
  1676. message="This will permanently delete all notification logs older than 30 days. This action cannot be undone."
  1677. confirmText="Clear Logs"
  1678. variant="warning"
  1679. onConfirm={async () => {
  1680. setShowClearLogsConfirm(false);
  1681. try {
  1682. const result = await api.clearNotificationLogs(30);
  1683. showToast(result.message, 'success');
  1684. } catch {
  1685. showToast('Failed to clear logs', 'error');
  1686. }
  1687. }}
  1688. onCancel={() => setShowClearLogsConfirm(false)}
  1689. />
  1690. )}
  1691. {/* Confirm Modal: Clear Local Storage */}
  1692. {showClearStorageConfirm && (
  1693. <ConfirmModal
  1694. title="Reset UI Preferences"
  1695. message="This will reset all UI preferences to defaults: sidebar order, theme, dashboard layout, view modes, and sorting preferences. Your printers, archives, and server settings will NOT be affected. The page will reload after clearing."
  1696. confirmText="Reset Preferences"
  1697. variant="default"
  1698. onConfirm={() => {
  1699. setShowClearStorageConfirm(false);
  1700. localStorage.clear();
  1701. showToast('UI preferences reset. Refreshing...', 'success');
  1702. setTimeout(() => window.location.reload(), 1000);
  1703. }}
  1704. onCancel={() => setShowClearStorageConfirm(false)}
  1705. />
  1706. )}
  1707. {/* Confirm Modal: Bulk Plug Action */}
  1708. {showBulkPlugConfirm && (
  1709. <ConfirmModal
  1710. title={`Turn All Plugs ${showBulkPlugConfirm === 'on' ? 'On' : 'Off'}`}
  1711. message={`This will turn ${showBulkPlugConfirm === 'on' ? 'ON' : 'OFF'} all ${smartPlugs?.filter(p => p.enabled).length || 0} enabled smart plugs. ${showBulkPlugConfirm === 'off' ? 'Any running printers may be affected!' : ''}`}
  1712. confirmText={`Turn All ${showBulkPlugConfirm === 'on' ? 'On' : 'Off'}`}
  1713. variant={showBulkPlugConfirm === 'off' ? 'danger' : 'warning'}
  1714. onConfirm={() => {
  1715. const action = showBulkPlugConfirm;
  1716. setShowBulkPlugConfirm(null);
  1717. bulkPlugActionMutation.mutate(action);
  1718. }}
  1719. onCancel={() => setShowBulkPlugConfirm(null)}
  1720. />
  1721. )}
  1722. {/* Backup Modal */}
  1723. {showBackupModal && (
  1724. <BackupModal
  1725. onClose={() => setShowBackupModal(false)}
  1726. onExport={async (categories) => {
  1727. setShowBackupModal(false);
  1728. try {
  1729. const { blob, filename } = await api.exportBackup(categories);
  1730. const url = URL.createObjectURL(blob);
  1731. const a = document.createElement('a');
  1732. a.href = url;
  1733. a.download = filename;
  1734. a.click();
  1735. URL.revokeObjectURL(url);
  1736. showToast('Backup downloaded', 'success');
  1737. } catch (err) {
  1738. showToast('Failed to create backup', 'error');
  1739. }
  1740. }}
  1741. />
  1742. )}
  1743. {/* Restore Modal */}
  1744. {showRestoreModal && (
  1745. <RestoreModal
  1746. onClose={() => setShowRestoreModal(false)}
  1747. onRestore={async (file, overwrite) => {
  1748. return await api.importBackup(file, overwrite);
  1749. }}
  1750. onSuccess={() => {
  1751. queryClient.invalidateQueries();
  1752. }}
  1753. />
  1754. )}
  1755. </div>
  1756. );
  1757. }