SettingsPage.tsx 107 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381
  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, Info, X, Shield, Printer, Cylinder } from 'lucide-react';
  3. import { useTranslation } from 'react-i18next';
  4. import { api } from '../api/client';
  5. import { formatDateOnly } from '../utils/date';
  6. import type { AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus } from '../api/client';
  7. import { Card, CardContent, CardHeader } from '../components/Card';
  8. import { Button } from '../components/Button';
  9. import { SmartPlugCard } from '../components/SmartPlugCard';
  10. import { AddSmartPlugModal } from '../components/AddSmartPlugModal';
  11. import { NotificationProviderCard } from '../components/NotificationProviderCard';
  12. import { AddNotificationModal } from '../components/AddNotificationModal';
  13. import { NotificationTemplateEditor } from '../components/NotificationTemplateEditor';
  14. import { NotificationLogViewer } from '../components/NotificationLogViewer';
  15. import { ConfirmModal } from '../components/ConfirmModal';
  16. import { BackupModal } from '../components/BackupModal';
  17. import { RestoreModal } from '../components/RestoreModal';
  18. import { SpoolmanSettings } from '../components/SpoolmanSettings';
  19. import { ExternalLinksSettings } from '../components/ExternalLinksSettings';
  20. import { VirtualPrinterSettings } from '../components/VirtualPrinterSettings';
  21. import { APIBrowser } from '../components/APIBrowser';
  22. import { virtualPrinterApi } from '../api/client';
  23. import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
  24. import { availableLanguages } from '../i18n';
  25. import { useToast } from '../contexts/ToastContext';
  26. import { useTheme, type ThemeStyle, type DarkBackground, type LightBackground, type ThemeAccent } from '../contexts/ThemeContext';
  27. import { useState, useEffect, useRef, useCallback } from 'react';
  28. import { Palette } from 'lucide-react';
  29. export function SettingsPage() {
  30. const queryClient = useQueryClient();
  31. const { t, i18n } = useTranslation();
  32. const { showToast, showPersistentToast, dismissToast } = useToast();
  33. const {
  34. mode,
  35. darkStyle, darkBackground, darkAccent,
  36. lightStyle, lightBackground, lightAccent,
  37. setDarkStyle, setDarkBackground, setDarkAccent,
  38. setLightStyle, setLightBackground, setLightAccent,
  39. } = useTheme();
  40. const [localSettings, setLocalSettings] = useState<AppSettings | null>(null);
  41. const [showPlugModal, setShowPlugModal] = useState(false);
  42. const [editingPlug, setEditingPlug] = useState<SmartPlug | null>(null);
  43. const [showNotificationModal, setShowNotificationModal] = useState(false);
  44. const [editingProvider, setEditingProvider] = useState<NotificationProvider | null>(null);
  45. const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(null);
  46. const [showLogViewer, setShowLogViewer] = useState(false);
  47. const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());
  48. const [activeTab, setActiveTab] = useState<'general' | 'plugs' | 'notifications' | 'filament' | 'apikeys' | 'virtual-printer'>('general');
  49. const [showCreateAPIKey, setShowCreateAPIKey] = useState(false);
  50. const [newAPIKeyName, setNewAPIKeyName] = useState('');
  51. const [newAPIKeyPermissions, setNewAPIKeyPermissions] = useState({
  52. can_queue: true,
  53. can_control_printer: false,
  54. can_read_status: true,
  55. });
  56. const [createdAPIKey, setCreatedAPIKey] = useState<string | null>(null);
  57. const [showDeleteAPIKeyConfirm, setShowDeleteAPIKeyConfirm] = useState<number | null>(null);
  58. const [testApiKey, setTestApiKey] = useState('');
  59. // Confirm modal states
  60. const [showClearLogsConfirm, setShowClearLogsConfirm] = useState(false);
  61. const [showClearStorageConfirm, setShowClearStorageConfirm] = useState(false);
  62. const [showBulkPlugConfirm, setShowBulkPlugConfirm] = useState<'on' | 'off' | null>(null);
  63. const [showBackupModal, setShowBackupModal] = useState(false);
  64. const [showRestoreModal, setShowRestoreModal] = useState(false);
  65. const [showTelemetryInfo, setShowTelemetryInfo] = useState(false);
  66. const [showReleaseNotes, setShowReleaseNotes] = useState(false);
  67. const handleDefaultViewChange = (path: string) => {
  68. setDefaultViewState(path);
  69. setDefaultView(path);
  70. };
  71. const handleResetSidebarOrder = () => {
  72. localStorage.removeItem('sidebarOrder');
  73. window.location.reload();
  74. };
  75. const { data: settings, isLoading } = useQuery({
  76. queryKey: ['settings'],
  77. queryFn: api.getSettings,
  78. });
  79. const { data: smartPlugs, isLoading: plugsLoading } = useQuery({
  80. queryKey: ['smart-plugs'],
  81. queryFn: api.getSmartPlugs,
  82. });
  83. // Fetch energy data for all smart plugs when on the plugs tab
  84. const { data: plugEnergySummary, isLoading: energyLoading } = useQuery({
  85. queryKey: ['smart-plugs-energy', smartPlugs?.map(p => p.id)],
  86. queryFn: async () => {
  87. if (!smartPlugs || smartPlugs.length === 0) return null;
  88. const statuses = await Promise.all(
  89. smartPlugs.filter(p => p.enabled).map(async (plug) => {
  90. try {
  91. const status = await api.getSmartPlugStatus(plug.id);
  92. return { plug, status };
  93. } catch {
  94. return { plug, status: null as SmartPlugStatus | null };
  95. }
  96. })
  97. );
  98. // Aggregate energy data
  99. let totalPower = 0;
  100. let totalToday = 0;
  101. let totalYesterday = 0;
  102. let totalLifetime = 0;
  103. let reachableCount = 0;
  104. for (const { status } of statuses) {
  105. if (status?.reachable && status.energy) {
  106. reachableCount++;
  107. if (status.energy.power != null) totalPower += status.energy.power;
  108. if (status.energy.today != null) totalToday += status.energy.today;
  109. if (status.energy.yesterday != null) totalYesterday += status.energy.yesterday;
  110. if (status.energy.total != null) totalLifetime += status.energy.total;
  111. }
  112. }
  113. return {
  114. totalPower,
  115. totalToday,
  116. totalYesterday,
  117. totalLifetime,
  118. reachableCount,
  119. totalPlugs: smartPlugs.filter(p => p.enabled).length,
  120. };
  121. },
  122. enabled: activeTab === 'plugs' && !!smartPlugs && smartPlugs.length > 0,
  123. refetchInterval: activeTab === 'plugs' ? 10000 : false, // Refresh every 10s when on plugs tab
  124. });
  125. const { data: notificationProviders, isLoading: providersLoading } = useQuery({
  126. queryKey: ['notification-providers'],
  127. queryFn: api.getNotificationProviders,
  128. });
  129. const { data: apiKeys, isLoading: apiKeysLoading } = useQuery({
  130. queryKey: ['api-keys'],
  131. queryFn: api.getAPIKeys,
  132. });
  133. const createAPIKeyMutation = useMutation({
  134. mutationFn: (data: { name: string; can_queue: boolean; can_control_printer: boolean; can_read_status: boolean }) =>
  135. api.createAPIKey(data),
  136. onSuccess: (data) => {
  137. setCreatedAPIKey(data.key || null);
  138. setShowCreateAPIKey(false);
  139. setNewAPIKeyName('');
  140. queryClient.invalidateQueries({ queryKey: ['api-keys'] });
  141. showToast('API key created');
  142. },
  143. onError: (error: Error) => {
  144. showToast(`Failed to create API key: ${error.message}`, 'error');
  145. },
  146. });
  147. const deleteAPIKeyMutation = useMutation({
  148. mutationFn: (id: number) => api.deleteAPIKey(id),
  149. onSuccess: () => {
  150. queryClient.invalidateQueries({ queryKey: ['api-keys'] });
  151. showToast('API key deleted');
  152. },
  153. onError: (error: Error) => {
  154. showToast(`Failed to delete API key: ${error.message}`, 'error');
  155. },
  156. });
  157. const { data: printers } = useQuery({
  158. queryKey: ['printers'],
  159. queryFn: api.getPrinters,
  160. });
  161. const { data: notificationTemplates, isLoading: templatesLoading } = useQuery({
  162. queryKey: ['notification-templates'],
  163. queryFn: api.getNotificationTemplates,
  164. });
  165. // Virtual printer status for tab indicator
  166. const { data: virtualPrinterSettings } = useQuery({
  167. queryKey: ['virtual-printer-settings'],
  168. queryFn: virtualPrinterApi.getSettings,
  169. refetchInterval: 10000,
  170. });
  171. const virtualPrinterRunning = virtualPrinterSettings?.status?.running ?? false;
  172. const { data: ffmpegStatus } = useQuery({
  173. queryKey: ['ffmpeg-status'],
  174. queryFn: api.checkFfmpeg,
  175. });
  176. const { data: versionInfo } = useQuery({
  177. queryKey: ['version'],
  178. queryFn: api.getVersion,
  179. });
  180. const { data: updateCheck, refetch: refetchUpdateCheck, isRefetching: isCheckingUpdate } = useQuery({
  181. queryKey: ['updateCheck'],
  182. queryFn: api.checkForUpdates,
  183. staleTime: 5 * 60 * 1000,
  184. });
  185. const { data: updateStatus, refetch: refetchUpdateStatus } = useQuery({
  186. queryKey: ['updateStatus'],
  187. queryFn: api.getUpdateStatus,
  188. refetchInterval: (query) => {
  189. const status = query.state.data as UpdateStatus | undefined;
  190. // Poll while update is in progress
  191. if (status?.status === 'downloading' || status?.status === 'installing') {
  192. return 1000;
  193. }
  194. return false;
  195. },
  196. });
  197. const applyUpdateMutation = useMutation({
  198. mutationFn: api.applyUpdate,
  199. onSuccess: (data) => {
  200. if (data.is_docker) {
  201. showToast(data.message, 'error');
  202. } else {
  203. refetchUpdateStatus();
  204. }
  205. },
  206. });
  207. // Test all notification providers
  208. const [testAllResult, setTestAllResult] = useState<{
  209. tested: number;
  210. success: number;
  211. failed: number;
  212. results: Array<{
  213. provider_id: number;
  214. provider_name: string;
  215. provider_type: string;
  216. success: boolean;
  217. message: string;
  218. }>;
  219. } | null>(null);
  220. const testAllMutation = useMutation({
  221. mutationFn: api.testAllNotificationProviders,
  222. onSuccess: (data) => {
  223. setTestAllResult(data);
  224. queryClient.invalidateQueries({ queryKey: ['notification-providers'] });
  225. if (data.failed === 0) {
  226. showToast(`All ${data.tested} providers tested successfully!`, 'success');
  227. } else {
  228. showToast(`${data.success}/${data.tested} providers succeeded`, data.failed > 0 ? 'error' : 'success');
  229. }
  230. },
  231. onError: (error: Error) => {
  232. showToast(`Failed to test providers: ${error.message}`, 'error');
  233. },
  234. });
  235. // Bulk action for smart plugs
  236. const bulkPlugActionMutation = useMutation({
  237. mutationFn: async (action: 'on' | 'off') => {
  238. if (!smartPlugs) return { success: 0, failed: 0 };
  239. const enabledPlugs = smartPlugs.filter(p => p.enabled);
  240. const results = await Promise.all(
  241. enabledPlugs.map(async (plug) => {
  242. try {
  243. await api.controlSmartPlug(plug.id, action);
  244. return { success: true };
  245. } catch {
  246. return { success: false };
  247. }
  248. })
  249. );
  250. return {
  251. success: results.filter(r => r.success).length,
  252. failed: results.filter(r => !r.success).length,
  253. };
  254. },
  255. onSuccess: (data, action) => {
  256. queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
  257. queryClient.invalidateQueries({ queryKey: ['smart-plugs-energy'] });
  258. if (data.failed === 0) {
  259. showToast(`All ${data.success} plugs turned ${action}`, 'success');
  260. } else {
  261. showToast(`${data.success} plugs turned ${action}, ${data.failed} failed`, 'error');
  262. }
  263. },
  264. onError: (error: Error) => {
  265. showToast(`Failed: ${error.message}`, 'error');
  266. },
  267. });
  268. // Ref for debounce timeout
  269. const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  270. const isInitialLoadRef = useRef(true);
  271. // Sync local state when settings load
  272. useEffect(() => {
  273. if (settings && !localSettings) {
  274. setLocalSettings(settings);
  275. // Mark initial load complete after a short delay
  276. setTimeout(() => {
  277. isInitialLoadRef.current = false;
  278. }, 100);
  279. }
  280. }, [settings, localSettings]);
  281. const updateMutation = useMutation({
  282. mutationFn: api.updateSettings,
  283. onSuccess: (data) => {
  284. queryClient.setQueryData(['settings'], data);
  285. // Invalidate archive stats to reflect energy tracking mode change
  286. queryClient.invalidateQueries({ queryKey: ['archiveStats'] });
  287. showToast('Settings saved', 'success');
  288. },
  289. onError: (error: Error) => {
  290. showToast(`Failed to save: ${error.message}`, 'error');
  291. },
  292. });
  293. // Debounced auto-save when localSettings change
  294. useEffect(() => {
  295. // Skip if initial load or no settings
  296. if (isInitialLoadRef.current || !localSettings || !settings) {
  297. return;
  298. }
  299. // Check if there are actual changes
  300. const hasChanges =
  301. settings.auto_archive !== localSettings.auto_archive ||
  302. settings.save_thumbnails !== localSettings.save_thumbnails ||
  303. settings.capture_finish_photo !== localSettings.capture_finish_photo ||
  304. settings.default_filament_cost !== localSettings.default_filament_cost ||
  305. settings.currency !== localSettings.currency ||
  306. settings.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh ||
  307. settings.energy_tracking_mode !== localSettings.energy_tracking_mode ||
  308. settings.check_updates !== localSettings.check_updates ||
  309. settings.notification_language !== localSettings.notification_language ||
  310. settings.telemetry_enabled !== localSettings.telemetry_enabled ||
  311. settings.ams_humidity_good !== localSettings.ams_humidity_good ||
  312. settings.ams_humidity_fair !== localSettings.ams_humidity_fair ||
  313. settings.ams_temp_good !== localSettings.ams_temp_good ||
  314. settings.ams_temp_fair !== localSettings.ams_temp_fair ||
  315. settings.ams_history_retention_days !== localSettings.ams_history_retention_days ||
  316. settings.date_format !== localSettings.date_format ||
  317. settings.time_format !== localSettings.time_format ||
  318. settings.default_printer_id !== localSettings.default_printer_id ||
  319. settings.ftp_retry_enabled !== localSettings.ftp_retry_enabled ||
  320. settings.ftp_retry_count !== localSettings.ftp_retry_count ||
  321. settings.ftp_retry_delay !== localSettings.ftp_retry_delay ||
  322. settings.ftp_timeout !== localSettings.ftp_timeout;
  323. if (!hasChanges) {
  324. return;
  325. }
  326. // Clear existing timeout
  327. if (saveTimeoutRef.current) {
  328. clearTimeout(saveTimeoutRef.current);
  329. }
  330. // Set new debounced save (500ms delay)
  331. saveTimeoutRef.current = setTimeout(() => {
  332. // Only send the fields we manage on this page (exclude virtual_printer_* which are managed separately)
  333. const settingsToSave: AppSettingsUpdate = {
  334. auto_archive: localSettings.auto_archive,
  335. save_thumbnails: localSettings.save_thumbnails,
  336. capture_finish_photo: localSettings.capture_finish_photo,
  337. default_filament_cost: localSettings.default_filament_cost,
  338. currency: localSettings.currency,
  339. energy_cost_per_kwh: localSettings.energy_cost_per_kwh,
  340. energy_tracking_mode: localSettings.energy_tracking_mode,
  341. check_updates: localSettings.check_updates,
  342. notification_language: localSettings.notification_language,
  343. telemetry_enabled: localSettings.telemetry_enabled,
  344. ams_humidity_good: localSettings.ams_humidity_good,
  345. ams_humidity_fair: localSettings.ams_humidity_fair,
  346. ams_temp_good: localSettings.ams_temp_good,
  347. ams_temp_fair: localSettings.ams_temp_fair,
  348. ams_history_retention_days: localSettings.ams_history_retention_days,
  349. date_format: localSettings.date_format,
  350. time_format: localSettings.time_format,
  351. default_printer_id: localSettings.default_printer_id,
  352. ftp_retry_enabled: localSettings.ftp_retry_enabled,
  353. ftp_retry_count: localSettings.ftp_retry_count,
  354. ftp_retry_delay: localSettings.ftp_retry_delay,
  355. ftp_timeout: localSettings.ftp_timeout,
  356. };
  357. updateMutation.mutate(settingsToSave);
  358. }, 500);
  359. // Cleanup on unmount or when localSettings changes again
  360. return () => {
  361. if (saveTimeoutRef.current) {
  362. clearTimeout(saveTimeoutRef.current);
  363. }
  364. };
  365. }, [localSettings, settings, updateMutation]);
  366. const updateSetting = useCallback(<K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
  367. setLocalSettings(prev => prev ? { ...prev, [key]: value } : null);
  368. }, []);
  369. if (isLoading || !localSettings) {
  370. return (
  371. <div className="p-4 md:p-8 flex justify-center">
  372. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  373. </div>
  374. );
  375. }
  376. return (
  377. <div className="p-4 md:p-8">
  378. <div className="mb-8">
  379. <h1 className="text-2xl font-bold text-white">Settings</h1>
  380. <p className="text-bambu-gray">Configure Bambuddy</p>
  381. </div>
  382. {/* Tab Navigation */}
  383. <div className="flex gap-1 mb-6 border-b border-bambu-dark-tertiary">
  384. <button
  385. onClick={() => setActiveTab('general')}
  386. className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
  387. activeTab === 'general'
  388. ? 'text-bambu-green border-bambu-green'
  389. : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
  390. }`}
  391. >
  392. General
  393. </button>
  394. <button
  395. onClick={() => setActiveTab('plugs')}
  396. className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
  397. activeTab === 'plugs'
  398. ? 'text-bambu-green border-bambu-green'
  399. : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
  400. }`}
  401. >
  402. <Plug className="w-4 h-4" />
  403. Smart Plugs
  404. {smartPlugs && smartPlugs.length > 0 && (
  405. <span className="text-xs bg-bambu-dark-tertiary px-1.5 py-0.5 rounded-full">
  406. {smartPlugs.length}
  407. </span>
  408. )}
  409. </button>
  410. <button
  411. onClick={() => setActiveTab('notifications')}
  412. className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
  413. activeTab === 'notifications'
  414. ? 'text-bambu-green border-bambu-green'
  415. : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
  416. }`}
  417. >
  418. <Bell className="w-4 h-4" />
  419. Notifications
  420. {notificationProviders && notificationProviders.length > 0 && (
  421. <span className="text-xs bg-bambu-dark-tertiary px-1.5 py-0.5 rounded-full">
  422. {notificationProviders.length}
  423. </span>
  424. )}
  425. </button>
  426. <button
  427. onClick={() => setActiveTab('filament')}
  428. className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
  429. activeTab === 'filament'
  430. ? 'text-bambu-green border-bambu-green'
  431. : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
  432. }`}
  433. >
  434. <Cylinder className="w-4 h-4" />
  435. Filament
  436. </button>
  437. <button
  438. onClick={() => setActiveTab('apikeys')}
  439. className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
  440. activeTab === 'apikeys'
  441. ? 'text-bambu-green border-bambu-green'
  442. : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
  443. }`}
  444. >
  445. <Key className="w-4 h-4" />
  446. API Keys
  447. {apiKeys && apiKeys.length > 0 && (
  448. <span className="text-xs bg-bambu-dark-tertiary px-1.5 py-0.5 rounded-full">
  449. {apiKeys.length}
  450. </span>
  451. )}
  452. </button>
  453. <button
  454. onClick={() => setActiveTab('virtual-printer')}
  455. className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
  456. activeTab === 'virtual-printer'
  457. ? 'text-bambu-green border-bambu-green'
  458. : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
  459. }`}
  460. >
  461. <Printer className="w-4 h-4" />
  462. Virtual Printer
  463. <span className={`w-2 h-2 rounded-full ${virtualPrinterRunning ? 'bg-green-400' : 'bg-gray-500'}`} />
  464. </button>
  465. </div>
  466. {/* General Tab */}
  467. {activeTab === 'general' && (
  468. <div className="flex flex-col lg:flex-row gap-6 lg:gap-8">
  469. {/* Left Column - General Settings */}
  470. <div className="space-y-6 flex-1 lg:max-w-xl">
  471. <Card>
  472. <CardHeader>
  473. <h2 className="text-lg font-semibold text-white">{t('settings.general')}</h2>
  474. </CardHeader>
  475. <CardContent className="space-y-4">
  476. <div>
  477. <label className="block text-sm text-bambu-gray mb-1">
  478. <Globe className="w-4 h-4 inline mr-1" />
  479. {t('settings.language')}
  480. </label>
  481. <select
  482. value={i18n.language}
  483. onChange={(e) => i18n.changeLanguage(e.target.value)}
  484. 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"
  485. >
  486. {availableLanguages.map((lang) => (
  487. <option key={lang.code} value={lang.code}>
  488. {lang.nativeName} ({lang.name})
  489. </option>
  490. ))}
  491. </select>
  492. <p className="text-xs text-bambu-gray mt-1">
  493. {t('settings.languageDescription')}
  494. </p>
  495. </div>
  496. <div>
  497. <label className="block text-sm text-bambu-gray mb-1">
  498. {t('settings.defaultView')}
  499. </label>
  500. <select
  501. value={defaultView}
  502. onChange={(e) => handleDefaultViewChange(e.target.value)}
  503. 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"
  504. >
  505. {defaultNavItems.map((item) => (
  506. <option key={item.id} value={item.to}>
  507. {t(item.labelKey)}
  508. </option>
  509. ))}
  510. </select>
  511. <p className="text-xs text-bambu-gray mt-1">
  512. {t('settings.defaultViewDescription')}
  513. </p>
  514. </div>
  515. <div className="grid grid-cols-2 gap-3">
  516. <div>
  517. <label className="block text-sm text-bambu-gray mb-1">
  518. Date Format
  519. </label>
  520. <select
  521. value={localSettings.date_format || 'system'}
  522. onChange={(e) => updateSetting('date_format', e.target.value as 'system' | 'us' | 'eu' | 'iso')}
  523. 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"
  524. >
  525. <option value="system">System Default</option>
  526. <option value="us">US (MM/DD/YYYY)</option>
  527. <option value="eu">EU (DD/MM/YYYY)</option>
  528. <option value="iso">ISO (YYYY-MM-DD)</option>
  529. </select>
  530. </div>
  531. <div>
  532. <label className="block text-sm text-bambu-gray mb-1">
  533. Time Format
  534. </label>
  535. <select
  536. value={localSettings.time_format || 'system'}
  537. onChange={(e) => updateSetting('time_format', e.target.value as 'system' | '12h' | '24h')}
  538. 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"
  539. >
  540. <option value="system">System Default</option>
  541. <option value="12h">12-hour (3:30 PM)</option>
  542. <option value="24h">24-hour (15:30)</option>
  543. </select>
  544. </div>
  545. </div>
  546. <div>
  547. <label className="block text-sm text-bambu-gray mb-1">
  548. Default Printer
  549. </label>
  550. <select
  551. value={localSettings.default_printer_id ?? ''}
  552. onChange={(e) => updateSetting('default_printer_id', e.target.value ? Number(e.target.value) : null)}
  553. 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"
  554. >
  555. <option value="">No default (ask each time)</option>
  556. {printers?.map((printer) => (
  557. <option key={printer.id} value={printer.id}>
  558. {printer.name}
  559. </option>
  560. ))}
  561. </select>
  562. <p className="text-xs text-bambu-gray mt-1">
  563. Pre-select this printer for uploads, reprints, and other operations.
  564. </p>
  565. </div>
  566. <div className="flex items-center justify-between">
  567. <div>
  568. <p className="text-white">Sidebar order</p>
  569. <p className="text-sm text-bambu-gray">
  570. Drag items in the sidebar to reorder. Reset to default order here.
  571. </p>
  572. </div>
  573. <Button
  574. variant="secondary"
  575. size="sm"
  576. onClick={handleResetSidebarOrder}
  577. >
  578. <RotateCcw className="w-4 h-4" />
  579. Reset
  580. </Button>
  581. </div>
  582. </CardContent>
  583. </Card>
  584. <Card>
  585. <CardHeader>
  586. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  587. <Palette className="w-5 h-5" />
  588. Appearance
  589. </h2>
  590. </CardHeader>
  591. <CardContent className="space-y-6">
  592. {/* Dark Mode Settings */}
  593. <div className={`space-y-3 p-4 rounded-lg border ${mode === 'dark' ? 'border-bambu-green bg-bambu-green/5' : 'border-bambu-dark-tertiary'}`}>
  594. <h3 className="text-sm font-medium text-white flex items-center gap-2">
  595. Dark Mode
  596. {mode === 'dark' && <span className="text-xs text-bambu-green">(active)</span>}
  597. </h3>
  598. <div className="grid grid-cols-3 gap-3">
  599. <div>
  600. <label className="block text-xs text-bambu-gray mb-1">Background</label>
  601. <select
  602. value={darkBackground}
  603. onChange={(e) => { setDarkBackground(e.target.value as DarkBackground); showToast('Settings saved', 'success'); }}
  604. className="w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  605. >
  606. <option value="neutral">Neutral</option>
  607. <option value="warm">Warm</option>
  608. <option value="cool">Cool</option>
  609. <option value="oled">OLED Black</option>
  610. <option value="slate">Slate Blue</option>
  611. <option value="forest">Forest Green</option>
  612. </select>
  613. </div>
  614. <div>
  615. <label className="block text-xs text-bambu-gray mb-1">Accent</label>
  616. <select
  617. value={darkAccent}
  618. onChange={(e) => { setDarkAccent(e.target.value as ThemeAccent); showToast('Settings saved', 'success'); }}
  619. className="w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  620. >
  621. <option value="green">Green</option>
  622. <option value="teal">Teal</option>
  623. <option value="blue">Blue</option>
  624. <option value="orange">Orange</option>
  625. <option value="purple">Purple</option>
  626. <option value="red">Red</option>
  627. </select>
  628. </div>
  629. <div>
  630. <label className="block text-xs text-bambu-gray mb-1">Style</label>
  631. <select
  632. value={darkStyle}
  633. onChange={(e) => { setDarkStyle(e.target.value as ThemeStyle); showToast('Settings saved', 'success'); }}
  634. className="w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  635. >
  636. <option value="classic">Classic</option>
  637. <option value="glow">Glow</option>
  638. <option value="vibrant">Vibrant</option>
  639. </select>
  640. </div>
  641. </div>
  642. </div>
  643. {/* Light Mode Settings */}
  644. <div className={`space-y-3 p-4 rounded-lg border ${mode === 'light' ? 'border-bambu-green bg-bambu-green/5' : 'border-bambu-dark-tertiary'}`}>
  645. <h3 className="text-sm font-medium text-white flex items-center gap-2">
  646. Light Mode
  647. {mode === 'light' && <span className="text-xs text-bambu-green">(active)</span>}
  648. </h3>
  649. <div className="grid grid-cols-3 gap-3">
  650. <div>
  651. <label className="block text-xs text-bambu-gray mb-1">Background</label>
  652. <select
  653. value={lightBackground}
  654. onChange={(e) => { setLightBackground(e.target.value as LightBackground); showToast('Settings saved', 'success'); }}
  655. className="w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  656. >
  657. <option value="neutral">Neutral</option>
  658. <option value="warm">Warm</option>
  659. <option value="cool">Cool</option>
  660. </select>
  661. </div>
  662. <div>
  663. <label className="block text-xs text-bambu-gray mb-1">Accent</label>
  664. <select
  665. value={lightAccent}
  666. onChange={(e) => { setLightAccent(e.target.value as ThemeAccent); showToast('Settings saved', 'success'); }}
  667. className="w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  668. >
  669. <option value="green">Green</option>
  670. <option value="teal">Teal</option>
  671. <option value="blue">Blue</option>
  672. <option value="orange">Orange</option>
  673. <option value="purple">Purple</option>
  674. <option value="red">Red</option>
  675. </select>
  676. </div>
  677. <div>
  678. <label className="block text-xs text-bambu-gray mb-1">Style</label>
  679. <select
  680. value={lightStyle}
  681. onChange={(e) => { setLightStyle(e.target.value as ThemeStyle); showToast('Settings saved', 'success'); }}
  682. className="w-full px-2 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  683. >
  684. <option value="classic">Classic</option>
  685. <option value="glow">Glow</option>
  686. <option value="vibrant">Vibrant</option>
  687. </select>
  688. </div>
  689. </div>
  690. </div>
  691. <p className="text-xs text-bambu-gray">
  692. Toggle between dark and light mode using the sun/moon icon in the sidebar.
  693. </p>
  694. </CardContent>
  695. </Card>
  696. <Card>
  697. <CardHeader>
  698. <h2 className="text-lg font-semibold text-white">Archive Settings</h2>
  699. </CardHeader>
  700. <CardContent className="space-y-4">
  701. <div className="flex items-center justify-between">
  702. <div>
  703. <p className="text-white">Auto-archive prints</p>
  704. <p className="text-sm text-bambu-gray">
  705. Automatically save 3MF files when prints complete
  706. </p>
  707. </div>
  708. <label className="relative inline-flex items-center cursor-pointer">
  709. <input
  710. type="checkbox"
  711. checked={localSettings.auto_archive}
  712. onChange={(e) => updateSetting('auto_archive', e.target.checked)}
  713. className="sr-only peer"
  714. />
  715. <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>
  716. </label>
  717. </div>
  718. <div className="flex items-center justify-between">
  719. <div>
  720. <p className="text-white">Save thumbnails</p>
  721. <p className="text-sm text-bambu-gray">
  722. Extract and save preview images from 3MF files
  723. </p>
  724. </div>
  725. <label className="relative inline-flex items-center cursor-pointer">
  726. <input
  727. type="checkbox"
  728. checked={localSettings.save_thumbnails}
  729. onChange={(e) => updateSetting('save_thumbnails', e.target.checked)}
  730. className="sr-only peer"
  731. />
  732. <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>
  733. </label>
  734. </div>
  735. <div className="flex items-center justify-between">
  736. <div>
  737. <p className="text-white">Capture finish photo</p>
  738. <p className="text-sm text-bambu-gray">
  739. Take a photo from printer camera when print completes
  740. </p>
  741. </div>
  742. <label className="relative inline-flex items-center cursor-pointer">
  743. <input
  744. type="checkbox"
  745. checked={localSettings.capture_finish_photo}
  746. onChange={(e) => updateSetting('capture_finish_photo', e.target.checked)}
  747. className="sr-only peer"
  748. />
  749. <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>
  750. </label>
  751. </div>
  752. {localSettings.capture_finish_photo && ffmpegStatus && !ffmpegStatus.installed && (
  753. <div className="flex items-start gap-2 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
  754. <AlertTriangle className="w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5" />
  755. <div className="text-sm">
  756. <p className="text-yellow-500 font-medium">ffmpeg not installed</p>
  757. <p className="text-bambu-gray mt-1">
  758. Camera capture requires ffmpeg. Install it via{' '}
  759. <code className="bg-bambu-dark-tertiary px-1 rounded">brew install ffmpeg</code> (macOS) or{' '}
  760. <code className="bg-bambu-dark-tertiary px-1 rounded">apt install ffmpeg</code> (Linux).
  761. </p>
  762. </div>
  763. </div>
  764. )}
  765. </CardContent>
  766. </Card>
  767. </div>
  768. {/* Second Column - Cost, AMS & Spoolman */}
  769. <div className="space-y-6 flex-1 lg:max-w-md">
  770. <Card>
  771. <CardHeader>
  772. <h2 className="text-lg font-semibold text-white">Cost Tracking</h2>
  773. </CardHeader>
  774. <CardContent className="space-y-4">
  775. <div>
  776. <label className="block text-sm text-bambu-gray mb-1">
  777. Default filament cost (per kg)
  778. </label>
  779. <input
  780. type="number"
  781. step="0.01"
  782. min="0"
  783. value={localSettings.default_filament_cost}
  784. onChange={(e) =>
  785. updateSetting('default_filament_cost', parseFloat(e.target.value) || 0)
  786. }
  787. 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"
  788. />
  789. </div>
  790. <div>
  791. <label className="block text-sm text-bambu-gray mb-1">Currency</label>
  792. <select
  793. value={localSettings.currency}
  794. onChange={(e) => updateSetting('currency', e.target.value)}
  795. 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"
  796. >
  797. <option value="USD">USD ($)</option>
  798. <option value="EUR">EUR (€)</option>
  799. <option value="GBP">GBP (£)</option>
  800. <option value="CHF">CHF (Fr.)</option>
  801. <option value="JPY">JPY (¥)</option>
  802. <option value="CNY">CNY (¥)</option>
  803. <option value="CAD">CAD ($)</option>
  804. <option value="AUD">AUD ($)</option>
  805. </select>
  806. </div>
  807. <div>
  808. <label className="block text-sm text-bambu-gray mb-1">
  809. Electricity cost per kWh
  810. </label>
  811. <input
  812. type="number"
  813. step="0.01"
  814. min="0"
  815. value={localSettings.energy_cost_per_kwh}
  816. onChange={(e) =>
  817. updateSetting('energy_cost_per_kwh', parseFloat(e.target.value) || 0)
  818. }
  819. 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"
  820. />
  821. </div>
  822. <div>
  823. <label className="block text-sm text-bambu-gray mb-1">
  824. Energy display mode
  825. </label>
  826. <select
  827. value={localSettings.energy_tracking_mode || 'total'}
  828. onChange={(e) => updateSetting('energy_tracking_mode', e.target.value as 'print' | 'total')}
  829. 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"
  830. >
  831. <option value="print">Prints Only</option>
  832. <option value="total">Total Consumption</option>
  833. </select>
  834. <p className="text-xs text-bambu-gray mt-1">
  835. {localSettings.energy_tracking_mode === 'print'
  836. ? 'Dashboard shows sum of energy used during prints'
  837. : 'Dashboard shows lifetime energy from smart plugs'}
  838. </p>
  839. </div>
  840. </CardContent>
  841. </Card>
  842. <Card>
  843. <CardHeader>
  844. <h2 className="text-lg font-semibold text-white">AMS Display Thresholds</h2>
  845. </CardHeader>
  846. <CardContent className="space-y-4">
  847. <p className="text-sm text-bambu-gray">
  848. Configure color thresholds for AMS humidity and temperature indicators.
  849. </p>
  850. {/* Humidity Thresholds */}
  851. <div className="space-y-3">
  852. <div className="flex items-center gap-2 text-white">
  853. <Droplets className="w-4 h-4 text-blue-400" />
  854. <span className="font-medium">Humidity</span>
  855. </div>
  856. <div className="grid grid-cols-2 gap-3">
  857. <div>
  858. <label className="block text-sm text-bambu-gray mb-1">
  859. Good (green) ≤
  860. </label>
  861. <div className="flex items-center gap-2">
  862. <input
  863. type="number"
  864. min="0"
  865. max="100"
  866. value={localSettings.ams_humidity_good ?? 40}
  867. onChange={(e) => updateSetting('ams_humidity_good', parseInt(e.target.value) || 40)}
  868. 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"
  869. />
  870. <span className="text-bambu-gray">%</span>
  871. </div>
  872. </div>
  873. <div>
  874. <label className="block text-sm text-bambu-gray mb-1">
  875. Fair (orange) ≤
  876. </label>
  877. <div className="flex items-center gap-2">
  878. <input
  879. type="number"
  880. min="0"
  881. max="100"
  882. value={localSettings.ams_humidity_fair ?? 60}
  883. onChange={(e) => updateSetting('ams_humidity_fair', parseInt(e.target.value) || 60)}
  884. 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"
  885. />
  886. <span className="text-bambu-gray">%</span>
  887. </div>
  888. </div>
  889. </div>
  890. <p className="text-xs text-bambu-gray">
  891. Above fair threshold shows as red (bad)
  892. </p>
  893. </div>
  894. {/* Temperature Thresholds */}
  895. <div className="space-y-3 pt-2 border-t border-bambu-dark-tertiary">
  896. <div className="flex items-center gap-2 text-white">
  897. <Thermometer className="w-4 h-4 text-orange-400" />
  898. <span className="font-medium">Temperature</span>
  899. </div>
  900. <div className="grid grid-cols-2 gap-3">
  901. <div>
  902. <label className="block text-sm text-bambu-gray mb-1">
  903. Good (blue) ≤
  904. </label>
  905. <div className="flex items-center gap-2">
  906. <input
  907. type="number"
  908. step="0.5"
  909. min="0"
  910. max="60"
  911. value={localSettings.ams_temp_good ?? 28}
  912. onChange={(e) => updateSetting('ams_temp_good', parseFloat(e.target.value) || 28)}
  913. 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"
  914. />
  915. <span className="text-bambu-gray">°C</span>
  916. </div>
  917. </div>
  918. <div>
  919. <label className="block text-sm text-bambu-gray mb-1">
  920. Fair (orange) ≤
  921. </label>
  922. <div className="flex items-center gap-2">
  923. <input
  924. type="number"
  925. step="0.5"
  926. min="0"
  927. max="60"
  928. value={localSettings.ams_temp_fair ?? 35}
  929. onChange={(e) => updateSetting('ams_temp_fair', parseFloat(e.target.value) || 35)}
  930. 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"
  931. />
  932. <span className="text-bambu-gray">°C</span>
  933. </div>
  934. </div>
  935. </div>
  936. <p className="text-xs text-bambu-gray">
  937. Above fair threshold shows as red (hot)
  938. </p>
  939. </div>
  940. {/* History Retention */}
  941. <div className="space-y-3 pt-4 border-t border-bambu-dark-tertiary">
  942. <div className="flex items-center gap-2 text-white">
  943. <Database className="w-4 h-4 text-purple-400" />
  944. <span className="font-medium">History Retention</span>
  945. </div>
  946. <div>
  947. <label className="block text-sm text-bambu-gray mb-1">
  948. Keep sensor history for
  949. </label>
  950. <div className="flex items-center gap-2">
  951. <input
  952. type="number"
  953. min="1"
  954. max="365"
  955. value={localSettings.ams_history_retention_days ?? 30}
  956. onChange={(e) => updateSetting('ams_history_retention_days', parseInt(e.target.value) || 30)}
  957. 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"
  958. />
  959. <span className="text-bambu-gray">days</span>
  960. </div>
  961. </div>
  962. <p className="text-xs text-bambu-gray">
  963. Older humidity and temperature data will be automatically deleted
  964. </p>
  965. </div>
  966. </CardContent>
  967. </Card>
  968. {/* FTP Retry Settings */}
  969. <Card>
  970. <CardHeader>
  971. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  972. <RefreshCw className="w-5 h-5 text-blue-400" />
  973. FTP Retry
  974. </h2>
  975. </CardHeader>
  976. <CardContent className="space-y-4">
  977. <p className="text-sm text-bambu-gray">
  978. Retry FTP operations when printer WiFi is unreliable. Applies to 3MF downloads, print uploads, timelapse downloads, and firmware updates.
  979. </p>
  980. <div className="flex items-center justify-between">
  981. <div>
  982. <p className="text-white">Enable retry</p>
  983. <p className="text-sm text-bambu-gray">
  984. Automatically retry failed FTP operations
  985. </p>
  986. </div>
  987. <label className="relative inline-flex items-center cursor-pointer">
  988. <input
  989. type="checkbox"
  990. checked={localSettings.ftp_retry_enabled ?? true}
  991. onChange={(e) => updateSetting('ftp_retry_enabled', e.target.checked)}
  992. className="sr-only peer"
  993. />
  994. <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>
  995. </label>
  996. </div>
  997. {localSettings.ftp_retry_enabled && (
  998. <div className="space-y-4 pt-2 border-t border-bambu-dark-tertiary">
  999. <div>
  1000. <label className="block text-sm text-bambu-gray mb-1">
  1001. Retry attempts
  1002. </label>
  1003. <div className="flex items-center gap-2">
  1004. <input
  1005. type="number"
  1006. min="1"
  1007. max="10"
  1008. value={localSettings.ftp_retry_count ?? 3}
  1009. onChange={(e) => updateSetting('ftp_retry_count', Math.min(10, Math.max(1, parseInt(e.target.value) || 3)))}
  1010. 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"
  1011. />
  1012. <span className="text-bambu-gray">times</span>
  1013. </div>
  1014. <p className="text-xs text-bambu-gray mt-1">
  1015. Number of retry attempts before giving up (1-10)
  1016. </p>
  1017. </div>
  1018. <div>
  1019. <label className="block text-sm text-bambu-gray mb-1">
  1020. Retry delay
  1021. </label>
  1022. <div className="flex items-center gap-2">
  1023. <input
  1024. type="number"
  1025. min="1"
  1026. max="30"
  1027. value={localSettings.ftp_retry_delay ?? 2}
  1028. onChange={(e) => updateSetting('ftp_retry_delay', Math.min(30, Math.max(1, parseInt(e.target.value) || 2)))}
  1029. 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"
  1030. />
  1031. <span className="text-bambu-gray">seconds</span>
  1032. </div>
  1033. <p className="text-xs text-bambu-gray mt-1">
  1034. Wait time between retries (1-30)
  1035. </p>
  1036. </div>
  1037. </div>
  1038. )}
  1039. <div className="pt-2 border-t border-bambu-dark-tertiary">
  1040. <label className="block text-sm text-bambu-gray mb-1">
  1041. Connection timeout
  1042. </label>
  1043. <div className="flex items-center gap-2">
  1044. <input
  1045. type="number"
  1046. min="10"
  1047. max="120"
  1048. value={localSettings.ftp_timeout ?? 30}
  1049. onChange={(e) => updateSetting('ftp_timeout', Math.min(120, Math.max(10, parseInt(e.target.value) || 30)))}
  1050. 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"
  1051. />
  1052. <span className="text-bambu-gray">seconds</span>
  1053. </div>
  1054. <p className="text-xs text-bambu-gray mt-1">
  1055. Socket timeout for slow connections. Increase for A1/A1 Mini printers with weak WiFi (10-120)
  1056. </p>
  1057. </div>
  1058. </CardContent>
  1059. </Card>
  1060. </div>
  1061. {/* Third Column - Updates */}
  1062. <div className="space-y-6 flex-1 lg:max-w-sm">
  1063. <ExternalLinksSettings />
  1064. <Card>
  1065. <CardHeader>
  1066. <h2 className="text-lg font-semibold text-white">Updates</h2>
  1067. </CardHeader>
  1068. <CardContent className="space-y-4">
  1069. <div className="flex items-center justify-between">
  1070. <div>
  1071. <p className="text-white">Check for updates</p>
  1072. <p className="text-sm text-bambu-gray">
  1073. Automatically check for new versions on startup
  1074. </p>
  1075. </div>
  1076. <label className="relative inline-flex items-center cursor-pointer">
  1077. <input
  1078. type="checkbox"
  1079. checked={localSettings.check_updates}
  1080. onChange={(e) => updateSetting('check_updates', e.target.checked)}
  1081. className="sr-only peer"
  1082. />
  1083. <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>
  1084. </label>
  1085. </div>
  1086. <div className="flex items-center justify-between">
  1087. <div>
  1088. <div className="flex items-center gap-2">
  1089. <p className="text-white">{t('settings.telemetry')}</p>
  1090. <button
  1091. onClick={() => setShowTelemetryInfo(true)}
  1092. className="inline-flex items-center gap-1 px-2 py-0.5 text-xs bg-bambu-dark rounded-full text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary transition-colors"
  1093. >
  1094. <Info className="w-3 h-3" />
  1095. {t('settings.telemetryLearnMore')}
  1096. </button>
  1097. </div>
  1098. <p className="text-sm text-bambu-gray">
  1099. {t('settings.telemetryDescription')}
  1100. </p>
  1101. </div>
  1102. <label className="relative inline-flex items-center cursor-pointer">
  1103. <input
  1104. type="checkbox"
  1105. checked={localSettings.telemetry_enabled}
  1106. onChange={(e) => updateSetting('telemetry_enabled', e.target.checked)}
  1107. className="sr-only peer"
  1108. />
  1109. <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>
  1110. </label>
  1111. </div>
  1112. <div className="border-t border-bambu-dark-tertiary pt-4">
  1113. <div className="flex items-center justify-between mb-2">
  1114. <div>
  1115. <p className="text-white">Current version</p>
  1116. <p className="text-sm text-bambu-gray">v{versionInfo?.version || '...'}</p>
  1117. </div>
  1118. <Button
  1119. variant="secondary"
  1120. size="sm"
  1121. onClick={() => refetchUpdateCheck()}
  1122. disabled={isCheckingUpdate}
  1123. >
  1124. {isCheckingUpdate ? (
  1125. <Loader2 className="w-4 h-4 animate-spin" />
  1126. ) : (
  1127. <RefreshCw className="w-4 h-4" />
  1128. )}
  1129. Check now
  1130. </Button>
  1131. </div>
  1132. {updateCheck?.update_available ? (
  1133. <div className="mt-4 p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg">
  1134. <div className="flex items-start justify-between">
  1135. <div>
  1136. <p className="text-bambu-green font-medium">
  1137. Update available: v{updateCheck.latest_version}
  1138. </p>
  1139. {updateCheck.release_name && updateCheck.release_name !== updateCheck.latest_version && (
  1140. <p className="text-sm text-bambu-gray mt-1">{updateCheck.release_name}</p>
  1141. )}
  1142. </div>
  1143. <div className="flex items-center gap-2">
  1144. {updateCheck.release_notes && (
  1145. <button
  1146. onClick={() => setShowReleaseNotes(true)}
  1147. className="text-bambu-gray hover:text-white transition-colors text-sm underline"
  1148. >
  1149. Release Notes
  1150. </button>
  1151. )}
  1152. {updateCheck.release_url && (
  1153. <a
  1154. href={updateCheck.release_url}
  1155. target="_blank"
  1156. rel="noopener noreferrer"
  1157. className="text-bambu-gray hover:text-white transition-colors"
  1158. title="View release on GitHub"
  1159. >
  1160. <ExternalLink className="w-4 h-4" />
  1161. </a>
  1162. )}
  1163. </div>
  1164. </div>
  1165. {updateStatus?.status === 'downloading' || updateStatus?.status === 'installing' ? (
  1166. <div className="mt-3">
  1167. <div className="flex items-center gap-2 text-sm text-bambu-gray">
  1168. <Loader2 className="w-4 h-4 animate-spin" />
  1169. <span>{updateStatus.message}</span>
  1170. </div>
  1171. <div className="mt-2 w-full bg-bambu-dark-tertiary rounded-full h-2">
  1172. <div
  1173. className="bg-bambu-green h-2 rounded-full transition-all duration-300"
  1174. style={{ width: `${updateStatus.progress}%` }}
  1175. />
  1176. </div>
  1177. </div>
  1178. ) : updateStatus?.status === 'complete' ? (
  1179. <div className="mt-3 p-2 bg-bambu-green/20 rounded text-sm text-bambu-green">
  1180. {updateStatus.message}
  1181. </div>
  1182. ) : updateStatus?.status === 'error' ? (
  1183. <div className="mt-3 p-2 bg-red-500/20 rounded text-sm text-red-400">
  1184. {updateStatus.error || updateStatus.message}
  1185. </div>
  1186. ) : updateCheck?.is_docker ? (
  1187. <div className="mt-3 p-3 bg-bambu-dark-tertiary rounded-lg">
  1188. <p className="text-sm text-bambu-gray mb-2">
  1189. Update via Docker Compose:
  1190. </p>
  1191. <code className="block text-xs bg-bambu-dark p-2 rounded text-bambu-green font-mono">
  1192. docker compose pull && docker compose up -d
  1193. </code>
  1194. </div>
  1195. ) : (
  1196. <Button
  1197. className="mt-3"
  1198. onClick={() => applyUpdateMutation.mutate()}
  1199. disabled={applyUpdateMutation.isPending}
  1200. >
  1201. {applyUpdateMutation.isPending ? (
  1202. <Loader2 className="w-4 h-4 animate-spin" />
  1203. ) : (
  1204. <Download className="w-4 h-4" />
  1205. )}
  1206. Install Update
  1207. </Button>
  1208. )}
  1209. </div>
  1210. ) : updateCheck?.error ? (
  1211. <div className="mt-2 p-2 bg-red-500/10 border border-red-500/30 rounded text-sm text-red-400">
  1212. Failed to check for updates: {updateCheck.error}
  1213. </div>
  1214. ) : updateCheck && !updateCheck.update_available ? (
  1215. <p className="mt-2 text-sm text-bambu-gray">
  1216. You're running the latest version
  1217. </p>
  1218. ) : null}
  1219. </div>
  1220. </CardContent>
  1221. </Card>
  1222. {/* Data Management */}
  1223. <Card>
  1224. <CardHeader>
  1225. <h2 className="text-lg font-semibold text-white">Data Management</h2>
  1226. </CardHeader>
  1227. <CardContent className="space-y-4">
  1228. {/* Backup/Restore */}
  1229. <div className="flex items-center justify-between">
  1230. <div>
  1231. <p className="text-white">Backup Data</p>
  1232. <p className="text-sm text-bambu-gray">
  1233. Export settings, providers, printers, and more
  1234. </p>
  1235. </div>
  1236. <Button
  1237. variant="secondary"
  1238. size="sm"
  1239. onClick={() => setShowBackupModal(true)}
  1240. >
  1241. <Download className="w-4 h-4" />
  1242. Export
  1243. </Button>
  1244. </div>
  1245. <div className="flex items-center justify-between">
  1246. <div>
  1247. <p className="text-white">Restore Backup</p>
  1248. <p className="text-sm text-bambu-gray">
  1249. Import settings from a backup file with duplicate handling options
  1250. </p>
  1251. </div>
  1252. <Button
  1253. variant="secondary"
  1254. size="sm"
  1255. onClick={() => setShowRestoreModal(true)}
  1256. >
  1257. <Upload className="w-4 h-4" />
  1258. Restore
  1259. </Button>
  1260. </div>
  1261. <div className="border-t border-bambu-dark-tertiary pt-4">
  1262. <div className="flex items-center justify-between">
  1263. <div>
  1264. <p className="text-white">Clear Notification Logs</p>
  1265. <p className="text-sm text-bambu-gray">
  1266. Delete notification logs older than 30 days
  1267. </p>
  1268. </div>
  1269. <Button
  1270. variant="secondary"
  1271. size="sm"
  1272. onClick={() => setShowClearLogsConfirm(true)}
  1273. >
  1274. <Trash2 className="w-4 h-4" />
  1275. Clear
  1276. </Button>
  1277. </div>
  1278. </div>
  1279. <div className="flex items-center justify-between">
  1280. <div>
  1281. <p className="text-white">Reset UI Preferences</p>
  1282. <p className="text-sm text-bambu-gray">
  1283. Reset sidebar order, theme, view modes, and layout preferences. Printers, archives, and settings are not affected.
  1284. </p>
  1285. </div>
  1286. <Button
  1287. variant="secondary"
  1288. size="sm"
  1289. onClick={() => setShowClearStorageConfirm(true)}
  1290. >
  1291. <Trash2 className="w-4 h-4" />
  1292. Reset
  1293. </Button>
  1294. </div>
  1295. </CardContent>
  1296. </Card>
  1297. </div>
  1298. </div>
  1299. )}
  1300. {/* Smart Plugs Tab */}
  1301. {activeTab === 'plugs' && (
  1302. <div className="max-w-4xl">
  1303. <div className="flex items-start justify-between mb-6">
  1304. <div>
  1305. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  1306. <Plug className="w-5 h-5 text-bambu-green" />
  1307. Smart Plugs
  1308. </h2>
  1309. <p className="text-sm text-bambu-gray mt-1">
  1310. Connect Tasmota-based smart plugs to automate power control and track energy usage for your printers.
  1311. </p>
  1312. </div>
  1313. <div className="flex items-center gap-2 pt-1 shrink-0">
  1314. {smartPlugs && smartPlugs.filter(p => p.enabled).length > 1 && (
  1315. <>
  1316. <Button
  1317. variant="secondary"
  1318. size="sm"
  1319. className="whitespace-nowrap"
  1320. onClick={() => setShowBulkPlugConfirm('on')}
  1321. disabled={bulkPlugActionMutation.isPending}
  1322. title="Turn all plugs on"
  1323. >
  1324. {bulkPlugActionMutation.isPending ? (
  1325. <Loader2 className="w-4 h-4 animate-spin" />
  1326. ) : (
  1327. <Power className="w-4 h-4 text-bambu-green" />
  1328. )}
  1329. All On
  1330. </Button>
  1331. <Button
  1332. variant="secondary"
  1333. size="sm"
  1334. className="whitespace-nowrap"
  1335. onClick={() => setShowBulkPlugConfirm('off')}
  1336. disabled={bulkPlugActionMutation.isPending}
  1337. title="Turn all plugs off"
  1338. >
  1339. {bulkPlugActionMutation.isPending ? (
  1340. <Loader2 className="w-4 h-4 animate-spin" />
  1341. ) : (
  1342. <PowerOff className="w-4 h-4 text-red-400" />
  1343. )}
  1344. All Off
  1345. </Button>
  1346. </>
  1347. )}
  1348. <Button
  1349. className="whitespace-nowrap"
  1350. onClick={() => {
  1351. setEditingPlug(null);
  1352. setShowPlugModal(true);
  1353. }}
  1354. >
  1355. <Plus className="w-4 h-4" />
  1356. Add Smart Plug
  1357. </Button>
  1358. </div>
  1359. </div>
  1360. {/* Energy Summary Card */}
  1361. {smartPlugs && smartPlugs.length > 0 && (
  1362. <Card className="mb-6">
  1363. <CardHeader>
  1364. <h3 className="text-base font-semibold text-white flex items-center gap-2">
  1365. <Zap className="w-4 h-4 text-yellow-400" />
  1366. Energy Summary
  1367. {energyLoading && (
  1368. <Loader2 className="w-4 h-4 animate-spin text-bambu-gray ml-2" />
  1369. )}
  1370. </h3>
  1371. </CardHeader>
  1372. <CardContent>
  1373. {plugEnergySummary ? (
  1374. <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
  1375. {/* Current Power */}
  1376. <div className="bg-bambu-dark rounded-lg p-3">
  1377. <div className="flex items-center gap-2 text-bambu-gray text-xs mb-1">
  1378. <Zap className="w-3 h-3" />
  1379. Current Power
  1380. </div>
  1381. <div className="text-xl font-bold text-white">
  1382. {plugEnergySummary.totalPower.toFixed(1)}
  1383. <span className="text-sm font-normal text-bambu-gray ml-1">W</span>
  1384. </div>
  1385. <div className="text-xs text-bambu-gray mt-1">
  1386. {plugEnergySummary.reachableCount}/{plugEnergySummary.totalPlugs} plugs online
  1387. </div>
  1388. </div>
  1389. {/* Today */}
  1390. <div className="bg-bambu-dark rounded-lg p-3">
  1391. <div className="flex items-center gap-2 text-bambu-gray text-xs mb-1">
  1392. <Calendar className="w-3 h-3" />
  1393. Today
  1394. </div>
  1395. <div className="text-xl font-bold text-white">
  1396. {plugEnergySummary.totalToday.toFixed(2)}
  1397. <span className="text-sm font-normal text-bambu-gray ml-1">kWh</span>
  1398. </div>
  1399. {localSettings && localSettings.energy_cost_per_kwh > 0 && (
  1400. <div className="text-xs text-bambu-gray mt-1">
  1401. ~{(plugEnergySummary.totalToday * localSettings.energy_cost_per_kwh).toFixed(2)} {localSettings.currency}
  1402. </div>
  1403. )}
  1404. </div>
  1405. {/* Yesterday */}
  1406. <div className="bg-bambu-dark rounded-lg p-3">
  1407. <div className="flex items-center gap-2 text-bambu-gray text-xs mb-1">
  1408. <TrendingUp className="w-3 h-3" />
  1409. Yesterday
  1410. </div>
  1411. <div className="text-xl font-bold text-white">
  1412. {plugEnergySummary.totalYesterday.toFixed(2)}
  1413. <span className="text-sm font-normal text-bambu-gray ml-1">kWh</span>
  1414. </div>
  1415. {localSettings && localSettings.energy_cost_per_kwh > 0 && (
  1416. <div className="text-xs text-bambu-gray mt-1">
  1417. ~{(plugEnergySummary.totalYesterday * localSettings.energy_cost_per_kwh).toFixed(2)} {localSettings.currency}
  1418. </div>
  1419. )}
  1420. </div>
  1421. {/* Total Lifetime */}
  1422. <div className="bg-bambu-dark rounded-lg p-3">
  1423. <div className="flex items-center gap-2 text-bambu-gray text-xs mb-1">
  1424. <DollarSign className="w-3 h-3" />
  1425. Total
  1426. </div>
  1427. <div className="text-xl font-bold text-white">
  1428. {plugEnergySummary.totalLifetime.toFixed(1)}
  1429. <span className="text-sm font-normal text-bambu-gray ml-1">kWh</span>
  1430. </div>
  1431. {localSettings && localSettings.energy_cost_per_kwh > 0 && (
  1432. <div className="text-xs text-bambu-gray mt-1">
  1433. ~{(plugEnergySummary.totalLifetime * localSettings.energy_cost_per_kwh).toFixed(2)} {localSettings.currency}
  1434. </div>
  1435. )}
  1436. </div>
  1437. </div>
  1438. ) : !energyLoading ? (
  1439. <p className="text-sm text-bambu-gray">
  1440. Enable plugs to see energy summary
  1441. </p>
  1442. ) : null}
  1443. </CardContent>
  1444. </Card>
  1445. )}
  1446. {plugsLoading ? (
  1447. <div className="flex justify-center py-12">
  1448. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  1449. </div>
  1450. ) : smartPlugs && smartPlugs.length > 0 ? (
  1451. <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
  1452. {smartPlugs.map((plug) => (
  1453. <SmartPlugCard
  1454. key={plug.id}
  1455. plug={plug}
  1456. onEdit={(p) => {
  1457. setEditingPlug(p);
  1458. setShowPlugModal(true);
  1459. }}
  1460. />
  1461. ))}
  1462. </div>
  1463. ) : (
  1464. <Card>
  1465. <CardContent className="py-12">
  1466. <div className="text-center text-bambu-gray">
  1467. <Plug className="w-16 h-16 mx-auto mb-4 opacity-30" />
  1468. <p className="text-lg font-medium text-white mb-2">No smart plugs configured</p>
  1469. <p className="text-sm mb-4">Add a Tasmota-based smart plug to track energy usage and automate power control.</p>
  1470. <Button
  1471. onClick={() => {
  1472. setEditingPlug(null);
  1473. setShowPlugModal(true);
  1474. }}
  1475. >
  1476. <Plus className="w-4 h-4" />
  1477. Add Your First Smart Plug
  1478. </Button>
  1479. </div>
  1480. </CardContent>
  1481. </Card>
  1482. )}
  1483. </div>
  1484. )}
  1485. {/* Notifications Tab */}
  1486. {activeTab === 'notifications' && (
  1487. <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
  1488. {/* Left Column: Providers */}
  1489. <div>
  1490. <div className="flex items-center justify-between mb-4">
  1491. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  1492. <Bell className="w-5 h-5 text-bambu-green" />
  1493. Providers
  1494. </h2>
  1495. <div className="flex items-center gap-2">
  1496. <Button
  1497. size="sm"
  1498. variant="secondary"
  1499. onClick={() => setShowLogViewer(true)}
  1500. >
  1501. <History className="w-4 h-4" />
  1502. Log
  1503. </Button>
  1504. {notificationProviders && notificationProviders.length > 0 && (
  1505. <Button
  1506. size="sm"
  1507. variant="secondary"
  1508. onClick={() => {
  1509. setTestAllResult(null);
  1510. testAllMutation.mutate();
  1511. }}
  1512. disabled={testAllMutation.isPending}
  1513. >
  1514. {testAllMutation.isPending ? (
  1515. <Loader2 className="w-4 h-4 animate-spin" />
  1516. ) : (
  1517. <Send className="w-4 h-4" />
  1518. )}
  1519. Test All
  1520. </Button>
  1521. )}
  1522. <Button
  1523. size="sm"
  1524. onClick={() => {
  1525. setEditingProvider(null);
  1526. setShowNotificationModal(true);
  1527. }}
  1528. >
  1529. <Plus className="w-4 h-4" />
  1530. Add
  1531. </Button>
  1532. </div>
  1533. </div>
  1534. {/* Notification Language Setting */}
  1535. <Card className="mb-4">
  1536. <CardContent className="py-3">
  1537. <div className="flex items-center justify-between">
  1538. <div>
  1539. <p className="text-white text-sm font-medium">{t('settings.notificationLanguage')}</p>
  1540. <p className="text-xs text-bambu-gray">{t('settings.notificationLanguageDescription')}</p>
  1541. </div>
  1542. <select
  1543. value={localSettings.notification_language || 'en'}
  1544. onChange={(e) => updateSetting('notification_language', e.target.value)}
  1545. 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"
  1546. >
  1547. {availableLanguages.map((lang) => (
  1548. <option key={lang.code} value={lang.code}>
  1549. {lang.nativeName}
  1550. </option>
  1551. ))}
  1552. </select>
  1553. </div>
  1554. </CardContent>
  1555. </Card>
  1556. {/* Test All Results */}
  1557. {testAllResult && (
  1558. <Card className="mb-4">
  1559. <CardContent className="py-3">
  1560. <div className="flex items-center justify-between mb-2">
  1561. <span className="text-sm font-medium text-white">Test Results</span>
  1562. <button
  1563. onClick={() => setTestAllResult(null)}
  1564. className="text-bambu-gray hover:text-white text-xs"
  1565. >
  1566. Dismiss
  1567. </button>
  1568. </div>
  1569. <div className="flex items-center gap-4 text-sm mb-2">
  1570. <span className="flex items-center gap-1 text-bambu-green">
  1571. <CheckCircle className="w-4 h-4" />
  1572. {testAllResult.success} passed
  1573. </span>
  1574. {testAllResult.failed > 0 && (
  1575. <span className="flex items-center gap-1 text-red-400">
  1576. <XCircle className="w-4 h-4" />
  1577. {testAllResult.failed} failed
  1578. </span>
  1579. )}
  1580. </div>
  1581. {testAllResult.results.filter(r => !r.success).length > 0 && (
  1582. <div className="space-y-1 mt-2 pt-2 border-t border-bambu-dark-tertiary">
  1583. {testAllResult.results.filter(r => !r.success).map((result) => (
  1584. <div key={result.provider_id} className="text-xs text-red-400">
  1585. <span className="font-medium">{result.provider_name}:</span> {result.message}
  1586. </div>
  1587. ))}
  1588. </div>
  1589. )}
  1590. </CardContent>
  1591. </Card>
  1592. )}
  1593. {providersLoading ? (
  1594. <div className="flex justify-center py-12">
  1595. <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
  1596. </div>
  1597. ) : notificationProviders && notificationProviders.length > 0 ? (
  1598. <div className="space-y-3">
  1599. {notificationProviders.map((provider) => (
  1600. <NotificationProviderCard
  1601. key={provider.id}
  1602. provider={provider}
  1603. onEdit={(p) => {
  1604. setEditingProvider(p);
  1605. setShowNotificationModal(true);
  1606. }}
  1607. />
  1608. ))}
  1609. </div>
  1610. ) : (
  1611. <Card>
  1612. <CardContent className="py-8">
  1613. <div className="text-center text-bambu-gray">
  1614. <Bell className="w-12 h-12 mx-auto mb-3 opacity-30" />
  1615. <p className="text-sm font-medium text-white mb-2">No providers configured</p>
  1616. <p className="text-xs mb-3">Add a provider to receive alerts.</p>
  1617. <Button
  1618. size="sm"
  1619. onClick={() => {
  1620. setEditingProvider(null);
  1621. setShowNotificationModal(true);
  1622. }}
  1623. >
  1624. <Plus className="w-4 h-4" />
  1625. Add Provider
  1626. </Button>
  1627. </div>
  1628. </CardContent>
  1629. </Card>
  1630. )}
  1631. </div>
  1632. {/* Right Column: Templates */}
  1633. <div>
  1634. <h2 className="text-lg font-semibold text-white flex items-center gap-2 mb-4">
  1635. <FileText className="w-5 h-5 text-bambu-green" />
  1636. Message Templates
  1637. </h2>
  1638. <p className="text-sm text-bambu-gray mb-4">
  1639. Customize notification messages for each event.
  1640. </p>
  1641. {templatesLoading ? (
  1642. <div className="flex justify-center py-8">
  1643. <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
  1644. </div>
  1645. ) : notificationTemplates && notificationTemplates.length > 0 ? (
  1646. <div className="space-y-2">
  1647. {notificationTemplates.map((template) => (
  1648. <Card
  1649. key={template.id}
  1650. className="cursor-pointer hover:border-bambu-green/50 transition-colors"
  1651. onClick={() => setEditingTemplate(template)}
  1652. >
  1653. <CardContent className="py-2.5 px-3">
  1654. <div className="flex items-center justify-between">
  1655. <div className="min-w-0 flex-1">
  1656. <p className="text-white font-medium text-sm truncate">{template.name}</p>
  1657. <p className="text-bambu-gray text-xs truncate mt-0.5">
  1658. {template.title_template}
  1659. </p>
  1660. </div>
  1661. <button
  1662. className="p-1.5 hover:bg-bambu-dark-tertiary rounded transition-colors shrink-0 ml-2"
  1663. onClick={(e) => {
  1664. e.stopPropagation();
  1665. setEditingTemplate(template);
  1666. }}
  1667. >
  1668. <Edit2 className="w-4 h-4 text-bambu-gray" />
  1669. </button>
  1670. </div>
  1671. </CardContent>
  1672. </Card>
  1673. ))}
  1674. </div>
  1675. ) : (
  1676. <Card>
  1677. <CardContent className="py-8">
  1678. <div className="text-center text-bambu-gray">
  1679. <FileText className="w-12 h-12 mx-auto mb-3 opacity-30" />
  1680. <p className="text-sm">No templates available. Restart the backend to seed default templates.</p>
  1681. </div>
  1682. </CardContent>
  1683. </Card>
  1684. )}
  1685. </div>
  1686. </div>
  1687. )}
  1688. {/* API Keys Tab */}
  1689. {activeTab === 'apikeys' && (
  1690. <div className="grid grid-cols-1 xl:grid-cols-2 gap-8">
  1691. {/* Left Column - API Keys Management */}
  1692. <div>
  1693. <div className="flex items-start justify-between gap-4 mb-6">
  1694. <div className="flex-1">
  1695. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  1696. <Key className="w-5 h-5 text-bambu-green" />
  1697. API Keys
  1698. </h2>
  1699. <p className="text-sm text-bambu-gray mt-1">
  1700. Create API keys for external integrations and webhooks.
  1701. </p>
  1702. </div>
  1703. <Button size="sm" onClick={() => setShowCreateAPIKey(true)} className="flex-shrink-0">
  1704. <Plus className="w-4 h-4" />
  1705. Create Key
  1706. </Button>
  1707. </div>
  1708. {/* Created Key Display */}
  1709. {createdAPIKey && (
  1710. <Card className="mb-6 border-bambu-green">
  1711. <CardContent className="py-4">
  1712. <div className="flex items-start gap-3">
  1713. <CheckCircle className="w-5 h-5 text-bambu-green flex-shrink-0 mt-0.5" />
  1714. <div className="flex-1">
  1715. <p className="text-white font-medium mb-1">API Key Created Successfully</p>
  1716. <p className="text-sm text-bambu-gray mb-2">
  1717. Copy this key now - it won't be shown again!
  1718. </p>
  1719. <div className="flex items-center gap-2 bg-bambu-dark rounded-lg p-2">
  1720. <code className="flex-1 text-sm text-bambu-green font-mono break-all">
  1721. {createdAPIKey}
  1722. </code>
  1723. <Button
  1724. variant="secondary"
  1725. size="sm"
  1726. onClick={async () => {
  1727. try {
  1728. if (navigator.clipboard && navigator.clipboard.writeText) {
  1729. await navigator.clipboard.writeText(createdAPIKey);
  1730. } else {
  1731. const textArea = document.createElement('textarea');
  1732. textArea.value = createdAPIKey;
  1733. textArea.style.position = 'fixed';
  1734. textArea.style.left = '-999999px';
  1735. document.body.appendChild(textArea);
  1736. textArea.select();
  1737. document.execCommand('copy');
  1738. document.body.removeChild(textArea);
  1739. }
  1740. showToast('Key copied to clipboard');
  1741. } catch {
  1742. showToast('Failed to copy key', 'error');
  1743. }
  1744. }}
  1745. >
  1746. <Copy className="w-4 h-4" />
  1747. </Button>
  1748. </div>
  1749. <div className="flex gap-2 mt-3">
  1750. <Button
  1751. variant="secondary"
  1752. size="sm"
  1753. onClick={() => {
  1754. setTestApiKey(createdAPIKey);
  1755. showToast('Key added to API Browser');
  1756. }}
  1757. >
  1758. Use in API Browser
  1759. </Button>
  1760. <Button
  1761. variant="secondary"
  1762. size="sm"
  1763. onClick={() => setCreatedAPIKey(null)}
  1764. >
  1765. Dismiss
  1766. </Button>
  1767. </div>
  1768. </div>
  1769. </div>
  1770. </CardContent>
  1771. </Card>
  1772. )}
  1773. {/* Create Key Form */}
  1774. {showCreateAPIKey && (
  1775. <Card className="mb-6">
  1776. <CardHeader>
  1777. <h3 className="text-base font-semibold text-white">Create New API Key</h3>
  1778. </CardHeader>
  1779. <CardContent className="space-y-4">
  1780. <div>
  1781. <label className="block text-sm text-bambu-gray mb-1">Key Name</label>
  1782. <input
  1783. type="text"
  1784. value={newAPIKeyName}
  1785. onChange={(e) => setNewAPIKeyName(e.target.value)}
  1786. placeholder="e.g., Home Assistant, OctoPrint"
  1787. 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"
  1788. />
  1789. </div>
  1790. <div>
  1791. <label className="block text-sm text-bambu-gray mb-2">Permissions</label>
  1792. <div className="space-y-2">
  1793. <label className="flex items-center gap-3 cursor-pointer">
  1794. <input
  1795. type="checkbox"
  1796. checked={newAPIKeyPermissions.can_read_status}
  1797. onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_read_status: e.target.checked }))}
  1798. className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
  1799. />
  1800. <div>
  1801. <span className="text-white">Read Status</span>
  1802. <p className="text-xs text-bambu-gray">View printer status and queue</p>
  1803. </div>
  1804. </label>
  1805. <label className="flex items-center gap-3 cursor-pointer">
  1806. <input
  1807. type="checkbox"
  1808. checked={newAPIKeyPermissions.can_queue}
  1809. onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_queue: e.target.checked }))}
  1810. className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
  1811. />
  1812. <div>
  1813. <span className="text-white">Manage Queue</span>
  1814. <p className="text-xs text-bambu-gray">Add and remove items from print queue</p>
  1815. </div>
  1816. </label>
  1817. <label className="flex items-center gap-3 cursor-pointer">
  1818. <input
  1819. type="checkbox"
  1820. checked={newAPIKeyPermissions.can_control_printer}
  1821. onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_control_printer: e.target.checked }))}
  1822. className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
  1823. />
  1824. <div>
  1825. <span className="text-white">Control Printer</span>
  1826. <p className="text-xs text-bambu-gray">Pause, resume, and stop prints</p>
  1827. </div>
  1828. </label>
  1829. </div>
  1830. </div>
  1831. <div className="flex items-center gap-2 pt-2">
  1832. <Button
  1833. onClick={() => createAPIKeyMutation.mutate({
  1834. name: newAPIKeyName || 'Unnamed Key',
  1835. ...newAPIKeyPermissions,
  1836. })}
  1837. disabled={createAPIKeyMutation.isPending}
  1838. >
  1839. {createAPIKeyMutation.isPending ? (
  1840. <Loader2 className="w-4 h-4 animate-spin" />
  1841. ) : (
  1842. <Plus className="w-4 h-4" />
  1843. )}
  1844. Create Key
  1845. </Button>
  1846. <Button variant="secondary" onClick={() => setShowCreateAPIKey(false)}>
  1847. Cancel
  1848. </Button>
  1849. </div>
  1850. </CardContent>
  1851. </Card>
  1852. )}
  1853. {/* Existing Keys List */}
  1854. {apiKeysLoading ? (
  1855. <div className="flex justify-center py-12">
  1856. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  1857. </div>
  1858. ) : apiKeys && apiKeys.length > 0 ? (
  1859. <div className="space-y-3">
  1860. {apiKeys.map((key) => (
  1861. <Card key={key.id}>
  1862. <CardContent className="py-3">
  1863. <div className="flex items-center justify-between">
  1864. <div className="flex items-center gap-3">
  1865. <Key className={`w-5 h-5 ${key.enabled ? 'text-bambu-green' : 'text-bambu-gray'}`} />
  1866. <div>
  1867. <p className="text-white font-medium">{key.name}</p>
  1868. <p className="text-xs text-bambu-gray">
  1869. {key.key_prefix}••••••••
  1870. {key.last_used && ` · Last used: ${formatDateOnly(key.last_used)}`}
  1871. </p>
  1872. </div>
  1873. </div>
  1874. <div className="flex items-center gap-2">
  1875. <div className="flex gap-1 text-xs">
  1876. {key.can_read_status && (
  1877. <span className="px-1.5 py-0.5 bg-blue-500/20 text-blue-400 rounded">Read</span>
  1878. )}
  1879. {key.can_queue && (
  1880. <span className="px-1.5 py-0.5 bg-green-500/20 text-green-400 rounded">Queue</span>
  1881. )}
  1882. {key.can_control_printer && (
  1883. <span className="px-1.5 py-0.5 bg-orange-500/20 text-orange-400 rounded">Control</span>
  1884. )}
  1885. </div>
  1886. <Button
  1887. variant="secondary"
  1888. size="sm"
  1889. onClick={() => setShowDeleteAPIKeyConfirm(key.id)}
  1890. >
  1891. <Trash2 className="w-4 h-4 text-red-400" />
  1892. </Button>
  1893. </div>
  1894. </div>
  1895. </CardContent>
  1896. </Card>
  1897. ))}
  1898. </div>
  1899. ) : (
  1900. <Card>
  1901. <CardContent className="py-12">
  1902. <div className="text-center text-bambu-gray">
  1903. <Key className="w-16 h-16 mx-auto mb-4 opacity-30" />
  1904. <p className="text-lg font-medium text-white mb-2">No API keys</p>
  1905. <p className="text-sm mb-4">Create an API key to integrate with external services.</p>
  1906. <Button onClick={() => setShowCreateAPIKey(true)}>
  1907. <Plus className="w-4 h-4" />
  1908. Create Your First Key
  1909. </Button>
  1910. </div>
  1911. </CardContent>
  1912. </Card>
  1913. )}
  1914. {/* Webhook Documentation */}
  1915. <Card className="mt-6">
  1916. <CardHeader>
  1917. <h3 className="text-base font-semibold text-white">Webhook Endpoints</h3>
  1918. </CardHeader>
  1919. <CardContent className="space-y-3 text-sm">
  1920. <p className="text-bambu-gray">
  1921. Use your API key in the <code className="text-bambu-green">X-API-Key</code> header.
  1922. </p>
  1923. <div className="space-y-2 font-mono text-xs">
  1924. <div className="p-2 bg-bambu-dark rounded">
  1925. <span className="text-blue-400">GET</span>{' '}
  1926. <span className="text-white">/api/v1/webhook/status</span>
  1927. <span className="text-bambu-gray"> - Get all printer status</span>
  1928. </div>
  1929. <div className="p-2 bg-bambu-dark rounded">
  1930. <span className="text-blue-400">GET</span>{' '}
  1931. <span className="text-white">/api/v1/webhook/status/:id</span>
  1932. <span className="text-bambu-gray"> - Get specific printer status</span>
  1933. </div>
  1934. <div className="p-2 bg-bambu-dark rounded">
  1935. <span className="text-green-400">POST</span>{' '}
  1936. <span className="text-white">/api/v1/webhook/queue</span>
  1937. <span className="text-bambu-gray"> - Add to print queue</span>
  1938. </div>
  1939. <div className="p-2 bg-bambu-dark rounded">
  1940. <span className="text-orange-400">POST</span>{' '}
  1941. <span className="text-white">/api/v1/webhook/printer/:id/pause</span>
  1942. <span className="text-bambu-gray"> - Pause print</span>
  1943. </div>
  1944. <div className="p-2 bg-bambu-dark rounded">
  1945. <span className="text-orange-400">POST</span>{' '}
  1946. <span className="text-white">/api/v1/webhook/printer/:id/resume</span>
  1947. <span className="text-bambu-gray"> - Resume print</span>
  1948. </div>
  1949. <div className="p-2 bg-bambu-dark rounded">
  1950. <span className="text-red-400">POST</span>{' '}
  1951. <span className="text-white">/api/v1/webhook/printer/:id/stop</span>
  1952. <span className="text-bambu-gray"> - Stop print</span>
  1953. </div>
  1954. </div>
  1955. </CardContent>
  1956. </Card>
  1957. </div>
  1958. {/* Right Column - API Browser */}
  1959. <div>
  1960. <div className="mb-6">
  1961. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  1962. <Globe className="w-5 h-5 text-bambu-green" />
  1963. API Browser
  1964. </h2>
  1965. <p className="text-sm text-bambu-gray mt-1">
  1966. Explore and test all available API endpoints.
  1967. </p>
  1968. </div>
  1969. {/* API Key Input for Testing */}
  1970. <Card className="mb-4">
  1971. <CardContent className="py-3">
  1972. <label className="block text-sm text-bambu-gray mb-2">API Key for Testing</label>
  1973. <input
  1974. type="text"
  1975. value={testApiKey}
  1976. onChange={(e) => setTestApiKey(e.target.value)}
  1977. placeholder="Paste your API key here to test authenticated endpoints..."
  1978. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white font-mono text-sm focus:border-bambu-green focus:outline-none"
  1979. />
  1980. <p className="text-xs text-bambu-gray mt-2">
  1981. This key will be sent as <code className="text-bambu-green">X-API-Key</code> header with requests.
  1982. </p>
  1983. </CardContent>
  1984. </Card>
  1985. <APIBrowser apiKey={testApiKey} />
  1986. </div>
  1987. </div>
  1988. )}
  1989. {/* Virtual Printer Tab */}
  1990. {activeTab === 'virtual-printer' && (
  1991. <VirtualPrinterSettings />
  1992. )}
  1993. {/* Filament Tab */}
  1994. {activeTab === 'filament' && (
  1995. <div className="max-w-2xl">
  1996. <SpoolmanSettings />
  1997. </div>
  1998. )}
  1999. {/* Delete API Key Confirmation */}
  2000. {showDeleteAPIKeyConfirm !== null && (
  2001. <ConfirmModal
  2002. title="Delete API Key"
  2003. message="Are you sure you want to delete this API key? Any integrations using this key will stop working."
  2004. confirmText="Delete Key"
  2005. variant="danger"
  2006. onConfirm={() => {
  2007. deleteAPIKeyMutation.mutate(showDeleteAPIKeyConfirm);
  2008. setShowDeleteAPIKeyConfirm(null);
  2009. }}
  2010. onCancel={() => setShowDeleteAPIKeyConfirm(null)}
  2011. />
  2012. )}
  2013. {/* Smart Plug Modal */}
  2014. {showPlugModal && (
  2015. <AddSmartPlugModal
  2016. plug={editingPlug}
  2017. onClose={() => {
  2018. setShowPlugModal(false);
  2019. setEditingPlug(null);
  2020. }}
  2021. />
  2022. )}
  2023. {/* Notification Modal */}
  2024. {showNotificationModal && (
  2025. <AddNotificationModal
  2026. provider={editingProvider}
  2027. onClose={() => {
  2028. setShowNotificationModal(false);
  2029. setEditingProvider(null);
  2030. }}
  2031. />
  2032. )}
  2033. {/* Template Editor Modal */}
  2034. {editingTemplate && (
  2035. <NotificationTemplateEditor
  2036. template={editingTemplate}
  2037. onClose={() => setEditingTemplate(null)}
  2038. />
  2039. )}
  2040. {/* Notification Log Viewer */}
  2041. {showLogViewer && (
  2042. <NotificationLogViewer
  2043. onClose={() => setShowLogViewer(false)}
  2044. />
  2045. )}
  2046. {/* Confirm Modal: Clear Notification Logs */}
  2047. {showClearLogsConfirm && (
  2048. <ConfirmModal
  2049. title="Clear Notification Logs"
  2050. message="This will permanently delete all notification logs older than 30 days. This action cannot be undone."
  2051. confirmText="Clear Logs"
  2052. variant="warning"
  2053. onConfirm={async () => {
  2054. setShowClearLogsConfirm(false);
  2055. try {
  2056. const result = await api.clearNotificationLogs(30);
  2057. showToast(result.message, 'success');
  2058. } catch {
  2059. showToast('Failed to clear logs', 'error');
  2060. }
  2061. }}
  2062. onCancel={() => setShowClearLogsConfirm(false)}
  2063. />
  2064. )}
  2065. {/* Confirm Modal: Clear Local Storage */}
  2066. {showClearStorageConfirm && (
  2067. <ConfirmModal
  2068. title="Reset UI Preferences"
  2069. 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."
  2070. confirmText="Reset Preferences"
  2071. variant="default"
  2072. onConfirm={() => {
  2073. setShowClearStorageConfirm(false);
  2074. localStorage.clear();
  2075. showToast('UI preferences reset. Refreshing...', 'success');
  2076. setTimeout(() => window.location.reload(), 1000);
  2077. }}
  2078. onCancel={() => setShowClearStorageConfirm(false)}
  2079. />
  2080. )}
  2081. {/* Confirm Modal: Bulk Plug Action */}
  2082. {showBulkPlugConfirm && (
  2083. <ConfirmModal
  2084. title={`Turn All Plugs ${showBulkPlugConfirm === 'on' ? 'On' : 'Off'}`}
  2085. 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!' : ''}`}
  2086. confirmText={`Turn All ${showBulkPlugConfirm === 'on' ? 'On' : 'Off'}`}
  2087. variant={showBulkPlugConfirm === 'off' ? 'danger' : 'warning'}
  2088. onConfirm={() => {
  2089. const action = showBulkPlugConfirm;
  2090. setShowBulkPlugConfirm(null);
  2091. bulkPlugActionMutation.mutate(action);
  2092. }}
  2093. onCancel={() => setShowBulkPlugConfirm(null)}
  2094. />
  2095. )}
  2096. {/* Backup Modal */}
  2097. {showBackupModal && (
  2098. <BackupModal
  2099. onClose={() => setShowBackupModal(false)}
  2100. onExport={async (categories) => {
  2101. setShowBackupModal(false);
  2102. const toastId = 'backup-progress';
  2103. const includesArchives = categories.archives;
  2104. // Show persistent loading toast for archive backups (can be large)
  2105. if (includesArchives) {
  2106. showPersistentToast(toastId, t('backup.preparing', { defaultValue: 'Preparing backup...' }), 'loading');
  2107. }
  2108. try {
  2109. const { blob, filename } = await api.exportBackup(categories);
  2110. // Dismiss loading toast before download starts
  2111. if (includesArchives) {
  2112. dismissToast(toastId);
  2113. }
  2114. const url = URL.createObjectURL(blob);
  2115. const a = document.createElement('a');
  2116. a.href = url;
  2117. a.download = filename;
  2118. a.click();
  2119. URL.revokeObjectURL(url);
  2120. showToast(t('backup.downloaded', { defaultValue: 'Backup downloaded' }), 'success');
  2121. } catch {
  2122. // Dismiss loading toast on error
  2123. if (includesArchives) {
  2124. dismissToast(toastId);
  2125. }
  2126. showToast(t('backup.failed', { defaultValue: 'Failed to create backup' }), 'error');
  2127. }
  2128. }}
  2129. />
  2130. )}
  2131. {/* Restore Modal */}
  2132. {showRestoreModal && (
  2133. <RestoreModal
  2134. onClose={() => setShowRestoreModal(false)}
  2135. onRestore={async (file, overwrite) => {
  2136. return await api.importBackup(file, overwrite);
  2137. }}
  2138. onSuccess={() => {
  2139. // Reset local settings to force re-sync from restored data
  2140. setLocalSettings(null);
  2141. isInitialLoadRef.current = true;
  2142. queryClient.invalidateQueries();
  2143. }}
  2144. />
  2145. )}
  2146. {/* Telemetry Info Modal */}
  2147. {showTelemetryInfo && (
  2148. <div
  2149. className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
  2150. onClick={() => setShowTelemetryInfo(false)}
  2151. >
  2152. <Card className="w-full max-w-lg" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
  2153. <CardHeader className="flex flex-row items-center justify-between">
  2154. <div className="flex items-center gap-2">
  2155. <Shield className="w-5 h-5 text-bambu-green" />
  2156. <h2 className="text-lg font-semibold text-white">{t('settings.telemetryInfoTitle')}</h2>
  2157. </div>
  2158. <button
  2159. onClick={() => setShowTelemetryInfo(false)}
  2160. className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white"
  2161. >
  2162. <X className="w-5 h-5" />
  2163. </button>
  2164. </CardHeader>
  2165. <CardContent className="space-y-4">
  2166. <p className="text-bambu-gray text-sm">
  2167. {t('settings.telemetryInfoIntro')}
  2168. </p>
  2169. <div className="space-y-3">
  2170. <h3 className="text-white font-medium">{t('settings.telemetryInfoCollected')}</h3>
  2171. <ul className="space-y-2 text-sm">
  2172. <li className="flex items-start gap-2 text-bambu-gray">
  2173. <CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 shrink-0" />
  2174. <span>{t('settings.telemetryInfoItem1')}</span>
  2175. </li>
  2176. <li className="flex items-start gap-2 text-bambu-gray">
  2177. <CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 shrink-0" />
  2178. <span>{t('settings.telemetryInfoItem2')}</span>
  2179. </li>
  2180. <li className="flex items-start gap-2 text-bambu-gray">
  2181. <CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 shrink-0" />
  2182. <span>{t('settings.telemetryInfoItem3')}</span>
  2183. </li>
  2184. <li className="flex items-start gap-2 text-bambu-gray">
  2185. <CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 shrink-0" />
  2186. <span>{t('settings.telemetryInfoItem4')}</span>
  2187. </li>
  2188. </ul>
  2189. </div>
  2190. <div className="space-y-3">
  2191. <h3 className="text-white font-medium">{t('settings.telemetryInfoNotCollected')}</h3>
  2192. <ul className="space-y-2 text-sm">
  2193. <li className="flex items-start gap-2 text-bambu-gray">
  2194. <XCircle className="w-4 h-4 text-red-400 mt-0.5 shrink-0" />
  2195. <span>{t('settings.telemetryInfoNotItem1')}</span>
  2196. </li>
  2197. <li className="flex items-start gap-2 text-bambu-gray">
  2198. <XCircle className="w-4 h-4 text-red-400 mt-0.5 shrink-0" />
  2199. <span>{t('settings.telemetryInfoNotItem2')}</span>
  2200. </li>
  2201. <li className="flex items-start gap-2 text-bambu-gray">
  2202. <XCircle className="w-4 h-4 text-red-400 mt-0.5 shrink-0" />
  2203. <span>{t('settings.telemetryInfoNotItem3')}</span>
  2204. </li>
  2205. <li className="flex items-start gap-2 text-bambu-gray">
  2206. <XCircle className="w-4 h-4 text-red-400 mt-0.5 shrink-0" />
  2207. <span>{t('settings.telemetryInfoNotItem4')}</span>
  2208. </li>
  2209. </ul>
  2210. </div>
  2211. <p className="text-bambu-gray text-xs border-t border-bambu-dark-tertiary pt-4">
  2212. {t('settings.telemetryInfoFooter')}
  2213. </p>
  2214. <Button
  2215. onClick={() => setShowTelemetryInfo(false)}
  2216. className="w-full"
  2217. >
  2218. {t('common.close')}
  2219. </Button>
  2220. </CardContent>
  2221. </Card>
  2222. </div>
  2223. )}
  2224. {/* Release Notes Modal */}
  2225. {showReleaseNotes && updateCheck?.release_notes && (
  2226. <div
  2227. className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
  2228. onClick={() => setShowReleaseNotes(false)}
  2229. >
  2230. <Card className="w-full max-w-2xl max-h-[80vh] flex flex-col" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
  2231. <CardHeader className="flex flex-row items-center justify-between shrink-0">
  2232. <div>
  2233. <h2 className="text-lg font-semibold text-white">
  2234. Release Notes - v{updateCheck.latest_version}
  2235. </h2>
  2236. {updateCheck.release_name && updateCheck.release_name !== updateCheck.latest_version && (
  2237. <p className="text-sm text-bambu-gray">{updateCheck.release_name}</p>
  2238. )}
  2239. </div>
  2240. <button
  2241. onClick={() => setShowReleaseNotes(false)}
  2242. className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white"
  2243. >
  2244. <X className="w-5 h-5" />
  2245. </button>
  2246. </CardHeader>
  2247. <CardContent className="overflow-y-auto flex-1">
  2248. <pre className="text-sm text-bambu-gray whitespace-pre-wrap font-sans">
  2249. {updateCheck.release_notes}
  2250. </pre>
  2251. </CardContent>
  2252. <div className="p-4 border-t border-bambu-dark-tertiary shrink-0 flex gap-2">
  2253. {updateCheck.release_url && (
  2254. <a
  2255. href={updateCheck.release_url}
  2256. target="_blank"
  2257. rel="noopener noreferrer"
  2258. className="flex-1"
  2259. >
  2260. <Button variant="secondary" className="w-full">
  2261. <ExternalLink className="w-4 h-4" />
  2262. View on GitHub
  2263. </Button>
  2264. </a>
  2265. )}
  2266. <Button
  2267. onClick={() => setShowReleaseNotes(false)}
  2268. className="flex-1"
  2269. >
  2270. Close
  2271. </Button>
  2272. </div>
  2273. </Card>
  2274. </div>
  2275. )}
  2276. </div>
  2277. );
  2278. }